diff --git a/docs/project/bindgen.mdx b/docs/project/bindgen.mdx index d944be199141..1015f33b9d21 100644 --- a/docs/project/bindgen.mdx +++ b/docs/project/bindgen.mdx @@ -448,7 +448,8 @@ thread. **A shared buffer** is borrowed like any other, as JSC's own `toBase64` and `indexOf` read one, and another thread that writes it during the call changes what the body reads. `shared: "copied"` is for a body that reads a byte again and counts on what it was, such as a parser that scans ahead -and comes back. JSC's `sort` copies for the same reason. +and comes back. JSC's `sort` copies for the same reason. With `string: "asIs"` the body calls +`JsBytes::get_or_copy_if_shared` instead. The kind of the value is checked, and a string converted, in the order of the arguments. The bytes of a buffer are read, copied or pinned after every argument is converted. It is not a diff --git a/docs/runtime/http/websockets.mdx b/docs/runtime/http/websockets.mdx index 908cf0677125..4108a2de5209 100644 --- a/docs/runtime/http/websockets.mdx +++ b/docs/runtime/http/websockets.mdx @@ -357,7 +357,7 @@ ws.terminate(); // no Close frame - `0`, `1005` and `1006` send a Close frame with no code, so you can forward the code from a `close` handler to another socket. - The reason is truncated to 123 bytes of UTF-8. -When the client violates the protocol, Bun sends a Close frame with `1002` (protocol error), `1007` (invalid UTF-8 or compressed data) or `1009` (message too big). The `close` handler receives `1006`, with a description of the problem as the reason. +When the client violates the protocol, Bun sends a Close frame with `1002` (protocol error), `1007` (invalid UTF-8 or compressed data) or `1009` (message too big). If a buffer for a message cannot be allocated, Bun sends `1011` and closes that connection only. The `close` handler receives `1006`, with a description of the problem as the reason. `server.stop()` doesn't close open WebSockets. `server.stop(true)` closes each one with `1001`. diff --git a/packages/bun-types/ffi.d.ts b/packages/bun-types/ffi.d.ts index 93584ffc457d..7af21e4dde7a 100644 --- a/packages/bun-types/ffi.d.ts +++ b/packages/bun-types/ffi.d.ts @@ -536,7 +536,8 @@ declare module "bun:ffi" { * * Once called, the library is no longer usable. * - * Calling a function from a library that has been closed is undefined behavior. + * Calling a symbol from a closed {@link cc} library throws a `TypeError`. + * Calling a symbol from a closed {@link dlopen} or {@link linkSymbols} library is undefined behavior. */ close(): void; } diff --git a/packages/bun-types/jsc.d.ts b/packages/bun-types/jsc.d.ts index 6af8f36e5656..d97a48b79a1a 100644 --- a/packages/bun-types/jsc.d.ts +++ b/packages/bun-types/jsc.d.ts @@ -802,7 +802,8 @@ declare module "bun:jsc" { * value, use {@link profile} instead. * * @param optionalDirectory A directory to write a text report of the hottest - * functions and bytecodes into when the process exits. Created if it does not exist. + * functions and bytecodes to when the process (or the calling worker) exits. + * The directory is created if it doesn't exist. * @param sampleInterval How often to sample the stack, in microseconds. Defaults to 1000 (once a millisecond). */ function startSamplingProfiler(optionalDirectory?: string, sampleInterval?: number): void; diff --git a/packages/bun-usockets/src/context.c b/packages/bun-usockets/src/context.c index 86ccc3dbb3ca..2035ce089304 100644 --- a/packages/bun-usockets/src/context.c +++ b/packages/bun-usockets/src/context.c @@ -74,6 +74,17 @@ void us_socket_group_deinit(struct us_socket_group_t *group) { * closes it in finalize(), so closing+freeing it here turns that into a UAF * (the original LSAN was only the *accepted* sockets, not the listener). */ void us_socket_group_close_all_ex(struct us_socket_group_t *group, int also_listeners) { + struct us_loop_t *loop = group->loop; + if (!loop) { + /* A Windows named-pipe Listener never initializes its group. */ + return; + } + + /* A close handler can tick the loop (waitForPromise). Bump tick_depth so + * that nested tick doesn't free the sockets closed here while this loop and + * Listener.stop still read them. */ + loop->data.tick_depth++; + if (also_listeners) { /* Listeners first — stops new sockets from being accepted into * head_sockets while we're draining it. */ @@ -141,6 +152,8 @@ void us_socket_group_close_all_ex(struct us_socket_group_t *group, int also_list } US_ASSERT(group->low_prio_count == 0); } + + loop->data.tick_depth--; } void us_socket_group_close_all(struct us_socket_group_t *group) { @@ -464,7 +477,7 @@ void us_listen_socket_close(struct us_listen_socket_t *ls) { struct us_socket_group_t *group = ls->accept_group; struct us_loop_t *loop = s->group->loop; us_poll_stop((struct us_poll_t *) s, loop); - bsd_close_socket(us_poll_fd((struct us_poll_t *) s)); + us_internal_poll_close_fd((struct us_poll_t *) s); us_internal_listen_socket_ssl_free(ls); diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 57b9c1a11082..1523165a4c3d 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -127,7 +127,6 @@ enum { /* SNI tree leaf — stored as the void* user in sni_tree.cpp. */ struct sni_node_t { SSL_CTX *ctx; - void *user; }; static _Atomic long ssl_ctx_live = 0; @@ -140,7 +139,6 @@ long us_ssl_ctx_live_count(void) { * - us_ctx_ex_idx (SSL_CTX): packed reneg {limit:u32,window:u32}; its * free_func also decrements ssl_ctx_live so the counter tracks ACTUAL * destruction (refcount→0), not every SSL_CTX_free. - * - us_sni_ex_idx (SSL_CTX): per-domain userdata (uWS HttpRouter*). * - us_ssl_rare_ex_idx (SSL): us_ssl_rare_t. * - us_ssl_wrapper_ex_idx (SSL): the owner of an SSL that no us_socket_t drives. * @@ -151,7 +149,6 @@ long us_ssl_ctx_live_count(void) { * internal-only, so use the platform primitive directly; root_certs.cpp does * the same via std::call_once.) */ static int us_ctx_ex_idx = -1; -static int us_sni_ex_idx = -1; static int us_ctx_cache_ex_idx = -1; /* Marks an SSL_CTX whose verification store holds user-provided CAs (the * ca/caFile options or a later addCACert): the per-socket client attach must @@ -373,7 +370,6 @@ extern void bun_ssl_ctx_cache_on_free(void *parent, void *ptr, CRYPTO_EX_DATA *a static void us_ex_idx_init(void) { us_ctx_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, us_ctx_ex_free); - 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_sni_policy_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, NULL); @@ -3394,7 +3390,7 @@ static int sni_cb(SSL *ssl, int *al, void *arg) { void us_listen_socket_add_server_name(struct us_listen_socket_t *ls, const char *hostname_pattern, - SSL_CTX *ctx, void *user) { + SSL_CTX *ctx) { SSL_CTX *default_ctx = ls->ssl_ctx; if (!default_ctx) return; @@ -3408,22 +3404,11 @@ void us_listen_socket_add_server_name(struct us_listen_socket_t *ls, struct sni_node_t *node = us_malloc(sizeof(struct sni_node_t)); node->ctx = ctx; - node->user = user; SSL_CTX_up_ref(ctx); - /* Stash userdata on the SSL_CTX too so per-socket lookup via - * SSL_get_SSL_CTX works regardless of which ctx the SNI cb selected. */ - us_ex_idx_ensure(); - SSL_CTX_set_ex_data(ctx, us_sni_ex_idx, user); sni_node_destructor(sni_add(names->tree, hostname_pattern, node)); } -void *us_listen_socket_find_server_name_userdata(struct us_listen_socket_t *ls, - const char *hostname_pattern) { - struct sni_node_t *node = us_server_names_find(ls->server_names, hostname_pattern); - return node ? node->user : NULL; -} - /* Returns the SSL_CTX registered for `hostname_pattern` via * us_listen_socket_add_server_name, or NULL. Owned - the caller must release * the reference. The on_server_name resolvers return owned references (the @@ -3481,11 +3466,6 @@ void us_socket_on_server_name(struct us_socket_t *s) { if (ctx) SSL_CTX_set_select_certificate_cb(ctx, us_select_cert_cb); } -void *us_socket_server_name_userdata(struct us_socket_t *s) { - if (!s->ssl || !s_ssl(s) || us_sni_ex_idx < 0) return NULL; - return SSL_CTX_get_ex_data(SSL_get_SSL_CTX(s_ssl(s)), us_sni_ex_idx); -} - const char *us_internal_ssl_sni_servername(struct us_socket_t *s) { if (!s->ssl || !s_ssl(s)) return NULL; return SSL_get_servername(s_ssl(s), TLSEXT_NAMETYPE_host_name); diff --git a/packages/bun-usockets/src/eventing/epoll_kqueue.c b/packages/bun-usockets/src/eventing/epoll_kqueue.c index 4d017cc34220..6e603ba7bed4 100644 --- a/packages/bun-usockets/src/eventing/epoll_kqueue.c +++ b/packages/bun-usockets/src/eventing/epoll_kqueue.c @@ -786,6 +786,10 @@ void us_poll_stop(struct us_poll_t *p, struct us_loop_t *loop) { us_internal_loop_update_pending_ready_polls(loop, p, 0, old_events, new_events); } +void us_internal_poll_close_fd(struct us_poll_t *p) { + bsd_close_socket(us_poll_fd(p)); +} + size_t us_internal_accept_poll_event(struct us_poll_t *p) { #ifdef LIBUS_USE_EPOLL int fd = us_poll_fd(p); diff --git a/packages/bun-usockets/src/eventing/libuv.c b/packages/bun-usockets/src/eventing/libuv.c index a76d0e73f48d..5b6b9bf10c55 100644 --- a/packages/bun-usockets/src/eventing/libuv.c +++ b/packages/bun-usockets/src/eventing/libuv.c @@ -47,8 +47,11 @@ static struct us_socket_t *us_internal_poll_cb_adopted_socket(struct us_poll_t * return us_internal_socket_follow_adopted((struct us_socket_t *)wp); } -/* uv_poll_t->data always (except for most times after calling us_poll_stop) - * points to the us_poll_t */ +static void close_cb_free_poll(uv_handle_t *h); + +/* uv_poll_t->data always points to the us_poll_t, even after us_poll_resize. + * libuv doesn't call poll_cb after us_poll_stop, and a stopped poll is never + * re-armed. */ static void poll_cb(uv_poll_t *p, int status, int events) { /* UV_DISCONNECT (Windows AFD): the peer closed its write side. A FIN * arriving after this side already half-closed and stopped reading never @@ -122,7 +125,23 @@ static void poll_cb(uv_poll_t *p, int status, int events) { if (!error && !eof && !(events & (UV_READABLE | UV_WRITABLE))) { return; } - us_internal_dispatch_ready_poll((struct us_poll_t *)p->data, error, eof, events); + struct us_poll_t *wp = (struct us_poll_t *)p->data; + wp->poll_cb_depth++; + us_internal_dispatch_ready_poll(wp, error, eof, events); + /* us_poll_resize may have moved the poll during the dispatch. The counter + * moved with it, so reload the pointer from the handle. */ + wp = (struct us_poll_t *)p->data; + /* A handler that waits for a promise re-enters uv_run. Closing the handle in + * that inner run would run its endgame while libuv's outer frame still uses + * the handle. So us_poll_stop only disarms the handle while a poll_cb frame is + * on the stack, and the outermost frame closes it here. Close the handle + * before the socket (see close_fd). */ + if (--wp->poll_cb_depth == 0 && wp->stopped) { + uv_close((uv_handle_t *)p, close_cb_free_poll); + if (wp->close_fd) { + bsd_close_socket(wp->fd); + } + } } static void prepare_cb(uv_prepare_t *p) { @@ -139,13 +158,16 @@ static void check_cb(uv_check_t *p) { /* Not used for polls, since polls need two frees */ static void close_cb_free(uv_handle_t *h) { us_free(h->data); } -/* This one is different for polls, since we need two frees here */ +/* A poll has two blocks. Whichever of us_poll_free and this callback runs + * second frees both (see us_poll_t). This callback runs first when a socket is + * closed during a nested tick. */ static void close_cb_free_poll(uv_handle_t *h) { - /* It is only in case we called us_poll_stop then quickly us_poll_free that we - * enter this. Most of the time, actual freeing is done by us_poll_free. */ - if (h->data) { - us_free(h->data); + struct us_poll_t *p = h->data; + if (p->released) { us_free(h); + us_free(p); + } else { + p->uv_closed = 1; } } @@ -168,39 +190,71 @@ void us_poll_init(struct us_poll_t *p, LIBUS_SOCKET_DESCRIPTOR fd, } void us_poll_free(struct us_poll_t *p, struct us_loop_t *loop) { - // poll was resized and dont own uv_poll_t anymore - if(!p->uv_p) { + uv_poll_t *h = p->uv_p; + /* us_poll_resize moved the handle to the new block, or us_poll_start_rc + * already freed it. */ + if (!h) { us_free(p); return; } - /* The idea here is like so; in us_poll_stop we call uv_close after setting - * data of uv-poll to 0. This means that in close_cb_free we call free on 0 - * with does nothing, since us_poll_stop should not really free the poll. - * HOWEVER, if we then call us_poll_free while still closing the uv-poll, we - * simply change back the data to point to our structure so that we actually - * do free it like we should. */ - if (uv_is_closing((uv_handle_t *)p->uv_p)) { - p->uv_p->data = p; - } else { - us_free(p->uv_p); + /* libuv either never saw the handle (us_create_poll zeroes it, so its type + * isn't UV_POLL) or already closed it, so free both blocks here. */ + if (h->type != UV_POLL || p->uv_closed) { + us_free(h); us_free(p); + return; + } + /* An AFD request that writes into h may still be in flight, so h must + * outlive it. close_cb_free_poll frees both blocks. */ + us_poll_stop(p, loop); + p->released = 1; +} + +/* Stopping a poll closes its handle, so a stopped poll can't be restarted. + * close_cb_free_poll frees the blocks later. Callers must close the socket with + * us_internal_poll_close_fd, which waits for a deferred uv_close. */ +void us_poll_stop(struct us_poll_t *p, struct us_loop_t *loop) { + uv_poll_t *h = p->uv_p; + if (!h || p->stopped) return; + p->stopped = 1; + /* libuv never saw the handle, so there's nothing to close. */ + if (h->type != UV_POLL) return; + /* Disarm first so an already dequeued completion doesn't reach poll_cb for a + * socket on the closed list. */ + uv_poll_stop(h); + /* Otherwise the outermost poll_cb frame closes the handle. */ + if (p->poll_cb_depth == 0) { + uv_close((uv_handle_t *)h, close_cb_free_poll); + } +} + +void us_internal_poll_close_fd(struct us_poll_t *p) { + /* uv_close is deferred to the outermost poll_cb frame and needs the socket + * open (see close_fd in us_poll_t), so that frame closes both. */ + if (p->stopped && p->poll_cb_depth > 0) { + p->close_fd = 1; + return; } + bsd_close_socket(p->fd); } int us_poll_start_rc(struct us_poll_t *p, struct us_loop_t *loop, int events) { - if(!p->uv_p) return 0; + uv_poll_t *h = p->uv_p; + if (!h) return 0; p->poll_type = us_internal_poll_type(p) | ((events & LIBUS_SOCKET_READABLE) ? POLL_TYPE_POLLING_IN : 0) | ((events & LIBUS_SOCKET_WRITABLE) ? POLL_TYPE_POLLING_OUT : 0); - /* uv_poll_init_socket (win/poll.c) can fail either before uv__handle_init - * (ioctlsocket FIONBIO) or after it (getsockopt SO_PROTOCOL_INFOW). The - * latter leaves the handle linked into loop->handle_queue with - * submitted_events_* still unset. Zero first so, on failure, ->type - * distinguishes the two states and the fields uv__poll_close reads are 0 - * rather than garbage. */ - memset(p->uv_p, 0, sizeof(uv_poll_t)); - p->uv_p->data = p; + if (p->stopped) { + errno = -UV_EBADF; + return UV_EBADF; + } + if (h->type == UV_POLL) { + /* Already registered. Initializing the handle again would wipe its + * in-flight AFD requests and list links, so only update the event mask. */ + uv_poll_start(h, events | UV_DISCONNECT, poll_cb); + return 0; + } int rc; #if defined(LIBUS_SOCKET_FAULT_INJECTION) && LIBUS_SOCKET_FAULT_INJECTION @@ -210,20 +264,17 @@ int us_poll_start_rc(struct us_poll_t *p, struct us_loop_t *loop, int events) { rc = (int) injected; } else #endif - rc = uv_poll_init_socket(loop->uv_loop, p->uv_p, p->fd); + rc = uv_poll_init_socket(loop->uv_loop, h, p->fd); if (rc < 0) { int saved = LIBUS_ERR; - if (p->uv_p->type == UV_POLL) { - /* uv__handle_init ran: the handle is in loop->handle_queue. Close it - * through libuv so it is unlinked; the caller's us_poll_free sees - * uv_is_closing and hands ownership to close_cb_free_poll. */ - p->uv_p->data = 0; - uv_close((uv_handle_t *)p->uv_p, close_cb_free_poll); + /* uv_poll_init_socket can fail before or after uv__handle_init. If it + * failed after, the handle is in loop->handle_queue and only uv_close can + * unlink it. uv__poll_close then reads submitted_events_*, which + * us_create_poll zeroed. If it failed before, libuv never saw the handle. */ + if (h->type == UV_POLL) { + us_poll_stop(p, loop); } else { - /* Never reached uv__handle_init: uv_p is still our raw block. Free it - * here and null the pointer so the caller's us_poll_free takes the - * !uv_p fast path (its uv_is_closing check would read garbage). */ - us_free(p->uv_p); + us_free(h); p->uv_p = NULL; } errno = saved ? saved : -rc; @@ -232,11 +283,11 @@ int us_poll_start_rc(struct us_poll_t *p, struct us_loop_t *loop, int events) { // This unref is okay in the context of Bun's event loop, because sockets have // a `Async.KeepAlive` associated with them, which is used instead of the // usockets internals. usockets doesnt have a notion of ref-counted handles. - uv_unref((uv_handle_t *)p->uv_p); + uv_unref((uv_handle_t *)h); /* Always ask for UV_DISCONNECT: a peer FIN must fire even when the poll is * writable-only at that moment (a half-closed connection whose reads are * paused is exactly the state that otherwise hangs; see poll_cb). */ - uv_poll_start(p->uv_p, events | UV_DISCONNECT, poll_cb); + uv_poll_start(h, events | UV_DISCONNECT, poll_cb); return 0; } @@ -245,7 +296,10 @@ void us_poll_start(struct us_poll_t *p, struct us_loop_t *loop, int events) { } int us_poll_change(struct us_poll_t *p, struct us_loop_t *loop, int events) { - if(!p->uv_p) return 0; + uv_poll_t *h = p->uv_p; + /* A stopped poll's socket is on the closed list. Re-arming it would call + * poll_cb for that socket. */ + if (!h || p->stopped) return 0; if (us_poll_events(p) != events) { p->poll_type = us_internal_poll_type(p) | @@ -254,23 +308,11 @@ int us_poll_change(struct us_poll_t *p, struct us_loop_t *loop, int events) { /* The poll stays initialized across changes here (the dispatcher never * parks a libuv poll), so this cannot hit the registration failure the * epoll re-add can; uv_poll_start on a live poll only rejects bad args. */ - uv_poll_start(p->uv_p, events | UV_DISCONNECT, poll_cb); + uv_poll_start(h, events | UV_DISCONNECT, poll_cb); } return 0; } -void us_poll_stop(struct us_poll_t *p, struct us_loop_t *loop) { - if(!p->uv_p) return; - uv_poll_stop(p->uv_p); - - /* We normally only want to close the poll here, not free it. But if we stop - * it, then quickly "free" it with us_poll_free, we postpone the actual - * freeing to close_cb_free_poll whenever it triggers. That's why we set data - * to null here, so that us_poll_free can reset it if needed */ - p->uv_p->data = 0; - uv_close((uv_handle_t *)p->uv_p, close_cb_free_poll); -} - int us_poll_events(struct us_poll_t *p) { return ((p->poll_type & POLL_TYPE_POLLING_IN) ? LIBUS_SOCKET_READABLE : 0) | ((p->poll_type & POLL_TYPE_POLLING_OUT) ? LIBUS_SOCKET_WRITABLE : 0); @@ -294,9 +336,11 @@ void us_loop_pump(struct us_loop_t *loop) { * timers are never processed. Bun's outer drive loops (wait_for_promise, * bun:test) supply their own keep-going predicate, so force exactly one * non-blocking iteration; UV_RUN_NOWAIT keeps the poll timeout at 0. */ + loop->data.tick_depth++; loop->uv_loop->active_handles++; uv_run(loop->uv_loop, UV_RUN_NOWAIT); loop->uv_loop->active_handles--; + loop->data.tick_depth--; } struct us_loop_t *us_create_loop(void *hint, @@ -307,7 +351,14 @@ struct us_loop_t *us_create_loop(void *hint, struct us_loop_t *loop = (struct us_loop_t *)us_calloc(1, sizeof(struct us_loop_t) + ext_size); - loop->uv_loop = hint ? hint : uv_loop_new(); + loop->uv_loop = hint; + if (!hint) { + loop->uv_loop = us_malloc(sizeof(uv_loop_t)); + if (uv_loop_init(loop->uv_loop)) { + us_free(loop->uv_loop); + loop->uv_loop = 0; + } + } /* NULL when uv_loop_init fails (CreateIoCompletionPort under handle exhaustion). */ if (!loop->uv_loop) { us_free(loop); @@ -339,6 +390,13 @@ struct us_loop_t *us_create_loop(void *hint, return loop; } +static void close_walk_cb(uv_handle_t *h, void *arg) { + (void)arg; + if (!uv_is_closing(h)) { + uv_close(h, 0); + } +} + // based on if this was default loop or not void us_loop_free(struct us_loop_t *loop) { // ref and close down prepare and check @@ -361,8 +419,23 @@ void us_loop_free(struct us_loop_t *loop) { // we need to run the loop one last round to call all close callbacks // we cannot do this if we do not own the loop, default if (!loop->is_default) { - uv_run(loop->uv_loop, UV_RUN_NOWAIT); - uv_loop_delete(loop->uv_loop); + /* uv_loop_close fails while a closing handle is still waiting for its IOCP + * completion, so retry until the deadline. If the loop never closes, leak + * it, because uv__wake_all_loops still walks it on system resume. */ + uv_walk(loop->uv_loop, close_walk_cb, 0); + const uint64_t deadline = uv_hrtime() + 10ULL * 1000000000ULL; + int busy; + for (;;) { + uv_run(loop->uv_loop, UV_RUN_NOWAIT); + busy = uv_loop_close(loop->uv_loop); + if (!busy || uv_hrtime() >= deadline) { + break; + } + uv_sleep(1); + } + if (!busy) { + us_free(loop->uv_loop); + } } // now we can free our part @@ -397,15 +470,27 @@ void us_loop_run(struct us_loop_t *loop) { } } + /* us_internal_loop_post only frees closed sockets at depth 1. A poll callback + * that waits for a promise re-enters here while the outer dispatch is still + * using its socket. us_loop_run_bun_tick does the same on POSIX. */ + loop->data.tick_depth++; uv_run(loop->uv_loop, UV_RUN_ONCE); + loop->data.tick_depth--; } struct us_poll_t *us_create_poll(struct us_loop_t *loop, int fallthrough, unsigned int ext_size) { struct us_poll_t *p = (struct us_poll_t *)us_malloc(sizeof(struct us_poll_t) + ext_size); - p->uv_p = us_malloc(sizeof(uv_poll_t)); + /* Zeroed so us_poll_free and us_poll_stop can check ->type to see whether + * us_poll_start_rc registered the handle (uv__handle_init sets it). */ + p->uv_p = us_calloc(1, sizeof(uv_poll_t)); p->uv_p->data = p; + p->stopped = 0; + p->close_fd = 0; + p->uv_closed = 0; + p->released = 0; + p->poll_cb_depth = 0; return p; } diff --git a/packages/bun-usockets/src/internal/eventing/libuv.h b/packages/bun-usockets/src/internal/eventing/libuv.h index 980f6c796539..3f287f40ad74 100644 --- a/packages/bun-usockets/src/internal/eventing/libuv.h +++ b/packages/bun-usockets/src/internal/eventing/libuv.h @@ -47,6 +47,23 @@ struct us_poll_t { uv_poll_t *uv_p; LIBUS_SOCKET_DESCRIPTOR fd; unsigned char poll_type; + /* Set by us_poll_stop. uv_p is closing, or will close when poll_cb_depth + * drops to 0 (see poll_cb). The poll is never re-armed after this. */ + unsigned char stopped : 1; + /* Set when us_internal_poll_close_fd runs while uv_p's close is deferred. + * poll_cb then closes fd right after uv_close. uv_close cancels the in-flight + * request with an ioctl on the socket, and a process with strict handle + * checks (every AppContainer) dies if that socket is already closed. */ + unsigned char close_fd : 1; + /* libuv keeps pointers into a registered uv_p until it runs the close + * callback. Whichever of us_poll_free and close_cb_free_poll runs second + * frees both blocks. These flags record which one already ran. */ + unsigned char uv_closed : 1; + unsigned char released : 1; + /* Number of poll_cb frames on the stack for this poll. It's above 1 when a + * handler waits for a promise and the nested loop run dispatches this poll + * again. */ + unsigned int poll_cb_depth; }; #endif // LIBUV_H \ No newline at end of file diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index da99f5a427af..a5c8c1536d91 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -190,6 +190,8 @@ long long us_internal_sweep_timeout_ns(struct us_loop_t *loop); void us_internal_sweep_if_due(struct us_loop_t *loop); #endif void us_internal_free_closed_sockets(us_loop_r loop); +/* Defers closing `s` with `code` to us_internal_loop_post. */ +void us_internal_loop_close_unresumable_socket(struct us_loop_t *loop, struct us_socket_t *s, int code); 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); /* Unlink the group from the loop iff every list/count is now zero. */ @@ -230,6 +232,10 @@ void us_internal_async_wakeup(struct us_internal_async *a); size_t us_internal_accept_poll_event(struct us_poll_t *p); int us_internal_poll_type(struct us_poll_t *p); void us_internal_poll_set_type(struct us_poll_t *p, int poll_type); +/* Closes a stopped poll's descriptor. On epoll/kqueue this is bsd_close_socket. + * On libuv the descriptor must outlive the handle's uv_close, which can be + * deferred (see us_poll_stop in eventing/libuv.c). */ +void us_internal_poll_close_fd(struct us_poll_t *p); /* SSL loop data */ void us_internal_init_loop_ssl_data(us_loop_r loop); diff --git a/packages/bun-usockets/src/internal/loop_data.h b/packages/bun-usockets/src/internal/loop_data.h index a39aa4f9235a..4b6ef00d6f76 100644 --- a/packages/bun-usockets/src/internal/loop_data.h +++ b/packages/bun-usockets/src/internal/loop_data.h @@ -36,6 +36,13 @@ typedef void* zig_mutex_t; struct us_quic_socket_context_s; struct us_nq_driver_s; +/* A socket that us_socket_resume() failed to re-register with the kernel, and + * its close code. */ +struct us_internal_unresumable_socket_t { + struct us_socket_t *socket; + int code; +}; + struct us_internal_loop_data_t { #ifdef LIBUS_USE_LIBUV struct us_timer_t *sweep_timer; @@ -89,11 +96,14 @@ struct us_internal_loop_data_t { /* We do not care if this flips or not, it doesn't matter */ size_t iteration_nr; void* jsc_vm; - /* Reentrancy depth of us_loop_run_bun_tick. When >1, we are inside a - * nested tick (e.g. waitForPromise from a poll callback). Freeing closed - * sockets must be deferred to the outermost tick so the outer dispatch - * doesn't read a freed poll. */ + /* Reentrancy depth of us_loop_run / us_loop_run_bun_tick (and us_loop_pump + * on libuv). When >1, we are inside a nested tick (e.g. waitForPromise from + * a poll callback). Freeing closed sockets must be deferred to the + * outermost tick so the outer dispatch doesn't read a freed poll. */ int tick_depth; + /* Filled by us_socket_resume(), closed by us_internal_loop_post(). */ + int num_unresumable_sockets; + struct us_internal_unresumable_socket_t *unresumable_sockets; }; #endif // LOOP_DATA_H diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index 62a9f6a496ac..53e5276f9d13 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -415,14 +415,10 @@ struct us_listen_socket_t *us_socket_group_listen_fd(us_socket_group_r group, void us_listen_socket_close(struct us_listen_socket_t *ls) nonnull_fn_decl; /* SNI: the names outlive us_listen_socket_close() for the connections already - * accepted. ssl_ctx is up_ref'd; user is opaque (uWS stores a per-domain - * HttpRouter*). user may be NULL. The last registration of a name replaces the + * accepted. ssl_ctx is up_ref'd. The last registration of a name replaces the * earlier one. */ void us_listen_socket_add_server_name(struct us_listen_socket_t *ls, - const char *hostname_pattern, struct ssl_ctx_st *ssl_ctx, void *user) - __attribute__((nonnull(1, 2, 3))); -void *us_listen_socket_find_server_name_userdata(struct us_listen_socket_t *ls, - const char *hostname_pattern) nonnull_fn_decl; + const char *hostname_pattern, struct ssl_ctx_st *ssl_ctx) nonnull_fn_decl; /* Returns an owned reference; the caller must release it. */ struct ssl_ctx_st *us_listen_socket_find_server_name_ctx(struct us_listen_socket_t *ls, const char *hostname_pattern) nonnull_fn_decl; @@ -444,7 +440,6 @@ void us_listen_socket_on_server_name(struct us_listen_socket_t *ls, * call consumes the reference. `error` != 0 aborts the handshake. Safe to call * after the socket closed (no-op). */ void us_socket_sni_resolve(us_socket_r s, struct ssl_ctx_st *ctx, int error); -void *us_socket_server_name_userdata(us_socket_r s); /* Records a per-serverName entry's client-certificate policy on its SSL_CTX * so the SNI switch adds it to the connection's inherited one, and gives the * context its own session-id context so sessions from other contexts are not diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index f803999f6590..b80076e5c142 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -163,6 +163,7 @@ void us_internal_loop_data_free(struct us_loop_t *loop) { us_free(loop->data.recv_buf); us_free(loop->data.send_buf); + us_free(loop->data.unresumable_sockets); #ifdef LIBUS_USE_LIBUV us_timer_close(loop->data.sweep_timer, 0); @@ -404,8 +405,44 @@ int us_internal_handle_dns_results(struct us_loop_t *loop) { return s != NULL; } +/* The kernel isn't polling `s`, so wake the loop explicitly. */ +void us_internal_loop_close_unresumable_socket(struct us_loop_t *loop, struct us_socket_t *s, int code) { + struct us_internal_loop_data_t *data = &loop->data; + struct us_internal_unresumable_socket_t *sockets = us_realloc(data->unresumable_sockets, + sizeof(*sockets) * ((size_t) data->num_unresumable_sockets + 1)); + if (!sockets) Bun__outOfMemory(); + sockets[data->num_unresumable_sockets].socket = s; + sockets[data->num_unresumable_sockets].code = code; + data->unresumable_sockets = sockets; + data->num_unresumable_sockets++; + us_wakeup_loop(loop); +} + +static void us_internal_close_unresumable_sockets(struct us_loop_t *loop) { + struct us_internal_loop_data_t *data = &loop->data; + /* A close handler can queue another socket or close a queued one. */ + while (data->num_unresumable_sockets) { + struct us_internal_unresumable_socket_t entry = data->unresumable_sockets[--data->num_unresumable_sockets]; + struct us_socket_t *s = us_internal_socket_follow_adopted(entry.socket); + if (s && !us_socket_is_closed(s)) { + us_internal_socket_close_raw(s, entry.code, NULL); + } + } +} + /* Note: Properly takes the linked list and timeout sweep into account */ void us_internal_free_closed_sockets(struct us_loop_t *loop) { + /* Remove the sockets freed below from the unresumable list. */ + struct us_internal_loop_data_t *data = &loop->data; + for (int i = 0; i < data->num_unresumable_sockets; ) { + struct us_socket_t *s = us_internal_socket_follow_adopted(data->unresumable_sockets[i].socket); + if (!s || us_socket_is_closed(s)) { + data->unresumable_sockets[i] = data->unresumable_sockets[--data->num_unresumable_sockets]; + } else { + data->unresumable_sockets[i++].socket = s; + } + } + /* Free all closed sockets (maybe it is better to reverse order?) */ for (struct us_socket_t *s = loop->data.closed_head; s; ) { struct us_socket_t *next = s->next; @@ -458,6 +495,8 @@ void us_internal_loop_post(struct us_loop_t *loop) { if (loop->data.quic_head) us_quic_loop_process(loop); #endif if (loop->data.nq_head) us_nq_loop_flush_if_pending(loop); + /* Runs at every tick depth because a nested tick may be waiting for this close. */ + us_internal_close_unresumable_sockets(loop); /* A poll callback may re-enter the loop (e.g. expect().toThrow() → * waitForPromise → us_loop_run_bun_tick). The inner tick must not free * closed sockets: the outer tick's dispatch is mid-iteration and may still diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index 950c117f5c02..3ba69a3a2a9c 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -214,7 +214,7 @@ void us_connecting_socket_close(struct us_connecting_socket_t *c) { us_internal_socket_group_unlink_socket(s->group, s); us_poll_stop((struct us_poll_t *) s, s->group->loop); - bsd_close_socket(us_poll_fd((struct us_poll_t *) s)); + us_internal_poll_close_fd((struct us_poll_t *) s); /* Link this socket to the close-list and let it be deleted after this iteration */ s->next = s->group->loop->data.closed_head; @@ -322,7 +322,7 @@ static struct us_socket_t *us_internal_socket_close_and_notify(struct us_socket_ setsockopt(us_poll_fd((struct us_poll_t *)s), SOL_SOCKET, SO_LINGER, (const char*)&l, sizeof(l)); } - bsd_close_socket(us_poll_fd((struct us_poll_t *) s)); + us_internal_poll_close_fd((struct us_poll_t *) s); /* Mark the socket as closed */ s->flags.is_closed = 1; @@ -886,8 +886,10 @@ void us_socket_resume(struct us_socket_t *s) { if (us_poll_change(&s->p, s->group->loop, events) != 0) { /* The dispatcher parked this socket while it was paused (loop.c) and the * kernel refused to take it back: nothing would ever deliver its tail, - * end or close again, so fail it now like a failed first registration. */ + * end or close again, so fail it like a failed first registration. Defer + * the close to the loop because the close handler frees state that + * resume's callers (uWS ending a response, a body read in JS) still use. */ int err = LIBUS_ERR; - us_internal_socket_close_raw(s, err > 2 ? err : LIBUS_ECONNRESET, NULL); + us_internal_loop_close_unresumable_socket(s->group->loop, s, err > 2 ? err : LIBUS_ECONNRESET); } } diff --git a/packages/bun-usockets/src/udp.c b/packages/bun-usockets/src/udp.c index d9a9bfc0fe14..efc74898f409 100644 --- a/packages/bun-usockets/src/udp.c +++ b/packages/bun-usockets/src/udp.c @@ -112,7 +112,7 @@ void us_udp_socket_close(struct us_udp_socket_t *s) { struct us_loop_t *loop = s->loop; struct us_poll_t *p = (struct us_poll_t *) s; us_poll_stop(p, loop); - bsd_close_socket(us_poll_fd(p)); + us_internal_poll_close_fd(p); s->closed = 1; s->next = loop->data.closed_udp_head; loop->data.closed_udp_head = s; diff --git a/packages/bun-uws/src/App.h b/packages/bun-uws/src/App.h index e825ac744867..545b762e4397 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -111,7 +111,6 @@ struct TemplatedApp { struct PendingServerName { std::string hostname; struct ssl_ctx_st *ctx; - HttpRouter::RouterData> *router; }; std::vector pendingServerNames; /* No raw us_listen_socket_t* cache here. src/runtime/server/mod.rs's non-abrupt stop calls @@ -158,13 +157,12 @@ struct TemplatedApp { if (httpContext->getSocketContextData()->http2Context) { us_ssl_ctx_enable_http2_alpn(domainCtx, httpContext->getSocketContextData()->allowHttp1); } - auto *domainRouter = new HttpRouter::RouterData>(); forEachListenSocket([&](us_listen_socket_t *ls) { - us_listen_socket_add_server_name(ls, hostname_pattern.c_str(), domainCtx, domainRouter); + us_listen_socket_add_server_name(ls, hostname_pattern.c_str(), domainCtx); }); /* Queue for any listeners not yet created. We hold one SSL_CTX ref; * each listen socket took its own via SSL_CTX_up_ref. */ - pendingServerNames.push_back({hostname_pattern, domainCtx, domainRouter}); + pendingServerNames.push_back({hostname_pattern, domainCtx}); if (success) *success = true; } @@ -267,7 +265,6 @@ struct TemplatedApp { * destroying us — httpContext->free() above only deinit()'s. */ for (auto &p : pendingServerNames) { us_internal_ssl_ctx_unref(p.ctx); - delete p.router; } us_internal_ssl_ctx_unref(sslCtx); } @@ -661,24 +658,6 @@ struct TemplatedApp { return std::move(*this); } - /* Browse to a server name, changing the router to this domain */ - TemplatedApp &&domain(const std::string &serverName) { - HttpContextData *httpContextData = httpContext->getSocketContextData(); - - /* The last registration of a name is the one the SNI tree points at */ - void *domainRouter = nullptr; - for (auto p = pendingServerNames.rbegin(); p != pendingServerNames.rend(); ++p) { - if (p->hostname == serverName) { domainRouter = p->router; break; } - } - if (domainRouter) { - httpContextData->currentRouter = (decltype(httpContextData->currentRouter)) domainRouter; - } else { - httpContextData->currentRouter = &httpContextData->router; - } - - return std::move(*this); - } - TemplatedApp &&get(std::string_view pattern, MoveOnlyFunction *, HttpRequest *)> &&handler) { if (httpContext) { httpContext->onHttp("GET", pattern, std::move(handler)); @@ -765,7 +744,7 @@ struct TemplatedApp { if (!ls) return nullptr; if constexpr (SSL) { for (auto &p : pendingServerNames) { - us_listen_socket_add_server_name(ls, p.hostname.c_str(), p.ctx, p.router); + us_listen_socket_add_server_name(ls, p.hostname.c_str(), p.ctx); } if (httpContext->getSocketContextData()->missingServerNameHandler) { us_listen_socket_on_server_name(ls, &onMissingServerName); diff --git a/packages/bun-uws/src/AsyncSocket.h b/packages/bun-uws/src/AsyncSocket.h index 1a799d605c1a..ecb84c462a15 100644 --- a/packages/bun-uws/src/AsyncSocket.h +++ b/packages/bun-uws/src/AsyncSocket.h @@ -307,8 +307,9 @@ struct AsyncSocket { /* Write in three levels of prioritization: cork-buffer, syscall, socket-buffer. Always drain if possible. * Returns pair of bytes written (anywhere) and wheter or not this call resulted in the polling for - * writable (or we are in a state that implies polling for writable). */ - std::pair write(const char *src, int length, bool optionally = false, int nextLength = 0) { + * writable (or we are in a state that implies polling for writable). + * length may exceed INT_MAX. nextLength is the size of the caller's next write. */ + std::pair write(const char *src, size_t length, bool optionally = false, size_t nextLength = 0) { /* Fake success if closed, simple fix to allow uncork of closed socket to succeed */ if (us_socket_is_closed((us_socket_t *) this)) { return {length, false}; @@ -334,7 +335,7 @@ struct AsyncSocket { return {0, true}; } else { /* This path is horrible and points towards erroneous usage */ - asyncSocketData->buffer.append(src, (unsigned int) length); + asyncSocketData->buffer.append(src, length); return {length, true}; } } @@ -348,9 +349,9 @@ struct AsyncSocket { if (slot != LoopData::INVALID_CORK_SLOT) { /* We are corked */ auto *s = loopData->getCorkSlot(slot); - if ((unsigned int) length <= LoopData::CORK_COPY_MAX && LoopData::CORK_BUFFER_SIZE - s->offset >= (unsigned int) length) { + if (length <= LoopData::CORK_COPY_MAX && LoopData::CORK_BUFFER_SIZE - s->offset >= length) { /* If the entire chunk fits in cork buffer */ - memcpy(s->buffer + s->offset, src, (unsigned int) length); + memcpy(s->buffer + s->offset, src, length); s->offset += (unsigned int) length; ASSERT(s->offset <= LoopData::CORK_BUFFER_SIZE); loopData->touchCorkSlot(slot); @@ -360,8 +361,11 @@ struct AsyncSocket { return writeFramed({}, src, length, {}, optionally); } } else { - /* We are not corked */ - int written = us_socket_write((us_socket_t *) this, src, length); + /* We are not corked. us_socket_write's length is an int. */ + if (length > INT_MAX) [[unlikely]] { + return writeOverIntMax(src, length, optionally); + } + size_t written = (size_t) us_socket_write((us_socket_t *) this, src, (int) length); /* Did we fail? */ if (written < length) { @@ -372,11 +376,11 @@ struct AsyncSocket { /* Fall back to worst possible case (should be very rare for HTTP) */ /* At least we can reserve room for next chunk if we know it up front */ if (nextLength) { - asyncSocketData->buffer.reserve(asyncSocketData->buffer.length() + (size_t) (length - written + nextLength)); + asyncSocketData->buffer.reserve(asyncSocketData->buffer.length() + (length - written) + nextLength); } /* Buffer this chunk */ - asyncSocketData->buffer.append(src + written, (size_t) (length - written)); + asyncSocketData->buffer.append(src + written, length - written); /* Return the failure */ return {length, true}; @@ -389,27 +393,55 @@ struct AsyncSocket { return {length, false}; } + /* Writes a src longer than INT_MAX in pieces, each as an optional write(). Stops at the first short write. + * If the write isn't optional, buffers the rest of src. */ + std::pair writeOverIntMax(const char *src, size_t length, bool optionally) { + /* Leave room for the cork buffer so writeFramed() can send it and a piece in one writev. */ + const size_t maxPiece = (size_t) INT_MAX - LoopData::CORK_BUFFER_SIZE; + size_t offset = 0; + while (offset < length) { + size_t piece = std::min(length - offset, maxPiece); + size_t written = write(src + offset, piece, true).first; + offset += written; + if (written < piece) { + if (optionally) { + return {offset, true}; + } + BackPressure &backpressure = getAsyncSocketData()->buffer; + backpressure.append(src + offset, length - offset); + return {length, true}; + } + } + return {length, false}; + } + /* write(head), write(src), write(tail) in one writev when they do not fit the cork buffer. The framing is never optional, and tail follows only a complete src. Returns like write(src). */ - std::pair writeFramed(std::string_view head, const char *src, int length, std::string_view tail, bool optionally = false) { + std::pair writeFramed(std::string_view head, const char *src, size_t length, std::string_view tail, bool optionally = false) { LoopData *loopData = getLoopData(); BackPressure &backpressure = getAsyncSocketData()->buffer; int slot = loopData->findCorkSlot(this); unsigned int corked = slot != LoopData::INVALID_CORK_SLOT ? loopData->getCorkSlot(slot)->offset : 0; - const bool fitsCork = slot != LoopData::INVALID_CORK_SLOT && (unsigned int) length <= LoopData::CORK_COPY_MAX - && LoopData::CORK_BUFFER_SIZE - corked >= head.length() + (size_t) length + tail.length(); + const bool fitsCork = slot != LoopData::INVALID_CORK_SLOT && length <= LoopData::CORK_COPY_MAX + && LoopData::CORK_BUFFER_SIZE - corked >= head.length() + length + tail.length(); /* macOS refuses a writev of more than INT_MAX bytes. */ - const bool tooLong = (size_t) length > (size_t) INT_MAX - LoopData::CORK_BUFFER_SIZE - head.length() - tail.length(); + const bool tooLong = length > (size_t) INT_MAX - LoopData::CORK_BUFFER_SIZE - head.length() - tail.length(); if (fitsCork || tooLong || backpressure.length() || us_socket_is_closed((us_socket_t *) this)) { if (head.length()) { - write(head.data(), (int) head.length()); + write(head.data(), head.length()); + } + std::pair result; + if (length > INT_MAX) [[unlikely]] { + result = writeOverIntMax(src, length, optionally); + } else { + /* write() calls back into writeFramed() with no framing when src doesn't fit in the cork buffer. */ + const bool fromWrite = head.empty() && tail.empty() && slot != LoopData::INVALID_CORK_SLOT && !fitsCork; + result = fromWrite ? uncork(src, length, optionally) : write(src, length, optionally); } - /* write() sends a src that does not fit the cork buffer back here without framing. */ - const bool fromWrite = head.empty() && tail.empty() && slot != LoopData::INVALID_CORK_SLOT && !fitsCork; - auto result = fromWrite ? uncork(src, length, optionally) : write(src, length, optionally); + ASSERT(optionally || result.first == length); if (tail.length() && result.first == length) { - result.second |= write(tail.data(), (int) tail.length()).second; + result.second |= write(tail.data(), tail.length()).second; } return result; } @@ -420,7 +452,7 @@ struct AsyncSocket { loopData->releaseCorkSlot(slot); } - const std::string_view parts[4] = {{corkBuffer, corked}, head, {src, (size_t) length}, tail}; + const std::string_view parts[4] = {{corkBuffer, corked}, head, {src, length}, tail}; struct us_iovec_t iov[4]; int count = 0; for (auto part : parts) { @@ -441,7 +473,7 @@ struct AsyncSocket { failed = true; if (optionally && i == 2) { /* The caller keeps the rest of src, and sends tail behind it. */ - return {(int) sent, true}; + return {sent, true}; } backpressure.append(part.data() + sent, part.length() - sent); } @@ -450,7 +482,7 @@ struct AsyncSocket { /* Uncork this socket and flush or buffer any corked and/or passed data. It is essential to remember doing this. */ /* It does NOT count bytes written from cork buffer (they are already accounted for in the write call responsible for its corking)! */ - std::pair uncork(const char *src = nullptr, int length = 0, bool optionally = false) { + std::pair uncork(const char *src = nullptr, size_t length = 0, bool optionally = false) { LoopData *loopData = getLoopData(); int slot = loopData->findCorkSlot(this); @@ -465,8 +497,8 @@ struct AsyncSocket { loopData->releaseCorkSlot(slot); if (offset) { - /* Corked data is already accounted for via its write call */ - auto [written, failed] = write(buffer, (int) offset, false, length); + /* Corked data is already accounted for via its write call. An optional src is never buffered, so don't reserve room for it. */ + auto [written, failed] = write(buffer, offset, false, optionally ? 0 : length); if (failed && optionally) { /* We do not need to care for buffering here, write does that */ diff --git a/packages/bun-uws/src/AsyncSocketData.h b/packages/bun-uws/src/AsyncSocketData.h index e38db49a9455..9df9166ff664 100644 --- a/packages/bun-uws/src/AsyncSocketData.h +++ b/packages/bun-uws/src/AsyncSocketData.h @@ -30,7 +30,9 @@ namespace uWS { /* Contiguous buffer with a moving head cursor: erase() bumps head, * append()/resize() compact into the drained head gap before growing, so - * draining never memmoves or reallocates. */ + * draining never memmoves or reallocates. Growing reserves TAIL_ROOM spare + * bytes, so appending framing after a buffered payload doesn't reallocate and + * copy that payload. */ struct BackPressure { BackPressure() = default; BackPressure(BackPressure &&other) noexcept @@ -93,6 +95,9 @@ struct BackPressure { } } + /* Enough for the chunk's CRLF and the next chunk-size line (HttpResponse::CHUNK_HEAD_MAX). */ + static constexpr size_t TAIL_ROOM = 12; + private: static constexpr size_t MIN_CAPACITY = 4096; @@ -117,7 +122,8 @@ struct BackPressure { return; } - size_t newCap = std::max(std::max(cap * 2, live + n), MIN_CAPACITY); + if (live + n > SIZE_MAX - TAIL_ROOM) std::abort(); + size_t newCap = std::max(std::max(cap * 2, live + n + TAIL_ROOM), MIN_CAPACITY); char *nb; if (head == 0) { /* mi_realloc may extend in place. */ diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index 696c9c6cd4cf..5d92f44b7665 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -558,15 +558,6 @@ struct HttpContext { } } - /* Select the router based on SNI (only possible for SSL) */ - auto *selectedRouter = &httpContextData->router; - if constexpr (SSL) { - void *domainRouter = us_socket_server_name_userdata((struct us_socket_t *) s); - if (domainRouter) { - selectedRouter = (decltype(selectedRouter)) domainRouter; - } - } - /* Bun.serve: every request of this read is dispatched corked, not only * the first. A response to an earlier request can release the cork * taken above (a response that JavaScript produced, a body larger @@ -579,8 +570,8 @@ struct HttpContext { } /* Route the method and URL */ - selectedRouter->getUserData() = {(HttpResponse *) s, httpRequest}; - if (!selectedRouter->route(httpRequest->getCaseSensitiveMethod(), httpRequest->getUrlForRouting())) { + httpContextData->router.getUserData() = {(HttpResponse *) s, httpRequest}; + if (!httpContextData->router.route(httpRequest->getCaseSensitiveMethod(), httpRequest->getUrlForRouting())) { /* We have to force close this socket as we have no handler for it. * close() first sends the responses to earlier requests of this read. */ ((AsyncSocket *) s)->close(); @@ -1194,17 +1185,17 @@ struct HttpContext { methods = {&method_sv_buffer, 1}; } - uint32_t priority = method == "*" ? httpContextData->currentRouter->LOW_PRIORITY : (upgrade ? httpContextData->currentRouter->HIGH_PRIORITY : httpContextData->currentRouter->MEDIUM_PRIORITY); + uint32_t priority = method == "*" ? httpContextData->router.LOW_PRIORITY : (upgrade ? httpContextData->router.HIGH_PRIORITY : httpContextData->router.MEDIUM_PRIORITY); /* If we are passed nullptr then remove this */ if (!handler) { for (const auto &method : methods) { - httpContextData->currentRouter->remove(method, pattern, priority); + httpContextData->router.remove(method, pattern, priority); } return; } - httpContextData->currentRouter->add(methods, pattern, [handler = std::move(handler), httpContextData](auto *r) mutable { + httpContextData->router.add(methods, pattern, [handler = std::move(handler), httpContextData](auto *r) mutable { auto user = r->getUserData(); user.httpRequest->setYield(false); user.httpRequest->setParameters(r->getParameters()); diff --git a/packages/bun-uws/src/HttpContextData.h b/packages/bun-uws/src/HttpContextData.h index 4c28067a9c8c..45cb7957a258 100644 --- a/packages/bun-uws/src/HttpContextData.h +++ b/packages/bun-uws/src/HttpContextData.h @@ -67,9 +67,6 @@ struct alignas(16) HttpContextData { HttpRequest *httpRequest; }; - /* This is the currently browsed-to router when using SNI */ - HttpRouter *currentRouter = &router; - /* The socket onData is currently parsing, nullptr outside a parse. The * close gates in internalEnd need the per-socket identity: a DIFFERENT * socket's response can complete inside this window (a microtask drained @@ -78,7 +75,6 @@ struct alignas(16) HttpContextData { * checks the parsed socket. */ struct us_socket_t *parsingSocket = nullptr; - /* This is the default router for default SNI or non-SSL */ HttpRouter router; void *upgradedWebSocket = nullptr; /* Used to simulate Node.js socket events. */ @@ -101,10 +97,8 @@ struct alignas(16) HttpContextData { * and non-preface cleartext). */ bool allowHttp1 = true; - // TODO: SNI void clearRoutes() { this->router = HttpRouter{}; - this->currentRouter = &router; /* Not filterHandlers: filters are per-context open/close hooks, not * routes. server.reload() never re-registers them, so wiping them here * leaves Bun's active_connection_count (and node:http's 'connection' diff --git a/packages/bun-uws/src/HttpResponse.h b/packages/bun-uws/src/HttpResponse.h index 1ffda7469b65..9b4a0e1087af 100644 --- a/packages/bun-uws/src/HttpResponse.h +++ b/packages/bun-uws/src/HttpResponse.h @@ -66,6 +66,7 @@ struct HttpResponse : public AsyncSocket { } /* The chunk-size line of a chunk. Returns its length. */ static constexpr size_t CHUNK_HEAD_MAX = 10; + static_assert(BackPressure::TAIL_ROOM >= 2 + CHUNK_HEAD_MAX); static size_t chunkHead(char (&buf)[CHUNK_HEAD_MAX], unsigned int chunkLength) { int length = utils::u32toaHex(chunkLength, buf); buf[length++] = '\r'; @@ -283,7 +284,7 @@ struct HttpResponse : public AsyncSocket { std::string &trailers = nodeHttpResponseData->nodeHttpResponseTrailers; trailers.insert(0, "0\r\n", 3); trailers.append("\r\n", 2); - Super::write(trailers.data(), (int) trailers.length()); + Super::write(trailers.data(), trailers.length()); trailers.clear(); httpResponseData->state &= ~HttpResponseData::HTTP_NODE_HAS_RESPONSE_TRAILERS; } else if (!terminated) { @@ -329,19 +330,10 @@ struct HttpResponse : public AsyncSocket { httpResponseData->state |= HttpResponseData::HTTP_END_CALLED; } - /* Even if we supply no new data to write, its failed boolean is useful to know - * if it failed to drain any prior failed header writes */ - - /* Write as much as possible without causing backpressure */ - size_t written = 0; - bool failed = false; - while (written < data.length() && !failed) { - /* uSockets only deals with int sizes, so pass chunks of max signed int size */ - auto writtenFailed = Super::write(data.data() + written, (int) std::min(data.length() - written, INT_MAX), optional); - - written += (size_t) writtenFailed.first; - failed = writtenFailed.second; - } + /* tryEnd() stops at a short write and end() buffers the rest. Skip the call when there's no data, + * because write(src, 0) would retry flushing the backpressure buffer. */ + auto [written, failed] = data.empty() ? std::pair{0, false} : Super::write(data.data(), data.length(), optional); + ASSERT(optional || written == data.length()); httpResponseData->offset += written; @@ -609,7 +601,7 @@ struct HttpResponse : public AsyncSocket { * the AsyncSocket write path (and buffer) writeStatus/end use, so a * pipelined replay stays ordered ahead of the final response bytes. */ HttpResponse *writeRawInformational(std::string_view data) { - Super::write(data.data(), (int) data.length()); + Super::write(data.data(), data.length()); return this; } @@ -634,7 +626,7 @@ struct HttpResponse : public AsyncSocket { httpResponseData->state |= HttpResponseData::HTTP_STATUS_CALLED; Super::write("HTTP/1.1 ", 9); - Super::write(status.data(), (int) status.length()); + Super::write(status.data(), status.length()); Super::write("\r\n", 2); return this; } @@ -643,9 +635,9 @@ struct HttpResponse : public AsyncSocket { HttpResponse *writeHeader(std::string_view key, std::string_view value) { writeStatus(HTTP_200_OK); - Super::write(key.data(), (int) key.length()); + Super::write(key.data(), key.length()); Super::write(": ", 2); - Super::write(value.data(), (int) value.length()); + Super::write(value.data(), value.length()); Super::write("\r\n", 2); return this; } @@ -660,7 +652,7 @@ struct HttpResponse : public AsyncSocket { return this; } - Super::write(key.data(), (int) key.length()); + Super::write(key.data(), key.length()); Super::write(": ", 2); writeUnsigned64(value); Super::write("\r\n", 2); @@ -852,48 +844,16 @@ struct HttpResponse : public AsyncSocket { Super::write("\r\n", 2); httpResponseData->state |= HttpResponseData::HTTP_WRITE_CALLED; } - size_t total_written = 0; - bool has_failed = false; - - if (length <= INT_MAX) { - auto [written, failed] = Super::writeFramed({chunkHeadBuffer, chunkHeadLength}, data.data(), (int) length, chunkTail); - this->resetTimeout(); - if (writtenPtr) { - *writtenPtr = (size_t) written; - } - return !failed; - } - Super::write(chunkHeadBuffer, (int) chunkHeadLength); - - // Handle data larger than INT_MAX by writing it in chunks of INT_MAX bytes - while (length > INT_MAX) { - // Write the maximum allowed chunk size (INT_MAX) - auto [written, failed] = Super::write(data.data(), INT_MAX); - // If the write failed, set the has_failed flag we continue writting because the data will be buffered - has_failed = has_failed || failed; - total_written += written; - length -= INT_MAX; - data = data.substr(INT_MAX); - } - // Handle the remaining data (less than INT_MAX bytes) - if (length > 0) { - // Write the final chunk with exact remaining length - auto [written, failed] = Super::write(data.data(), (int) length); - has_failed = has_failed || failed; - total_written += written; - } - - /* Empty for a close-delimited body, which is raw. */ - Super::write(chunkTail.data(), (int) chunkTail.length()); + auto [written, failed] = Super::writeFramed({chunkHeadBuffer, chunkHeadLength}, data.data(), length, chunkTail); /* Reset timeout on each sended chunk */ this->resetTimeout(); if (writtenPtr) { - *writtenPtr = total_written; + *writtenPtr = written; } /* If we did not fail the write, accept more */ - return !has_failed; + return !failed; } /* Like write(), but the body payload is written with optionally=true so the @@ -927,25 +887,7 @@ struct HttpResponse : public AsyncSocket { } } - size_t consumed = 0; - size_t length = data.length(); - if (length <= INT_MAX) { - consumed = (size_t) Super::writeFramed({chunkHeadBuffer, chunkHeadLength}, data.data(), (int) length, chunked ? "\r\n" : "", true).first; - this->resetTimeout(); - return consumed; - } - Super::write(chunkHeadBuffer, (int) chunkHeadLength); - while (consumed < length) { - int chunk = (int) std::min(length - consumed, (size_t) INT_MAX); - auto [written, failed] = Super::write(data.data() + consumed, chunk, true); - consumed += (size_t) written; - if (written < chunk || failed) break; - } - - if (consumed == length && chunked) { - Super::write("\r\n", 2); - } - + size_t consumed = Super::writeFramed({chunkHeadBuffer, chunkHeadLength}, data.data(), data.length(), chunked ? "\r\n" : "", true).first; this->resetTimeout(); return consumed; } @@ -957,12 +899,8 @@ struct HttpResponse : public AsyncSocket { HttpResponseData *httpResponseData = getHttpResponseData(); bool chunked = !(httpResponseData->state & (HttpResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER | HttpResponseData::HTTP_ANCIENT_REQUEST | HttpResponseData::HTTP_CLOSE_DELIMITED)); - size_t length = data.length(); - size_t done = 0; - while (done < length) { - int chunk = (int) std::min(length - done, (size_t) INT_MAX); - Super::write(data.data() + done, chunk); - done += (size_t) chunk; + if (!data.empty()) { + Super::write(data.data(), data.length()); } if (chunked) { Super::write("\r\n", 2); diff --git a/packages/bun-uws/src/Loop.h b/packages/bun-uws/src/Loop.h index 6ee007d54ecc..38312118debc 100644 --- a/packages/bun-uws/src/Loop.h +++ b/packages/bun-uws/src/Loop.h @@ -22,11 +22,15 @@ /* The loop is lazily created per-thread and run with run() */ #include "LoopData.h" +#include #include #include "AsyncSocket.h" extern "C" int bun_is_exiting(); extern "C" void __attribute__((__noreturn__)) Bun__panic(const char *message, size_t length); +#ifndef _WIN32 +extern "C" void __attribute__((__noreturn__)) Bun__loopInitFailed(int err); +#endif namespace uWS { struct Loop { @@ -89,8 +93,12 @@ struct Loop { /* The per-thread loop is not recoverable; every caller of get() * dereferences it. Only Bun.spawnSync's isolated loop (created * through the Rust uws::Loop::create) surfaces this as an error. */ - static const char msg[] = "failed to create the event loop (out of file descriptors?)"; +#ifndef _WIN32 + Bun__loopInitFailed(errno); +#else + static const char msg[] = "failed to create the event loop"; Bun__panic(msg, sizeof(msg) - 1); +#endif } return loop->init(); } diff --git a/packages/bun-uws/src/PerMessageDeflate.h b/packages/bun-uws/src/PerMessageDeflate.h index c9c9f1c85ad3..2618654689a8 100644 --- a/packages/bun-uws/src/PerMessageDeflate.h +++ b/packages/bun-uws/src/PerMessageDeflate.h @@ -96,6 +96,7 @@ namespace uWS { #include #include #include "libdeflate.h" +#include "ReceiveBuffer.h" namespace uWS { @@ -106,7 +107,7 @@ struct ZlibContext { * We need to have two classes to allow inflation followed * by many deflations without modifying the inflation */ std::string dynamicDeflationBuffer; - std::string dynamicInflationBuffer; + ReceiveBuffer dynamicInflationBuffer; char *deflationBuffer; char *inflationBuffer; libdeflate_decompressor *decompressor; @@ -191,7 +192,8 @@ struct DeflationStream { struct InflateResult { std::string_view data; - /* 0 on success, else the WebSocket close code: 1007 corrupt stream, 1009 over maxPayloadLength */ + /* 0 on success, otherwise a WebSocket close code: 1007 (corrupt stream), 1009 (over maxPayloadLength) + * or 1011 (out of memory) */ int closeCode; }; @@ -241,13 +243,14 @@ struct InflationStream { memcpy(tailLocation, tail, 4); compressed = {compressed.data(), compressed.length() + 4}; - /* We clear this one here, could be done better */ + /* We clear this one here, could be done better. clear() keeps the allocation for the next message. */ zlibContext->dynamicInflationBuffer.clear(); inflationStream.next_in = (Bytef *) compressed.data(); inflationStream.avail_in = (unsigned int) compressed.length(); int err; + bool outOfMemory = false; do { inflationStream.next_out = (Bytef *) zlibContext->inflationBuffer; inflationStream.avail_out = LARGE_BUFFER_SIZE; @@ -263,8 +266,11 @@ struct InflationStream { break; } - zlibContext->dynamicInflationBuffer.append(zlibContext->inflationBuffer, LARGE_BUFFER_SIZE - inflationStream.avail_out); - + /* Don't return here. The shared stream still has to be reset below. */ + if (!zlibContext->dynamicInflationBuffer.tryAppend(zlibContext->inflationBuffer, LARGE_BUFFER_SIZE - inflationStream.avail_out)) { + outOfMemory = true; + break; + } } while (inflationStream.avail_out == 0 && zlibContext->dynamicInflationBuffer.length() <= maxPayloadLength); @@ -275,6 +281,12 @@ struct InflationStream { /* Restore the bytes we used for the tail */ memcpy(tailLocation, preTailBytes, 4); + if (outOfMemory) { + /* Every connection on this loop shares this buffer, so free it. */ + zlibContext->dynamicInflationBuffer.release(); + return {{}, 1011}; + } + if (err != Z_BUF_ERROR && err != Z_OK) { return {{}, 1007}; } @@ -285,8 +297,11 @@ struct InflationStream { std::string_view inflated(zlibContext->inflationBuffer, LARGE_BUFFER_SIZE - inflationStream.avail_out); if (zlibContext->dynamicInflationBuffer.length()) { - zlibContext->dynamicInflationBuffer.append(inflated); - inflated = zlibContext->dynamicInflationBuffer; + if (!zlibContext->dynamicInflationBuffer.tryAppend(inflated.data(), inflated.length())) { + zlibContext->dynamicInflationBuffer.release(); + return {{}, 1011}; + } + inflated = {zlibContext->dynamicInflationBuffer.data(), zlibContext->dynamicInflationBuffer.length()}; } if (inflated.length() > maxPayloadLength) { diff --git a/packages/bun-uws/src/ReceiveBuffer.h b/packages/bun-uws/src/ReceiveBuffer.h new file mode 100644 index 000000000000..6c448fc2ae45 --- /dev/null +++ b/packages/bun-uws/src/ReceiveBuffer.h @@ -0,0 +1,82 @@ +#ifndef UWS_RECEIVEBUFFER_H +#define UWS_RECEIVEBUFFER_H + +#include "libusockets.h" + +#include +#include +#include + +namespace uWS { + +/* Contiguous buffer for bytes received from a peer. The peer controls the size, so tryReserve() and + * tryAppend() return false if allocation fails, without changing the contents. */ +struct ReceiveBuffer { + ReceiveBuffer() = default; + ReceiveBuffer(const ReceiveBuffer &) = delete; + ReceiveBuffer &operator=(const ReceiveBuffer &) = delete; + ~ReceiveBuffer() { us_free(buf); } + + size_t length() const { return len; } + char *data() { return buf; } + + /* Grows the capacity to at least n bytes. */ + [[nodiscard]] bool tryReserve(size_t n) { + if (n <= cap) return true; + + char *grown; + if (len) { + grown = (char *) us_realloc(buf, n); + } else { + /* The buffer is empty, so skip realloc, which could copy the stale bytes. */ + us_free(buf); + buf = nullptr; + cap = 0; + grown = (char *) us_malloc(n); + } + if (!grown) return false; + + buf = grown; + cap = n; + return true; + } + + [[nodiscard]] bool tryAppend(const char *src, size_t n) { + if (n > cap - len && !tryGrow(n)) return false; + if (n) std::memcpy(buf + len, src, n); + len += n; + return true; + } + + /* Shrinks the length to n bytes without freeing the allocation. */ + void truncate(size_t n) { len = n; } + + /* Empties the buffer and keeps the allocation for the next message. */ + void clear() { len = 0; } + + /* Empties the buffer and frees the allocation. */ + void release() { + us_free(buf); + buf = nullptr; + len = cap = 0; + } + +private: + /* Makes room for n more bytes. Doubles the capacity so appends are amortized O(1), and falls back to the + * exact size if the doubled allocation fails. */ + __attribute__((noinline)) bool tryGrow(size_t n) { + if (n > SIZE_MAX - len) return false; + size_t exact = len + n; + /* If cap * 2 overflows, max() picks exact. */ + size_t doubled = std::max(exact, cap * 2); + return tryReserve(doubled) || (doubled != exact && tryReserve(exact)); + } + + char *buf = nullptr; + size_t len = 0; + size_t cap = 0; +}; + +} + +#endif // UWS_RECEIVEBUFFER_H diff --git a/packages/bun-uws/src/WebSocket.h b/packages/bun-uws/src/WebSocket.h index f449e66819bc..76dea712770c 100644 --- a/packages/bun-uws/src/WebSocket.h +++ b/packages/bun-uws/src/WebSocket.h @@ -326,6 +326,22 @@ struct WebSocket : AsyncSocket { } } + /* Calls the close handler with 1006, or with -failure.kind in a wsPackage context. */ + void emitFailure(const WebSocketFailure &failure, unsigned int detail) { + if (!getContextData()->wsPackage) { + emitClose(1006, failure.diagnostic); + return; + } + + char text[16]; + size_t length = 0; + if (failure.kind == WebSocketFailureKind::InvalidOpcode || failure.kind == WebSocketFailureKind::InvalidControlPayloadLength + || failure.kind == WebSocketFailureKind::InvalidCloseCode) { + length = (size_t) (std::to_chars(text, text + sizeof(text), detail).ptr - text); + } + emitClose(-(int) failure.kind, {text, length}); + } + public: /* Send websocket close frame, send FIN once everything before it is written, emit close event. * Will not append a close reason if code is 0 or 1005. */ @@ -347,22 +363,32 @@ struct WebSocket : AsyncSocket { /* RFC 6455 7.1.7, fail the connection after a protocol error. The peer gets failure.closeCode. * The close handler gets 1006, or -failure.kind in a wsPackage context. */ void failConnection(const WebSocketFailure &failure, unsigned int detail) { - if (!getContextData()->wsPackage) { - if (sendClose(failure.closeCode, failure.diagnostic)) { - emitClose(1006, failure.diagnostic); - } + if (sendClose(failure.closeCode, getContextData()->wsPackage ? std::string_view() : failure.diagnostic)) { + emitFailure(failure, detail); + } + } + + /* Fails the connection because a received message couldn't be buffered or copied for its handler. + * Nothing here may allocate for this connection, because a failed allocation would abort the process. */ + void failOutOfMemory() { + WebSocketData *webSocketData = (WebSocketData *) us_socket_ext((us_socket_t *) this); + if (webSocketData->isShuttingDown) { return; } - if (sendClose(failure.closeCode, {})) { - char text[16]; - size_t length = 0; - if (failure.kind == WebSocketFailureKind::InvalidOpcode || failure.kind == WebSocketFailureKind::InvalidControlPayloadLength - || failure.kind == WebSocketFailureKind::InvalidCloseCode) { - length = (size_t) (std::to_chars(text, text + sizeof(text), detail).ptr - text); - } - emitClose(-(int) failure.kind, {text, length}); + /* Drop the queued publishes, because sendCloseFrame() would write them first. */ + getContextData()->topicTree->freeSubscriber(webSocketData->subscriber); + webSocketData->subscriber = nullptr; + + if (!getBufferedAmount()) { + failConnection(FAIL_OUT_OF_MEMORY, 0); + return; } + + /* Sending a Close frame would grow the backpressure buffer, so report the failure and reset the connection. */ + webSocketData->isShuttingDown = true; + emitFailure(FAIL_OUT_OF_MEMORY, 0); + us_socket_close((us_socket_t *) this, LIBUS_SOCKET_CLOSE_CODE_CONNECTION_RESET, nullptr); } /* Corks the response if possible. Leaves already corked socket be. */ diff --git a/packages/bun-uws/src/WebSocketContext.h b/packages/bun-uws/src/WebSocketContext.h index 873f080cefa4..6aed0c165fdf 100644 --- a/packages/bun-uws/src/WebSocketContext.h +++ b/packages/bun-uws/src/WebSocketContext.h @@ -109,6 +109,10 @@ struct WebSocketContext { ? webSocketData->inflationStream->inflate(loopData->zlibContext, {data, length}, webSocketContextData->maxPayloadLength, false) : loopData->inflationStream->inflate(loopData->zlibContext, {data, length}, webSocketContextData->maxPayloadLength, true); + if (inflated.closeCode == FAIL_OUT_OF_MEMORY.closeCode) { + failOutOfMemory(s); + return false; + } if (inflated.closeCode) { failConnection(nullptr, s, inflated.closeCode == FAIL_TOO_BIG_MESSAGE.closeCode ? FAIL_TOO_BIG_MESSAGE : FAIL_INVALID_COMPRESSED_DATA); return false; @@ -118,6 +122,27 @@ struct WebSocketContext { return true; } + /* Frees the partially buffered message and fails the connection. Returns true, like handleFragment() on breakage. */ + __attribute__((noinline)) static bool failOutOfMemory(void *s) { + ((WebSocketData *) us_socket_ext((us_socket_t *) s))->fragmentBuffer.release(); + ((WebSocket *) s)->failOutOfMemory(); + return true; + } + + /* Returns true if the message handler closed the connection or started shutting it down. A connection that's + * shutting down doesn't read anymore, so free its fragment buffer right away. */ + static bool stoppedByHandler(void *s) { + if (us_socket_is_closed((us_socket_t *) s)) { + return true; + } + WebSocketData *webSocketData = (WebSocketData *) us_socket_ext((us_socket_t *) s); + if (!webSocketData->isShuttingDown) { + return false; + } + webSocketData->fragmentBuffer.release(); + return true; + } + /* Returns true on breakage */ static bool handleFragment(char *data, size_t length, unsigned int remainingBytes, int opCode, bool fin, WebSocketState *webSocketState, void *s) { /* WebSocketData and WebSocketContextData */ @@ -147,22 +172,24 @@ struct WebSocketContext { /* Emit message event & break if we are closed or shut down when returning */ if (webSocketContextData->messageHandler) { webSocketContextData->messageHandler((WebSocket *) s, std::string_view(data, length), (OpCode) opCode); - if (us_socket_is_closed((us_socket_t *) s) || webSocketData->isShuttingDown) { + if (stoppedByHandler(s)) { return true; } } } else { - /* Allocate fragment buffer up front first time, but at most 16 MiB. The length comes from the - * frame header, which the peer controls, so don't trust it for large allocations. */ + /* Reserve the whole frame up front the first time, capped at 16 MiB because the peer controls the + * length. If that fails, the buffer grows as bytes arrive. */ if (!webSocketData->fragmentBuffer.length()) { - webSocketData->fragmentBuffer.reserve(std::min(length + remainingBytes, 16 * 1024 * 1024)); + (void) webSocketData->fragmentBuffer.tryReserve(std::min(length + remainingBytes, 16 * 1024 * 1024)); } /* Fragments forming a big message are not caught until appending them */ if (refusePayloadLength(length + webSocketData->fragmentBuffer.length(), webSocketState, s)) { failConnection(webSocketState, s, FAIL_TOO_BIG_MESSAGE); return true; } - webSocketData->fragmentBuffer.append(data, length); + if (!webSocketData->fragmentBuffer.tryAppend(data, length)) { + return failOutOfMemory(s); + } /* Are we done now? */ // todo: what if we don't have any remaining bytes yet we are not fin? forceclose! @@ -173,7 +200,9 @@ struct WebSocketContext { webSocketData->compressionStatus = WebSocketData::CompressionStatus::ENABLED; /* 9 bytes of padding for libdeflate, 4 for zlib */ - webSocketData->fragmentBuffer.append("123456789"); + if (!webSocketData->fragmentBuffer.tryAppend("123456789", 9)) { + return failOutOfMemory(s); + } data = webSocketData->fragmentBuffer.data(); length = webSocketData->fragmentBuffer.length() - 9; @@ -195,7 +224,7 @@ struct WebSocketContext { /* Emit message and check for shutdown or close */ if (webSocketContextData->messageHandler) { webSocketContextData->messageHandler((WebSocket *) s, std::string_view(data, length), (OpCode) opCode); - if (us_socket_is_closed((us_socket_t *) s) || webSocketData->isShuttingDown) { + if (stoppedByHandler(s)) { return true; } } @@ -232,7 +261,9 @@ struct WebSocketContext { } } else { /* Here we never mind any size optimizations as we are in the worst possible path */ - webSocketData->fragmentBuffer.append(data, length); + if (!webSocketData->fragmentBuffer.tryAppend(data, length)) { + return failOutOfMemory(s); + } webSocketData->controlTipLength += (unsigned int) length; if (!remainingBytes && fin) { @@ -260,7 +291,7 @@ struct WebSocketContext { } /* Same here, we do not care for any particular smart allocation scheme */ - webSocketData->fragmentBuffer.resize((unsigned int) webSocketData->fragmentBuffer.length() - webSocketData->controlTipLength); + webSocketData->fragmentBuffer.truncate(webSocketData->fragmentBuffer.length() - webSocketData->controlTipLength); webSocketData->controlTipLength = 0; } } diff --git a/packages/bun-uws/src/WebSocketData.h b/packages/bun-uws/src/WebSocketData.h index 55e6760d7716..572dfc7009fb 100644 --- a/packages/bun-uws/src/WebSocketData.h +++ b/packages/bun-uws/src/WebSocketData.h @@ -21,10 +21,9 @@ #include "WebSocketProtocol.h" #include "AsyncSocketData.h" #include "PerMessageDeflate.h" +#include "ReceiveBuffer.h" #include "TopicTree.h" -#include - namespace uWS { struct WebSocketData : AsyncSocketData, WebSocketState { @@ -33,7 +32,7 @@ struct WebSocketData : AsyncSocketData, WebSocketState { template friend struct WebSocketContextData; template friend struct WebSocket; private: - std::string fragmentBuffer; + ReceiveBuffer fragmentBuffer; unsigned int controlTipLength = 0; bool isShuttingDown = 0; /* Set together with isShuttingDown, except in a wsPackage context: there isShuttingDown waits for the peer's close frame */ diff --git a/packages/bun-uws/src/WebSocketProtocol.h b/packages/bun-uws/src/WebSocketProtocol.h index 96b1218850fd..c68ccf2033e1 100644 --- a/packages/bun-uws/src/WebSocketProtocol.h +++ b/packages/bun-uws/src/WebSocketProtocol.h @@ -44,7 +44,8 @@ enum class WebSocketFailureKind : int { InvalidUtf8, MessageTooBig, UnsupportedPayloadLength, - InvalidCompressedData + InvalidCompressedData, + OutOfMemory }; /* Reason for failing a connection (RFC 6455 7.1.7) */ @@ -69,6 +70,8 @@ inline constexpr WebSocketFailure FAIL_INVALID_TEXT{WebSocketFailureKind::Invali inline constexpr WebSocketFailure FAIL_INVALID_COMPRESSED_DATA{WebSocketFailureKind::InvalidCompressedData, 1007, "Received invalid compressed data"}; inline constexpr WebSocketFailure FAIL_TOO_BIG_MESSAGE{WebSocketFailureKind::MessageTooBig, 1009, "Received too big message"}; inline constexpr WebSocketFailure FAIL_UNSUPPORTED_LENGTH{WebSocketFailureKind::UnsupportedPayloadLength, 1009, "Received too big message"}; +/* Allocating a buffer for the peer's message failed. See WebSocket::failOutOfMemory(). */ +inline constexpr WebSocketFailure FAIL_OUT_OF_MEMORY{WebSocketFailureKind::OutOfMemory, 1011, "Received message that does not fit in memory"}; enum OpCode : unsigned char { CONTINUATION = 0, diff --git a/patches/libdeflate/bounded-emit.patch b/patches/libdeflate/bounded-emit.patch new file mode 100644 index 000000000000..80b868cf414a --- /dev/null +++ b/patches/libdeflate/bounded-emit.patch @@ -0,0 +1,37 @@ +Bounds-check the two output writes that only an ASSERT guards. + +deflate_flush_block() checks a block's cost against the remaining output space +once, after a first pass over the input, then emits the block in a second pass +with no bounds check. Bun can pass libdeflate memory that another thread writes +to (a SharedArrayBuffer view, a shared Bun.mmap region). A write between the +passes can make the block longer than its cost. + +The word-at-a-time flush is already bounded by out_fast_end. With this patch, +the byte-at-a-time tail sets the overflow flag instead of writing past the end, +and the final partial byte returns 0. + +--- a/lib/deflate_compress.c ++++ b/lib/deflate_compress.c +@@ -742,8 +742,10 @@ + } else { \ + /* Flush a byte at a time. */ \ + while (bitcount >= 8) { \ +- ASSERT(out_next < os->end); \ +- *out_next++ = bitbuf; \ ++ if (likely(out_next < os->end)) \ ++ *out_next++ = bitbuf; \ ++ else \ ++ os->overflow = true; \ + bitcount -= 8; \ + bitbuf >>= 8; \ + } \ +@@ -4063,7 +4065,8 @@ + */ + ASSERT(os.bitcount <= 7); + if (os.bitcount) { +- ASSERT(os.next < os.end); ++ if (unlikely(os.next >= os.end)) ++ return 0; + *os.next++ = os.bitbuf; + } + diff --git a/patches/libuv/win-pipe-read-req-count-across-callback.patch b/patches/libuv/win-pipe-read-req-count-across-callback.patch new file mode 100644 index 000000000000..bff9ea217dc5 --- /dev/null +++ b/patches/libuv/win-pipe-read-req-count-across-callback.patch @@ -0,0 +1,48 @@ +--- a/src/win/pipe.c ++++ b/src/win/pipe.c +@@ -2183,7 +2183,6 @@ void uv__process_pipe_read_req(uv_loop_t* loop, + assert(handle->type == UV_NAMED_PIPE); + + handle->flags &= ~(UV_HANDLE_READ_PENDING | UV_HANDLE_CANCELLATION_PENDING); +- DECREASE_PENDING_REQ_COUNT(handle); + eof_timer_stop(handle); + + if (handle->read_req.wait_handle != INVALID_HANDLE_VALUE) { +@@ -2191,11 +2190,17 @@ void uv__process_pipe_read_req(uv_loop_t* loop, + handle->read_req.wait_handle = INVALID_HANDLE_VALUE; + } + +- /* At this point, we're done with bookkeeping. If the user has stopped +- * reading the pipe in the meantime, there is nothing left to do, since there +- * is no callback that we can call. */ +- if (!(handle->flags & UV_HANDLE_READING)) ++ /* Don't decrement reqs_pending until this function is done with the handle. ++ * Otherwise read_cb can call uv_close() and then a nested uv_run(), which ++ * runs the endgame and the close callback, and the owner frees the handle ++ * there. */ ++ ++ /* If the user has stopped reading the pipe in the meantime, there is nothing ++ * left to do, since there is no callback that we can call. */ ++ if (!(handle->flags & UV_HANDLE_READING)) { ++ DECREASE_PENDING_REQ_COUNT(handle); + return; ++ } + + if (!REQ_SUCCESS(req)) { + /* An error occurred doing the zero-read. */ +@@ -2226,11 +2231,15 @@ void uv__process_pipe_read_req(uv_loop_t* loop, + } + } + ++ assert(!(handle->flags & UV_HANDLE_CLOSED)); ++ + /* Start another zero-read request if necessary. */ + if ((handle->flags & UV_HANDLE_READING) && + !(handle->flags & UV_HANDLE_READ_PENDING)) { + uv__pipe_queue_read(loop, handle); + } ++ ++ DECREASE_PENDING_REQ_COUNT(handle); + } + + diff --git a/patches/libuv/win-poll-no-reendgame-after-close.patch b/patches/libuv/win-poll-no-reendgame-after-close.patch new file mode 100644 index 000000000000..3698f5ba748e --- /dev/null +++ b/patches/libuv/win-poll-no-reendgame-after-close.patch @@ -0,0 +1,22 @@ +--- a/src/win/poll.c ++++ b/src/win/poll.c +@@ -242,8 +242,11 @@ static void uv__fast_poll_process_poll_req(uv_loop_t* loop, uv_poll_t* handle, + handle->submitted_events_2)) != 0) { + uv__fast_poll_submit_poll_req(loop, handle); + } else if ((handle->flags & UV_HANDLE_CLOSING) && ++ !(handle->flags & UV_HANDLE_CLOSED) && + handle->submitted_events_1 == 0 && + handle->submitted_events_2 == 0) { ++ /* A nested uv_run entered from poll_cb may have run this handle's endgame. ++ * Queuing it again would invoke close_cb a second time. */ + uv__want_endgame(loop, (uv_handle_t*) handle); + } + } +@@ -443,6 +446,7 @@ static void uv__slow_poll_process_poll_req(uv_loop_t* loop, uv_poll_t* handle, + handle->submitted_events_2)) != 0) { + uv__slow_poll_submit_poll_req(loop, handle); + } else if ((handle->flags & UV_HANDLE_CLOSING) && ++ !(handle->flags & UV_HANDLE_CLOSED) && + handle->submitted_events_1 == 0 && + handle->submitted_events_2 == 0) { + uv__want_endgame(loop, (uv_handle_t*) handle); diff --git a/patches/lsquic/node-quic-accessors.patch b/patches/lsquic/node-quic-accessors.patch index 8317ea477a95..b81feeef1fec 100644 --- a/patches/lsquic/node-quic-accessors.patch +++ b/patches/lsquic/node-quic-accessors.patch @@ -1918,7 +1918,7 @@ handle_early_read_shutdown(stream); } SM_HISTORY_APPEND(stream, SHE_SHUTDOWN_READ); -@@ -4442,6 +4464,62 @@ lsquic_stream_close (lsquic_stream_t *stream) +@@ -4442,6 +4464,67 @@ lsquic_stream_close (lsquic_stream_t *stream) } @@ -1957,6 +1957,11 @@ +lsquic_stream_force_reset_ext (struct lsquic_stream *stream, + uint64_t error_code) +{ ++ /* bun: a finished stream is freed in this tick, and the packet of a ++ * RESET_STREAM would keep a pointer to it. Every byte and the FIN are ++ * acked, so there is nothing to reset. */ ++ if (stream->stream_flags & STREAM_FINISHED) ++ return; + /* bun: stream_reset clears every queued sending flag; a STOP_SENDING + * the application queued just before the reset (Node's shutdown_stream + * emits both frames) must still reach the wire. */ diff --git a/patches/lsquic/prq-free-returned-conns.patch b/patches/lsquic/prq-free-returned-conns.patch new file mode 100644 index 000000000000..32559bbcfbe3 --- /dev/null +++ b/patches/lsquic/prq-free-returned-conns.patch @@ -0,0 +1,16 @@ +--- a/src/liblsquic/lsquic_pr_queue.c ++++ b/src/liblsquic/lsquic_pr_queue.c +@@ -237,6 +237,13 @@ lsquic_prq_destroy (struct pr_queue *prq) + TAILQ_REMOVE(&prq->prq_free_conns, conn, cn_next_pr); + free(conn); + } ++ /* bun: a conn whose packet was not sent waits on the returned list for ++ * the next send_packets_out(). The queue still owns it. */ ++ while ((conn = TAILQ_FIRST(&prq->prq_returned_conns))) ++ { ++ TAILQ_REMOVE(&prq->prq_returned_conns, conn, cn_next_pr); ++ free(conn); ++ } + lsquic_hash_destroy(prq->prq_reqs_hash); + lsquic_malo_destroy(prq->prq_reqs_pool); + free(prq); diff --git a/patches/mimalloc/new-oom-hook.patch b/patches/mimalloc/new-oom-hook.patch new file mode 100644 index 000000000000..c4b269c53dce --- /dev/null +++ b/patches/mimalloc/new-oom-hook.patch @@ -0,0 +1,40 @@ +Report out of memory instead of returning NULL from a throwing operator new. + +mimalloc implements the C++ `operator new` family (alloc-override.c) and is +Bun's global allocator on Linux. Bun compiles without exceptions and installs +no `std::new_handler`, so the no-exceptions arm of mi_try_new_handler reaches +`h == NULL`, cannot throw, and returns false. mi_theap_try_new then returns +NULL to a caller the standard promised memory, and the caller writes through +it: a refused `std::string` growth faults at address 0x0. + +With -DMI_NEW_OOM= that arm calls the named noreturn function +instead. `new (std::nothrow)` still returns NULL. + +--- a/src/alloc.c ++++ b/src/alloc.c +@@ -759,6 +759,14 @@ + -------------------------------------------------------*/ + #define MI_TRY_NEW_MAX (4) + ++#if defined(MI_NEW_OOM) && !defined(_CPPUNWIND) && !defined(__cpp_exceptions) ++// Reports out of memory and does not return. Stands in for the ++// `throw std::bad_alloc()` that a build without exceptions cannot do, so a ++// throwing `new` never returns NULL to a caller the standard promised memory. ++mi_decl_externc mi_decl_noreturn void MI_NEW_OOM(void); ++#define MI_HAS_NEW_OOM 1 ++#endif ++ + #ifdef __cplusplus + #include + static bool mi_try_new_handler(bool nothrow) { +@@ -774,6 +782,10 @@ + if (!nothrow) { + throw std::bad_alloc(); + } ++ #elif defined(MI_HAS_NEW_OOM) ++ if (!nothrow) { ++ MI_NEW_OOM(); ++ } + #else + MI_UNUSED(nothrow); + #endif diff --git a/patches/rust-argon2/fallible-memory.patch b/patches/rust-argon2/fallible-memory.patch new file mode 100644 index 000000000000..b3ba0c9f52aa --- /dev/null +++ b/patches/rust-argon2/fallible-memory.patch @@ -0,0 +1,133 @@ +--- a/src/argon2.rs ++++ b/src/argon2.rs +@@ -97,7 +97,7 @@ pub fn encoded_len( + /// ``` + pub fn hash_encoded(pwd: &[u8], salt: &[u8], config: &Config) -> Result { + let context = Context::new(config.clone(), pwd, salt)?; +- let hash = run(&context); ++ let hash = run(&context)?; + let encoded = encoding::encode_string(&context, &hash); + Ok(encoded) + } +@@ -141,7 +141,7 @@ pub fn hash_encoded(pwd: &[u8], salt: &[u8], config: &Config) -> Result + /// ``` + pub fn hash_raw(pwd: &[u8], salt: &[u8], config: &Config) -> Result> { + let context = Context::new(config.clone(), pwd, salt)?; +- let hash = run(&context); ++ let hash = run(&context)?; + Ok(hash) + } + +@@ -220,12 +220,12 @@ pub fn verify_raw(pwd: &[u8], salt: &[u8], hash: &[u8], config: &Config) -> Resu + ..config.clone() + }; + let context = Context::new(config, pwd, salt)?; +- let calculated_hash = run(&context); ++ let calculated_hash = run(&context)?; + Ok(constant_time_eq(hash, &calculated_hash)) + } + +-fn run(context: &Context) -> Vec { +- let mut memory = Memory::new(context.config.lanes, context.lane_length); ++fn run(context: &Context) -> Result> { ++ let mut memory = Memory::new(context.config.lanes, context.lane_length)?; + core::initialize(context, &mut memory); + // SAFETY: `memory` is constructed from `context`. + unsafe { core::fill_memory_blocks(context, &mut memory) }; +--- a/src/core.rs ++++ b/src/core.rs +@@ -11,7 +11,9 @@ + use crate::block::Block; + use crate::common; + use crate::context::Context; ++use crate::error::Error; + use crate::memory::{Memory, UnsafeBlocks}; ++use crate::result::Result; + use crate::variant::Variant; + use crate::version::Version; + use blake2b_simd::Params; +@@ -48,16 +50,20 @@ pub unsafe fn fill_memory_blocks(context: &Context, memory: &mut Memory) { + } + + /// Calculates the final hash and returns it. +-pub fn finalize(context: &Context, memory: &Memory) -> Vec { ++pub fn finalize(context: &Context, memory: &Memory) -> Result> { + let mut blockhash = memory[context.lane_length - 1].clone(); + for l in 1..context.config.lanes { + let last_block_in_lane = l * context.lane_length + (context.lane_length - 1); + blockhash ^= &memory[last_block_in_lane]; + } + +- let mut hash = vec![0u8; context.config.hash_length as usize]; ++ let hash_length = context.config.hash_length as usize; ++ let mut hash = Vec::new(); ++ hash.try_reserve_exact(hash_length) ++ .map_err(|_| Error::MemoryAllocationFailed)?; ++ hash.resize(hash_length, 0u8); + hprime(hash.as_mut_slice(), blockhash.as_u8()); +- hash ++ Ok(hash) + } + + fn blake2b(out: &mut [u8], input: &[&[u8]]) { +--- a/src/error.rs ++++ b/src/error.rs +@@ -58,6 +58,9 @@ pub enum Error { + /// The memory cost is too large (maximum 2GiB on 32-bit or 4TiB on 64-bit). + MemoryTooMuch, + ++ /// The memory matrix or the output could not be allocated. ++ MemoryAllocationFailed, ++ + /// The number of lanes (parallelism) is too small (minimum is 1). + LanesTooFew, + +@@ -91,6 +94,7 @@ impl Error { + Error::TimeTooLarge => "Time cost is too large", + Error::MemoryTooLittle => "Memory cost is too small", + Error::MemoryTooMuch => "Memory cost is too large", ++ Error::MemoryAllocationFailed => "Memory allocation failed", + Error::LanesTooFew => "Too few lanes", + Error::LanesTooMany => "Too many lanes", + Error::IncorrectType => "There is no such type of Argon2", +--- a/src/memory.rs ++++ b/src/memory.rs +@@ -9,6 +9,8 @@ + #![warn(unsafe_op_in_unsafe_fn)] + + use crate::block::Block; ++use crate::error::Error; ++use crate::result::Result; + use std::fmt; + use std::fmt::Debug; + use std::marker::PhantomData; +@@ -29,12 +31,17 @@ pub struct Memory { + + impl Memory { + /// Creates a new memory matrix. +- pub fn new(lanes: u32, lane_length: u32) -> Memory { ++ pub fn new(lanes: u32, lane_length: u32) -> Result { + let rows = lanes as usize; + let cols = lane_length as usize; + let total = rows * cols; +- let blocks = vec![Block::zero(); total].into_boxed_slice(); +- Memory { rows, cols, blocks } ++ let mut blocks = Vec::new(); ++ blocks ++ .try_reserve_exact(total) ++ .map_err(|_| Error::MemoryAllocationFailed)?; ++ blocks.resize_with(total, Block::zero); ++ let blocks = blocks.into_boxed_slice(); ++ Ok(Memory { rows, cols, blocks }) + } + + /// Returns a pointer to the flat array of blocks useful for parallel disjoint access. +@@ -158,7 +165,7 @@ mod tests { + fn new_returns_correct_instance() { + let lanes = 4; + let lane_length = 128; +- let memory = Memory::new(lanes, lane_length); ++ let memory = Memory::new(lanes, lane_length).unwrap(); + assert_eq!(memory.rows, lanes as usize); + assert_eq!(memory.cols, lane_length as usize); + assert_eq!(memory.blocks.len(), 512); diff --git a/patches/zstd/free-workers-before-dictionaries.patch b/patches/zstd/free-workers-before-dictionaries.patch new file mode 100644 index 000000000000..c954c98e7d61 --- /dev/null +++ b/patches/zstd/free-workers-before-dictionaries.patch @@ -0,0 +1,28 @@ +Free the worker threads of a context before the dictionaries they read. + +ZSTD_freeCCtxContent() calls ZSTD_clearAllDicts() and then ZSTDMT_freeCCtx(). +With nbWorkers >= 1, ZSTD_compressStream2() posts a job to a worker thread and +returns. The first job of a frame reads the dictionary of the context through +a pointer that it does not own (job->cdict). A job stops only in +ZSTDMT_freeCCtx(), which joins the threads. So ZSTD_freeCCtx() of a context that +has a dictionary and a running job frees the dictionary under that job. + +ZSTD_CCtx_reset() with parameters, ZSTD_CCtx_loadDictionary(), ZSTD_CCtx_refCDict() +and ZSTD_CCtx_refPrefix() free the dictionaries too. Bun does not call them on a +context whose jobs still run. + +--- a/lib/compress/zstd_compress.c ++++ b/lib/compress/zstd_compress.c +@@ -171,10 +171,11 @@ + { + assert(cctx != NULL); + assert(cctx->staticSize == 0); +- ZSTD_clearAllDicts(cctx); + #ifdef ZSTD_MULTITHREAD + ZSTDMT_freeCCtx(cctx->mtctx); cctx->mtctx = NULL; + #endif ++ /* Bun: after the join above. A job that still runs reads these dictionaries. */ ++ ZSTD_clearAllDicts(cctx); + ZSTD_cwksp_free(&cctx->workspace, cctx->customMem); + } + diff --git a/scripts/build/codegen.ts b/scripts/build/codegen.ts index 24787cc95792..40556b18596d 100644 --- a/scripts/build/codegen.ts +++ b/scripts/build/codegen.ts @@ -32,7 +32,7 @@ import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { basename, dirname, relative, resolve } from "node:path"; import type { Sources } from "../glob-sources.ts"; import { generateBuildOptionsRs } from "./buildOptionsRs.ts"; -import type { CodegenFields } from "./config.ts"; +import { webkitVersionName, type CodegenFields } from "./config.ts"; import { BuildError, assert } from "./error.ts"; import { writeIfChanged } from "./fs.ts"; import { generateJsonByteClass } from "./jsonByteClass.ts"; @@ -834,6 +834,8 @@ function emitJsModules({ n, cfg, sources, o, dirStamp }: Ctx): void { // ($makeErrorWithCode(N, ...)); without this dep an ErrorCode.ts edit leaves // stale error numbers in the JS bundles while the C++ enum regenerates. const errorCodeInput = resolve(cfg.cwd, "src", "jsc", "bindings", "ErrorCode.ts"); + // bundle-modules.ts hashes this header's entries into the builtins section (engineStamp). + const builtinNamesInput = resolve(cfg.cwd, "src", "js", "builtins", "BunBuiltinNames.h"); const outputs = [ resolve(cfg.codegenDir, "WebCoreJSBuiltins.cpp"), @@ -864,14 +866,22 @@ function emitJsModules({ n, cfg, sources, o, dirStamp }: Ctx): void { n.build({ outputs: [...outputs, ...types], rule: "codegen_bun", - inputs: [script, ...sources.js, ...sources.jsCodegen, extraInput, errorCodeInput], + inputs: [script, ...sources.js, ...sources.jsCodegen, extraInput, errorCodeInput, builtinNamesInput], orderOnlyInputs: [dirStamp], vars: { cwd: cfg.cwd, desc: "JS modules (bundle-modules)", // Note: arg is BUILD_PATH (buildDir), not CODEGEN_PATH. The script // derives CODEGEN_DIR = join(BUILD_PATH, "codegen") internally. - args: shJoin(cfg, ["run", script, debugFlag(cfg), cfg.buildDir, cfg.typesDir, typesOnlyFlag(cfg, sources)]), + args: shJoin(cfg, [ + "run", + script, + debugFlag(cfg), + cfg.buildDir, + cfg.typesDir, + typesOnlyFlag(cfg, sources), + `--webkit-version=${webkitVersionName(cfg.webkitVersion)}`, + ]), }, }); diff --git a/scripts/build/config.ts b/scripts/build/config.ts index bb20c5fe9354..bfa2cdf38348 100644 --- a/scripts/build/config.ts +++ b/scripts/build/config.ts @@ -59,6 +59,14 @@ const versionDefaults = { webkitVersion: WEBKIT_VERSION, }; +/** + * Returns the `process.versions.webkit` string for `webkitVersion`. A tag + * becomes the bare sha and a preview becomes `preview-pr--`. + */ +export function webkitVersionName(webkitVersion: string): string { + return webkitVersion.startsWith("autobuild-") ? webkitVersion.slice("autobuild-".length) : webkitVersion; +} + /** * The full resolved build configuration. Every field is concrete — no * undefined-because-it-depends-on-something-else. This is the single source @@ -843,6 +851,8 @@ function resolveBase(partial: PartialConfig, host: Host, os: OS, arch: Arch, js: const revision = getGitRevision(cwd, debug && !ci ? buildDir : undefined); // Default from versions.ts. Override via --nodejs-version= to test a bump. const nodejsVersion = partial.nodejsVersion ?? versionDefaults.nodejsVersion; + // Override via --webkit-version= to test a branch before bumping the pin. + const webkitVersion = partial.webkitVersion ?? versionDefaults.webkitVersion; return { bun: js.bun, @@ -855,6 +865,7 @@ function resolveBase(partial: PartialConfig, host: Host, os: OS, arch: Arch, js: version, revision, nodejsVersion, + webkitVersion, abi, linux, darwin, @@ -901,6 +912,7 @@ export type CodegenFields = Pick< | "version" | "revision" | "nodejsVersion" + | "webkitVersion" | keyof JsToolchain >; @@ -955,6 +967,7 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con version, revision, nodejsVersion, + webkitVersion, bun, npm, jsRuntime, @@ -1214,11 +1227,10 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con } // ─── Versioning ─── - // Defaults from versions.ts. Override via --webkit-version= etc. - // to test a branch before bumping the pinned default. + // Defaults from versions.ts. Override to test a bump before changing the + // pinned default. const nodejsAbiVersion = partial.nodejsAbiVersion ?? versionDefaults.nodejsAbiVersion; const nodejsV8Version = partial.nodejsV8Version ?? versionDefaults.nodejsV8Version; - const webkitVersion = partial.webkitVersion ?? versionDefaults.webkitVersion; // ─── macOS SDK ─── // Must be passed to nested cmake builds or they'll pick the wrong SDK. diff --git a/scripts/build/depVersionsHeader.ts b/scripts/build/depVersionsHeader.ts index 5bc6aaebff51..fd4e1e3f3fb4 100644 --- a/scripts/build/depVersionsHeader.ts +++ b/scripts/build/depVersionsHeader.ts @@ -14,7 +14,7 @@ import { mkdirSync } from "node:fs"; import { resolve } from "node:path"; -import type { Config } from "./config.ts"; +import { webkitVersionName, type Config } from "./config.ts"; import { allDeps } from "./deps/index.ts"; import { writeIfChanged } from "./fs.ts"; import type { Source } from "./source.ts"; @@ -52,11 +52,9 @@ function computeVersions(cfg: Config): [string, string][] { const source = dep.source(cfg); const id = sourceIdentifier(source); // WebKit special case: prebuilt.identity carries the artifact suffix, but - // process.versions wants the version. Strip the autobuild- prefix so tags - // report the clean sha and previews report preview-pr--. + // process.versions wants the version. if (dep.name === "WebKit") { - const v = cfg.webkitVersion; - versions.push([dep.versionMacro, v.startsWith("autobuild-") ? v.slice("autobuild-".length) : v]); + versions.push([dep.versionMacro, webkitVersionName(cfg.webkitVersion)]); } else if (id !== undefined) { versions.push([dep.versionMacro, id]); } diff --git a/scripts/build/deps/libdeflate.ts b/scripts/build/deps/libdeflate.ts index d16a7b580ece..cb755eeb231f 100644 --- a/scripts/build/deps/libdeflate.ts +++ b/scripts/build/deps/libdeflate.ts @@ -21,6 +21,8 @@ export const libdeflate: Dependency = { commit: LIBDEFLATE_COMMIT, }), + patches: ["patches/libdeflate/bounded-emit.patch"], + build: () => ({ kind: "direct", sources: [ diff --git a/scripts/build/deps/libuv.ts b/scripts/build/deps/libuv.ts index edcd6bf3b4cb..6fcb17118971 100644 --- a/scripts/build/deps/libuv.ts +++ b/scripts/build/deps/libuv.ts @@ -60,7 +60,22 @@ export const libuv: Dependency = { // an in-process loopback fetch().abort() can fall into. To upstream: // send to libuv/libuv with the wepoll/ReactOS references in the patch // comment as the rationale. - patches: ["patches/libuv/win-poll-rearm-before-callback.patch", "patches/libuv/win-poll-abort-with-disconnect.patch"], + // + // win-pipe-read-req-count-across-callback: makes uv__process_pipe_read_req + // decrement reqs_pending last, like every other DECREASE_PENDING_REQ_COUNT + // user. Otherwise JS inside read_cb can close the pipe and re-enter the loop, + // which runs the close callback and frees the uv_pipe_t while that function + // is still using it. + // + // win-poll-no-reendgame-after-close: doesn't queue the endgame after poll_cb + // if a nested uv_run inside poll_cb already ran it. Otherwise close_cb runs + // twice. + patches: [ + "patches/libuv/win-poll-rearm-before-callback.patch", + "patches/libuv/win-poll-abort-with-disconnect.patch", + "patches/libuv/win-poll-no-reendgame-after-close.patch", + "patches/libuv/win-pipe-read-req-count-across-callback.patch", + ], build: () => ({ kind: "direct", diff --git a/scripts/build/deps/lsquic.ts b/scripts/build/deps/lsquic.ts index b60d95f0828d..81895bd1aaed 100644 --- a/scripts/build/deps/lsquic.ts +++ b/scripts/build/deps/lsquic.ts @@ -134,6 +134,9 @@ export const lsquic: Dependency = { // H3_CLOSED_CRITICAL_STREAM and closes the connection, which kills the // requests the graceful stop was draining. Reject only request streams. "patches/lsquic/goaway-accept-uni-streams.patch", + // lsquic_prq_destroy() only frees the free list. A stateless reset whose + // send failed is on the returned list, so destroying the engine leaks it. + "patches/lsquic/prq-free-returned-conns.patch", ], fetchDeps: ["zlib", "lshpack", "lsqpack", "boringssl"], diff --git a/scripts/build/deps/mimalloc.ts b/scripts/build/deps/mimalloc.ts index f162fdf97830..e4415d45847f 100644 --- a/scripts/build/deps/mimalloc.ts +++ b/scripts/build/deps/mimalloc.ts @@ -24,6 +24,8 @@ export const mimalloc: Dependency = { commit: MIMALLOC_COMMIT, }), + patches: ["patches/mimalloc/new-oom-hook.patch"], + build: cfg => { // ─── Override behavior (global malloc replacement) ─── // ASAN: OFF — ASAN interceptors must see the real malloc. @@ -115,7 +117,12 @@ export const mimalloc: Dependency = { cflags.push(cfg.abi === "musl" ? "-ftls-model=local-dynamic" : "-ftls-model=initial-exec"); } - if (override) cflags.push("-fno-builtin-malloc"); + if (override) { + cflags.push("-fno-builtin-malloc"); + // mimalloc also implements `operator new` here. See patches/mimalloc/new-oom-hook.patch. + // The value is a function name, and a `defines` entry would quote it. + cflags.push("-DMI_NEW_OOM=Bun__outOfMemory"); + } // ─── Windows: silence the vendored-C-as-C++ warning flood ─── // lang:"c++" means .c compiles as C++; clang-cl then complains about diff --git a/scripts/build/deps/rust-argon2.ts b/scripts/build/deps/rust-argon2.ts index f5c71e5c4578..c73695e5af83 100644 --- a/scripts/build/deps/rust-argon2.ts +++ b/scripts/build/deps/rust-argon2.ts @@ -1,7 +1,8 @@ /** * rust-argon2 — argon2 for `Bun.password`. A cargo path dep like `lolhtml` - * (see that file), vendored so the patch below can lift the `m >= 8 * lanes` - * floor: older Bun versions produced hashes with `m < 8` that must still verify. + * (see that file), vendored so the patches below can lift the `m >= 8 * lanes` + * floor (older Bun versions produced hashes with `m < 8` that must still verify) + * and return an error instead of aborting when the allocation fails. */ import type { Dependency } from "../source.ts"; @@ -17,7 +18,7 @@ export const rustArgon2: Dependency = { commit: RUST_ARGON2_COMMIT, }), - patches: ["patches/rust-argon2/legacy-low-memory.patch"], + patches: ["patches/rust-argon2/legacy-low-memory.patch", "patches/rust-argon2/fallible-memory.patch"], build: () => ({ kind: "none" }), diff --git a/scripts/build/deps/zstd.ts b/scripts/build/deps/zstd.ts index 0e12a93386f2..803636dea2de 100644 --- a/scripts/build/deps/zstd.ts +++ b/scripts/build/deps/zstd.ts @@ -41,10 +41,15 @@ export const zstd: Dependency = { commit: ZSTD_COMMIT, }), - // x64 targets nehalem, so zstd picks its BMI2 kernels at run time and - // probes CPUID in every CCtx/DCtx init. CPUID is a VM exit under a - // hypervisor (about 2 us each, two per init). Probe once instead. - patches: ["patches/zstd/bmi2-probe-once.patch"], + patches: [ + // x64 targets nehalem, so zstd picks its BMI2 kernels at run time and + // probes CPUID in every CCtx/DCtx init. CPUID is a VM exit under a + // hypervisor (about 2 us each, two per init). Probe once instead. + "patches/zstd/bmi2-probe-once.patch", + // ZSTD_freeCCtx frees a context's dictionaries before joining the worker + // threads, so a job that's still running can read freed memory. + "patches/zstd/free-workers-before-dictionaries.patch", + ], build: cfg => { const sources = [...SOURCES]; diff --git a/src/ast/expr.rs b/src/ast/expr.rs index 056ca92028e8..0d5a4c82a80a 100644 --- a/src/ast/expr.rs +++ b/src/ast/expr.rs @@ -117,7 +117,7 @@ impl Expr { self.deep_clone_no_detach(bump, bun_core::StackCheck::init()) } #[inline] - pub(crate) fn deep_clone_no_detach( + pub fn deep_clone_no_detach( &self, bump: &Bump, stack_check: bun_core::StackCheck, diff --git a/src/ast/g.rs b/src/ast/g.rs index e8f650babe9b..d4b603adb1ee 100644 --- a/src/ast/g.rs +++ b/src/ast/g.rs @@ -195,6 +195,20 @@ impl Property { self.class_static_block.as_deref_mut() } + /// Copies the property. The copy's expressions and static block still point to `self`'s nodes. + pub fn shallow_clone(&self) -> Property { + Property { + initializer: self.initializer, + kind: self.kind, + flags: self.flags, + class_static_block: self.class_static_block, + ts_decorators: bun_alloc::AstAlloc::vec_from_slice(self.ts_decorators.slice()), + key: self.key, + value: self.value, + ts_metadata: self.ts_metadata.clone(), + } + } + pub(crate) fn deep_clone( &self, bump: &bun_alloc::Arena, diff --git a/src/ast/lib.rs b/src/ast/lib.rs index 0e86641642b6..f4c86093762d 100644 --- a/src/ast/lib.rs +++ b/src/ast/lib.rs @@ -1172,7 +1172,10 @@ impl BabyString { if substring.is_empty() { return BabyString::new(0, 0); } - let off = bun_core::strings::index_of(container, substring).expect("unreachable"); + // `container` is formatted lossily, so a `substring` with invalid UTF-8 may not be in it. + let Some(off) = bun_core::strings::index_of(container, substring) else { + return BabyString::new(0, 0); + }; BabyString::new(off as u16, substring.len() as u16) // @truncate } diff --git a/src/ast/symbol.rs b/src/ast/symbol.rs index 0baff6d2b7b0..a17540946f5e 100644 --- a/src/ast/symbol.rs +++ b/src/ast/symbol.rs @@ -417,6 +417,23 @@ pub type List<'a> = bun_alloc::ArenaVec<'a, Symbol>; /// clones are owned for the link lifetime — global allocator, no arena tag. pub type NestedList = Vec>; +/// Like [`Map::follow`], including path compression, but for the parser's flat per-file symbol table. +pub fn follow_symbols(symbols: &[Symbol], ref_: Ref) -> Ref { + let mut root = ref_; + loop { + let link = symbols[root.inner_index() as usize].link.get(); + if !link.is_valid() { + break; + } + root = link; + } + let mut current = ref_; + while current != root { + current = symbols[current.inner_index() as usize].link.replace(root); + } + root +} + impl Symbol { pub(crate) fn merge_contents_with(&mut self, old: &mut Symbol) { self.use_count_estimate += old.use_count_estimate; diff --git a/src/boringssl_sys/boringssl.rs b/src/boringssl_sys/boringssl.rs index d2a8d39a1516..6b3b1a4dac25 100644 --- a/src/boringssl_sys/boringssl.rs +++ b/src/boringssl_sys/boringssl.rs @@ -1259,8 +1259,8 @@ unsafe extern "C" { /// Returns a NEW reference (caller frees) or null when the peer sent no /// certificate. pub fn SSL_get_peer_certificate(ssl: *const SSL) -> *mut X509; - /// Returns a BORROWED reference to the local certificate, or null. - pub fn SSL_get_certificate(ssl: *const SSL) -> *mut X509; + /// Returns a BORROWED reference to the context's leaf certificate, or null. + pub fn SSL_CTX_get0_certificate(ctx: *const SSL_CTX) -> *mut X509; pub fn SSL_set_session(ssl: *mut SSL, session: *mut SSL_SESSION) -> c_int; pub fn SSL_SESSION_free(session: *mut SSL_SESSION); diff --git a/src/bun_core/Global.rs b/src/bun_core/Global.rs index e7747e078fe8..7954b6b8da95 100644 --- a/src/bun_core/Global.rs +++ b/src/bun_core/Global.rs @@ -676,6 +676,7 @@ fn is_exiting() -> bool { // calls. `#[link_name]` avoids colliding with this module's own `pub fn exit`. #[allow(suspicious_runtime_symbol_definitions)] // signatures are ABI-identical; `safe fn` is intentional (above) unsafe extern "C" { + #[cfg(windows)] #[link_name = "abort"] safe fn libc_abort() -> !; #[link_name = "raise"] @@ -683,6 +684,8 @@ unsafe extern "C" { #[cfg(unix)] #[link_name = "exit"] safe fn libc_exit(code: c_int) -> !; + #[cfg(not(windows))] + safe fn _exit(code: c_int) -> !; #[cfg(all(unix, not(target_os = "macos")))] safe fn quick_exit(code: c_int) -> !; } @@ -772,22 +775,50 @@ pub fn raise_ignoring_panic_handler_raw(sig: c_int) -> ! { } } - // clear signal handler #[cfg(not(windows))] + raise_default_action(sig); + + #[cfg(windows)] { - // SAFETY: zeroed sigset + SIG_DFL handler is a valid Sigaction. - unsafe { - let mut sa: libc::sigaction = crate::ffi::zeroed(); - sa.sa_sigaction = libc::SIG_DFL; - libc::sigemptyset(&raw mut sa.sa_mask); - sa.sa_flags = libc::SA_RESETHAND; - let _ = libc::sigaction(sig, &raw const sa, core::ptr::null_mut()); - } + // kill self; `raise` has no preconditions (see the `safe fn` decl above). + let _ = libc_raise(sig); + // The CRT's raise() returns for SIGTERM and for signals it doesn't recognize. + libc_abort() } +} - // kill self — `raise`/`abort` have no preconditions (see `safe fn` decls above). +/// Kills the process with `sig`'s default action. Exits with 128 + `sig` if the kernel discards +/// the signal (PID 1 in a pid namespace). Async-signal-safe. +#[cfg(not(windows))] +pub fn raise_default_action(sig: c_int) -> ! { + // SAFETY: zeroed sigset + SIG_DFL handler is a valid Sigaction. + unsafe { + let mut sa: libc::sigaction = crate::ffi::zeroed(); + sa.sa_sigaction = libc::SIG_DFL; + libc::sigemptyset(&raw mut sa.sa_mask); + sa.sa_flags = libc::SA_RESETHAND; + let _ = libc::sigaction(sig, &raw const sa, core::ptr::null_mut()); + } + + // raise() only makes a blocked signal pending, and `sig` is blocked inside its own handler. + // SAFETY: zeroed sigset is valid; sigemptyset/sigaddset initialize it. + unsafe { + let mut set: libc::sigset_t = crate::ffi::zeroed(); + libc::sigemptyset(&raw mut set); + libc::sigaddset(&raw mut set, sig); + let _ = libc::pthread_sigmask(libc::SIG_UNBLOCK, &raw const set, core::ptr::null_mut()); + } + + // kill self; `raise` has no preconditions (see the `safe fn` decl above). let _ = libc_raise(sig); - libc_abort(); + _exit(128 + sig) +} + +/// Exposes `raise_default_action` to C++ (`onExitSignal` in c-bindings.cpp). +#[cfg(not(windows))] +#[unsafe(no_mangle)] +extern "C" fn Bun__raiseDefaultAction(sig: c_int) -> ! { + raise_default_action(sig) } #[derive(Default)] @@ -874,6 +905,8 @@ extern "C" fn Bun__onExit() { run_exit_callbacks(); Output::flush(); crate::keep_symbols!(Bun__atexit); + #[cfg(not(windows))] + crate::keep_symbols!(Bun__raiseDefaultAction); Output::source::stdio::restore(); } diff --git a/src/bun_core/env_var.rs b/src/bun_core/env_var.rs index 2c8b1a99f387..78e86cefe1c9 100644 --- a/src/bun_core/env_var.rs +++ b/src/bun_core/env_var.rs @@ -178,6 +178,7 @@ new!(pub JENKINS_URL: string, "JENKINS_URL", {}); new!(pub MI_VERBOSE: boolean, "MI_VERBOSE", { default: false }); new!(pub NO_COLOR: boolean, "NO_COLOR", { default: false }); new!(pub NODE_CHANNEL_FD: string, "NODE_CHANNEL_FD", {}); +new!(pub NODE_CHANNEL_SERIALIZATION_MODE: string, "NODE_CHANNEL_SERIALIZATION_MODE", {}); // A string, not a boolean: node suppresses warnings only when the value is // exactly "1" (lib/internal/process/pre_execution.js). new!(pub NODE_NO_WARNINGS: string, "NODE_NO_WARNINGS", {}); diff --git a/src/bun_core/fmt.rs b/src/bun_core/fmt.rs index 86fbac07555b..9f9c2176d455 100644 --- a/src/bun_core/fmt.rs +++ b/src/bun_core/fmt.rs @@ -586,8 +586,8 @@ pub fn format_utf16_type(slice_: &[u16], writer: &mut impl fmt::Write) -> fmt::R if result.read == 0 || result.written == 0 { break; } - write_bytes(writer, &chunk[..result.written as usize])?; - slice = &slice[result.read as usize..]; + write_bytes(writer, &chunk[..result.written])?; + slice = &slice[result.read..]; } Ok(()) } @@ -608,7 +608,7 @@ pub(crate) fn format_utf16_type_with_path_options( break; } - let to_write = &chunk[..result.written as usize]; + let to_write = &chunk[..result.written]; if !opts.escape_backslashes && opts.path_sep == PathSep::Any { write_bytes(writer, to_write)?; } else { @@ -625,7 +625,7 @@ pub(crate) fn format_utf16_type_with_path_options( } write_bytes(writer, ptr)?; } - slice = &slice[result.read as usize..]; + slice = &slice[result.read..]; } Ok(()) } @@ -1267,8 +1267,8 @@ pub fn format_latin1(slice_: &[u8], writer: &mut impl fmt::Write) -> fmt::Result if result.read == 0 || result.written == 0 { break; } - write_bytes(writer, &chunk[..result.written as usize])?; - slice = &slice[result.read as usize..]; + write_bytes(writer, &chunk[..result.written])?; + slice = &slice[result.read..]; } if !slice.is_empty() { diff --git a/src/bun_core/lib.rs b/src/bun_core/lib.rs index 244144d4c0ad..e7ce6cf189c6 100644 --- a/src/bun_core/lib.rs +++ b/src/bun_core/lib.rs @@ -1744,10 +1744,13 @@ pub(crate) mod strings_impl { } /// Result of an encode-into-fixed-buffer operation. Port of `EncodeIntoResult`. + /// `written` can be exactly 2^32, JSC's max ArrayBuffer size. + /// The C++ mirror is `TextEncoderEncodeIntoResult` in `headers-handwritten.h`. + #[repr(C)] #[derive(Clone, Copy, Default, Debug)] pub struct EncodeIntoResult { - pub read: u32, - pub written: u32, + pub read: usize, + pub written: usize, } /// Port of `elementLengthUTF16IntoUTF8`: the exact UTF-8 byte length of a @@ -1803,8 +1806,8 @@ pub(crate) mod strings_impl { }; if r.status == simdutf::Status::SUCCESS { return EncodeIntoResult { - read: utf16.len() as u32, - written: r.count as u32, + read: utf16.len(), + written: r.count, }; } } @@ -1822,10 +1825,7 @@ pub(crate) mod strings_impl { written += n; read += adv as usize; } - EncodeIntoResult { - read: read as u32, - written: written as u32, - } + EncodeIntoResult { read, written } } /// Port of `copyLatin1IntoUTF8` — encode Latin-1 into a fixed-size UTF-8 buffer. @@ -1889,8 +1889,8 @@ pub(crate) mod strings_impl { debug_assert!(latin1_[read] >= 0x80); if STOP { return EncodeIntoResult { - written: u32::MAX, - read: u32::MAX, + written: usize::MAX, + read: usize::MAX, }; } if buf_.len() - written < 2 { @@ -1902,10 +1902,7 @@ pub(crate) mod strings_impl { read += 1; } - EncodeIntoResult { - written: written as u32, - read: read as u32, - } + EncodeIntoResult { written, read } } /// Null-terminated variant of `to_utf8_from_latin1`. Returns `ZBox` so diff --git a/src/bun_core/string/immutable/unicode.rs b/src/bun_core/string/immutable/unicode.rs index 28b8a744b84d..4228f9dd1820 100644 --- a/src/bun_core/string/immutable/unicode.rs +++ b/src/bun_core/string/immutable/unicode.rs @@ -453,8 +453,8 @@ pub fn copy_cp1252_into_utf16(buf_: &mut [u16], latin1_: &[u8]) -> EncodeIntoRes } EncodeIntoResult { - read: (buf_total - buf.len()) as u32, - written: (latin1_total - latin1.len()) as u32, + read: buf_total - buf.len(), + written: latin1_total - latin1.len(), } } @@ -464,7 +464,6 @@ pub fn copy_latin1_into_utf16(buf_: &mut [u16], latin1_: &[u8]) -> EncodeIntoRes for (out, &inp) in buf_[..len].iter_mut().zip(latin1_[..len].iter()) { *out = u16::from(inp); } - let len = len as u32; EncodeIntoResult { read: len, written: len, @@ -1010,8 +1009,8 @@ fn copy_utf16_into_utf8_with_buffer_impl{}\": {} (execve)", + bstr::BStr::new(exec_path.as_bytes()), + crate::ErrnoNames::SYS + .name(errno) + .unwrap_or("unknown error"), + ); if may_return { - crate::output::pretty_errorln(format_args!( - "error: Failed to reload process: errno {}", - errno - )); return; } - panic!("Unexpected error while reloading: errno {}", errno); + crate::Global::exit(1); } #[cfg(not(any(unix, windows)))] @@ -5039,49 +5054,49 @@ pub mod timespec_mode { /// Mocked-time storage. The data lives at T0 so `Timespec::now` reads it /// directly; the test-runner (`useFakeTimers`) writes via `set`/`clear` -/// from `bun_runtime::test_runner::timers::FakeTimers::CurrentTime`. -/// Sentinel `i64::MIN` / `NaN` ⇒ not mocked. +/// from `bun_runtime::test_runner::timers::FakeTimers`. Thread-local, like +/// each VM's fake timer heap. pub mod mock_time { - use core::sync::atomic::{AtomicI64, AtomicU64, Ordering}; + use core::cell::Cell; - static MOCKED_TIME_NS: AtomicI64 = AtomicI64::new(i64::MIN); - // Mocked wall-clock `Date.now()` in ms, stored as f64 bits; NaN = unset. - static MOCKED_WALL_MS: AtomicU64 = AtomicU64::new(f64::NAN.to_bits()); + thread_local! { + static MOCKED_TIME_NS: Cell> = const { Cell::new(None) }; + /// Mocked wall-clock `Date.now()` in ms. + static MOCKED_WALL_MS: Cell> = const { Cell::new(None) }; + } /// Set the mocked monotonic time (nanoseconds). Called by fake-timers. #[inline] pub fn set(ns: i64) { - MOCKED_TIME_NS.store(ns, Ordering::Relaxed); + MOCKED_TIME_NS.set(Some(ns)); } /// Clear the mocked time so `Timespec::now(AllowMockedTime)` reads the /// real clock again. #[inline] pub fn clear() { - MOCKED_TIME_NS.store(i64::MIN, Ordering::Relaxed); + MOCKED_TIME_NS.set(None); } /// Current mocked time, or `None` if not mocked. #[inline] pub fn get() -> Option { - let v = MOCKED_TIME_NS.load(Ordering::Relaxed); - if v == i64::MIN { None } else { Some(v) } + MOCKED_TIME_NS.get() } /// Set the mocked wall-clock time (`Date.now()` in ms). Called by /// fake-timers alongside `set` so calendar-based consumers and the /// monotonic timer heap move through the same mock. #[inline] pub fn set_wall_ms(ms: f64) { - MOCKED_WALL_MS.store(ms.to_bits(), Ordering::Relaxed); + MOCKED_WALL_MS.set(Some(ms)); } /// Clear the mocked wall-clock time. #[inline] pub fn clear_wall() { - MOCKED_WALL_MS.store(f64::NAN.to_bits(), Ordering::Relaxed); + MOCKED_WALL_MS.set(None); } /// Current mocked wall-clock time in ms, or `None` if not mocked. #[inline] pub(crate) fn wall_ms() -> Option { - let v = f64::from_bits(MOCKED_WALL_MS.load(Ordering::Relaxed)); - if v.is_nan() { None } else { Some(v) } + MOCKED_WALL_MS.get() } } diff --git a/src/bundler/BundleThread.rs b/src/bundler/BundleThread.rs index 0b460e28d114..2880ba38a6e1 100644 --- a/src/bundler/BundleThread.rs +++ b/src/bundler/BundleThread.rs @@ -45,6 +45,8 @@ pub enum BundleV2Result { // provides the `CompletionStruct` impl for the forward-decl. pub(crate) struct BundleThread { pub(crate) waker: Async::Waker, + /// Set by the bundle thread before it signals `ready_event` if creating `waker` fails. + pub(crate) waker_error: Option, pub(crate) ready_event: ResetEvent, // `bun.UnboundedQueue(CompletionStruct, .next)` — intrusive over `C.next`; // the field offset is encoded via the `Node` supertrait on `CompletionStruct`. @@ -127,6 +129,7 @@ impl BundleThread { pub(crate) fn uninitialized() -> Self { Self { waker: Async::Waker::placeholder(), + waker_error: None, queue: UnboundedQueue::new(), generation: 0, ready_event: ResetEvent::default(), @@ -135,12 +138,11 @@ impl BundleThread { /// # Safety /// `instance` must be valid for `'static` (the spawned thread runs forever and - /// accesses it). After this returns the bundle thread concurrently accesses + /// accesses it). On `Ok`, the bundle thread concurrently accesses /// `*instance`; callers must only touch it via the raw-pointer methods on this - /// impl (e.g. `enqueue`) and never materialize a `&mut Self`. - pub(crate) unsafe fn spawn( - instance: *mut Self, - ) -> std::io::Result> { + /// impl (e.g. `enqueue`) and never materialize a `&mut Self`. On `Err`, no + /// thread references `*instance` and the caller owns it again. + pub(crate) unsafe fn spawn(instance: *mut Self) -> std::io::Result<()> { // `std::thread::Builder` (not `std::thread::spawn`) so the spawn error // is surfaced to the caller. struct SendPtr(*mut T); @@ -160,9 +162,20 @@ impl BundleThread { })?; // SAFETY: field projection via raw ptr — the spawned thread is concurrently // writing `waker`, so we must not hold `&Self`/`&mut Self` here. `ready_event` - // itself is a sync primitive safe to wait on from this thread. - unsafe { (*instance).ready_event.wait() }; - Ok(thread) + // itself is a sync primitive safe to wait on from this thread, and it + // publishes `waker_error`. + let waker_error = unsafe { + (*instance).ready_event.wait(); + (*instance).waker_error + }; + if let Some(err) = waker_error { + // The caller frees `*instance`, so join the thread first. + let _ = thread.join(); + return Err(std::io::Error::other(err)); + } + // `std.Thread.detach()` — drop the JoinHandle without joining. + drop(thread); + Ok(()) } /// # Safety @@ -187,12 +200,23 @@ impl BundleThread { unsafe fn thread_main(instance: *mut Self) { Output::Source::configure_named_thread(zstr!("Bundler")); + // On Windows the waker is this thread's libuv loop, so create it here, not in `spawn()`. + let waker = match Async::Waker::init() { + Ok(waker) => waker, + Err(err) => { + // SAFETY: `spawn()` is blocked in `ready_event.wait()` and joins this + // thread before `*instance` is freed. + unsafe { + core::ptr::addr_of_mut!((*instance).waker_error).write(Some(err)); + (*instance).ready_event.set(); + } + return; + } + }; + // SAFETY: `waker` is written exactly once here, before `ready_event.set()` // releases any thread that could call `enqueue` (which reads `waker`). - unsafe { - core::ptr::addr_of_mut!((*instance).waker) - .write(Async::Waker::init().unwrap_or_else(|_| panic!("Failed to create waker"))); - } + unsafe { core::ptr::addr_of_mut!((*instance).waker).write(waker) }; // Unblock the calling thread so it can continue. // SAFETY: raw-ptr field projection; spawning thread is blocked in `ready_event.wait()`. @@ -353,8 +377,8 @@ impl BundleThread { pub mod singleton { use super::*; - /// `Send + Sync` newtype around the leaked `BundleThread` allocation so it - /// can sit inside a `OnceLock`. Type-erased because Rust forbids generic + /// `Send` newtype around the leaked `BundleThread` allocation so it can be + /// stored in a `Guarded`. Type-erased because Rust forbids generic /// statics; see module comment. Stored as a raw pointer (not `&'static`) /// because the bundle thread mutates `*self` concurrently — callers must /// only ever project fields via raw-pointer access. @@ -363,47 +387,37 @@ pub mod singleton { // `'static`; cross-thread access is mediated entirely through // `UnboundedQueue` / `ResetEvent` atomics inside `BundleThread::enqueue`. unsafe impl Send for Instance {} - // SAFETY: `&Instance` only exposes the raw pointer; every dereference path - // goes through `BundleThread::enqueue`'s atomic queue/waker primitives, so - // sharing the pointer across threads is sound. - unsafe impl Sync for Instance {} - - static INSTANCE: std::sync::OnceLock = std::sync::OnceLock::new(); - // Blocks the calling thread until the bun build thread is created. - // OnceLock also blocks other callers of this function until the first caller is done. - fn load_once_impl() -> Instance { - let bundle_thread = bun_core::heap::into_raw(Box::new(BundleThread::::uninitialized())); + static INSTANCE: bun_threading::Guarded> = bun_threading::Guarded::new(None); - // 2. Spawn the bun build thread. - // SAFETY: bundle_thread is a leaked Box, valid for 'static; `spawn` takes the - // raw pointer directly so no `&mut` is materialized that would alias the - // bundle thread's own access. - let os_thread = unsafe { BundleThread::spawn(bundle_thread) } - .unwrap_or_else(|_| Output::panic(format_args!("Failed to spawn bun build thread"))); - // `std.Thread.detach()` — drop the JoinHandle without joining. - drop(os_thread); - - // SAFETY: `into_raw` of a `Box` is never null. - Instance(unsafe { NonNull::new_unchecked(bundle_thread.cast::<()>()) }) - } - - /// Returns the raw singleton pointer. The bundle thread runs `thread_main` - /// against this allocation for the process lifetime, so callers MUST NOT - /// materialize `&mut BundleThread` from it. + /// Returns the raw singleton pointer and starts the bundle thread on first + /// use. If the thread fails to start, the next build tries again. + /// The bundle thread runs `thread_main` against this allocation for the + /// process lifetime, so callers MUST NOT materialize `&mut BundleThread` from it. /// Use `BundleThread::enqueue(get(), ...)` instead. /// /// # Safety /// All calls (across the process) must use the same `C`; the static is /// type-erased. - pub(crate) fn get() -> *mut BundleThread { - // INSTANCE is a leaked 'static Box of `BundleThread` (same `C` per - // the safety contract). - INSTANCE - .get_or_init(load_once_impl::) - .0 - .as_ptr() - .cast::>() + pub(crate) fn get() -> std::io::Result<*mut BundleThread> { + // The lock also blocks other callers until the thread is created. + let mut instance = INSTANCE.lock(); + if let Some(instance) = &*instance { + return Ok(instance.0.as_ptr().cast::>()); + } + + let bundle_thread = + bun_core::heap::into_raw_nn(Box::new(BundleThread::::uninitialized())); + // SAFETY: bundle_thread is a leaked Box, valid for 'static; `spawn` takes the + // raw pointer directly so no `&mut` is materialized that would alias the + // bundle thread's own access. + if let Err(err) = unsafe { BundleThread::spawn(bundle_thread.as_ptr()) } { + // SAFETY: no thread refers to the allocation after `Err`. + unsafe { bun_core::heap::destroy(bundle_thread.as_ptr()) }; + return Err(err); + } + *instance = Some(Instance(bundle_thread.cast::<()>())); + Ok(bundle_thread.as_ptr()) } pub fn enqueue(completion: *mut C) { @@ -412,8 +426,29 @@ pub mod singleton { let completion = NonNull::new(completion).unwrap_or_else(|| { Output::panic(format_args!("BundleThread enqueue: null completion")) }); - // SAFETY: `get()` returns the leaked 'static singleton whose bundle thread is - // running; `BundleThread::enqueue` only performs raw-ptr field projections. - unsafe { BundleThread::enqueue(get::(), completion.as_ptr()) }; + let err = match get::() { + Ok(instance) => { + // SAFETY: `get()` returns the leaked 'static singleton whose bundle thread is + // running; `BundleThread::enqueue` only performs raw-ptr field projections. + unsafe { BundleThread::enqueue(instance, completion.as_ptr()) }; + return; + } + Err(err) => err, + }; + + // SAFETY: the task is live and never reached the queue, so nothing else uses it. + let completion = unsafe { &mut *completion.as_ptr() }; + // Mark the build as started so VM teardown lets the posted completion free it. + let started = completion.try_start(); + debug_assert!(started); + let mut log = bun_ast::Log::init(); + log.add_error_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!("Failed to start the bundler thread: {err}"), + ); + completion.set_log(log); + completion.set_result(BundleV2Result::Err(crate::Error::BuildFailed)); + completion.complete_on_bundle_thread(); } } diff --git a/src/bundler/Chunk.rs b/src/bundler/Chunk.rs index 5d73ed9f2ce9..bcc3c4f6f5b6 100644 --- a/src/bundler/Chunk.rs +++ b/src/bundler/Chunk.rs @@ -685,8 +685,7 @@ impl IntermediateOutput { let additional_files = graph.input_files.items_additional_files(); let unique_key_for_additional_files = graph.input_files.items_unique_key_for_additional_file(); - let mut relative_platform_buf = bun_paths::path_buffer_pool::get(); - let mut file_path_buf = bun_paths::path_buffer_pool::get(); + let mut file_path_posix: Vec = Vec::new(); match self { IntermediateOutput::Pieces(pieces) => { let entry_point_chunks_for_scb = linker_graph.files.items_entry_point_chunk_index(); @@ -802,17 +801,24 @@ impl IntermediateOutput { QueryKind::None | QueryKind::ChunkId => unreachable!(), }; + // Normalize like the write pass below so the counted length matches. + let file_path: &[u8] = { + file_path_posix.clear(); + file_path_posix.extend_from_slice(file_path); + bun_paths::resolve_path::platform_to_posix_in_place::( + &mut file_path_posix, + ); + &file_path_posix + }; let cheap_normalizer = cheap_prefix_normalizer( import_prefix, if use_outdir_relative_path { file_path } else { - bun_paths::resolve_path::relative_platform_buf::< + bun_paths::resolve_path::relative_platform::< bun_paths::platform::Posix, false, - >( - &mut relative_platform_buf[..], from_chunk_dir, file_path - ) + >(from_chunk_dir, file_path) }, ); count += cheap_normalizer[0].len() + cheap_normalizer[1].len(); @@ -988,29 +994,24 @@ impl IntermediateOutput { _ => unreachable!(), }; - // normalize windows paths to '/' - // The source slices are reachable only - // through `&Graph` / `&[Chunk]` here; materialising `&mut` from a - // shared-provenance pointer is UB regardless of whether the write - // happens. Copy into a pooled scratch buffer and normalise that. + // Normalize Windows paths to '/' in a copy because the source is borrowed immutably. let file_path: &[u8] = { - let n = file_path.len(); - let dst = &mut file_path_buf[..n]; - dst.copy_from_slice(file_path); - bun_paths::resolve_path::platform_to_posix_in_place::(dst); - dst + file_path_posix.clear(); + file_path_posix.extend_from_slice(file_path); + bun_paths::resolve_path::platform_to_posix_in_place::( + &mut file_path_posix, + ); + &file_path_posix }; let cheap_normalizer = cheap_prefix_normalizer( import_prefix, if use_outdir_relative_path { file_path } else { - bun_paths::resolve_path::relative_platform_buf::< + bun_paths::resolve_path::relative_platform::< bun_paths::platform::Posix, false, - >( - &mut relative_platform_buf[..], from_chunk_dir, file_path - ) + >(from_chunk_dir, file_path) }, ); diff --git a/src/bundler/HTMLImportManifest.rs b/src/bundler/HTMLImportManifest.rs index 09824136af24..a6bff9b4c5db 100644 --- a/src/bundler/HTMLImportManifest.rs +++ b/src/bundler/HTMLImportManifest.rs @@ -191,7 +191,7 @@ pub(crate) fn write( &options.root_dir[..] } else { // SAFETY: FileSystem singleton is initialized before bundling. - FileSystem::get().top_level_dir + FileSystem::get().top_level_dir() }; writer.write_all(b"{")?; diff --git a/src/bundler/HTMLScanner.rs b/src/bundler/HTMLScanner.rs index 41a76ebc2858..901ee553d0df 100644 --- a/src/bundler/HTMLScanner.rs +++ b/src/bundler/HTMLScanner.rs @@ -73,7 +73,7 @@ impl<'a> HTMLScanner<'a> { // In that case, we don't want to use the absolute filesystem path, we want to use the path relative to the project root let path_to_use: &[u8] = if input_path.len() > 1 && input_path[0] == b'/' { resolve_path::join_abs_string::( - fs::FileSystem::instance().top_level_dir, + fs::FileSystem::instance().top_level_dir(), &[&input_path[1..]], ) } @@ -377,9 +377,10 @@ impl let settings = lol_html::Settings { element_content_handlers, encoding: lol_html::AsciiCompatibleEncoding::utf_8(), + // lol_html only buffers the unparsed tail of each write, so the default preallocation is enough. memory_settings: lol_html::MemorySettings { - preallocated_parsing_buffer_size: (input.len() / 4).max(1024), - max_allowed_memory_usage: 1024 * 1024 * 10, + max_allowed_memory_usage: u32::MAX as usize, + ..lol_html::MemorySettings::new() }, strict: false, ..lol_html::Settings::new() diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index bbf0c81e36df..ea8170943457 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -27,6 +27,7 @@ use bun_ast::{ use crate::Index; use bun_ast::{E, G, S}; use bun_js_parser::lexer as lex; +use bun_js_parser::parse::parse_entry::{ModuleSyntax, parse_only_text}; use bun_js_printer::{self as js_printer, renamer}; use crate::bun_node_fallbacks as NodeFallbackModules; @@ -496,7 +497,7 @@ impl<'a> LinkerContext<'a> { path: &bun_paths::fs::Path<'static>, arena: &Bump, ) -> Result, BunError> { - let top_level_dir = bun_resolver::fs::FileSystem::get().top_level_dir; + let top_level_dir = bun_resolver::fs::FileSystem::get().top_level_dir(); generic_path_with_pretty_initialized(path, self.options.target, top_level_dir, arena) } @@ -1470,6 +1471,22 @@ impl LinkerOptions { && self.output_format == Format::Esm && self.compile_mode.is_executable() } + + /// Returns the module-record flags for `--banner` and `--footer`, which the printer never sees. + pub(crate) fn raw_text_module_syntax(&self) -> ModuleSyntax { + let mut syntax = ModuleSyntax::default(); + for text in [self.banner, self.footer] { + if text.is_empty() { + continue; + } + if let Some(piece) = parse_only_text(text, true, false, |parsed| parsed.module_syntax()) + { + syntax.import_meta |= piece.import_meta; + syntax.top_level_await |= piece.top_level_await; + } + } + syntax + } } impl Default for LinkerOptions { @@ -3919,7 +3936,11 @@ impl<'a> LinkerContext<'a> { // // This uses a O(n^2) array scan instead of a O(n) map because the vast // majority of cases have one or two elements - for prev_tracker in &self.cycle_detector[cycle_detector_top..] { + // + // Scan the whole stack, not just this call's entries. The entries below + // `cycle_detector_top` are from callers tracing this chain as an + // "export * from" alternative. + for prev_tracker in &self.cycle_detector { if import_tracker_eq(&tracker, prev_tracker) { result = MatchImport { kind: MatchImportKind::Cycle, diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index f14ea9f8c350..1c20cf221d97 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -91,6 +91,8 @@ pub struct ParseTask { pub(crate) path: Fs::Path<'static>, pub(crate) secondary_path_for_commonjs_interop: Option>, pub(crate) contents_or_fd: ContentsOrFd, + /// True if this task opened `contents_or_fd`'s file. False if the resolver's entry cache owns it. + pub(crate) opened_own_fd: bool, pub(crate) external_free_function: ExternalFreeFunction, pub(crate) side_effects: bun_ast::SideEffects, pub(crate) loader: Option, @@ -178,6 +180,8 @@ impl ResultValue { pub(crate) struct WatcherData { pub(crate) fd: Fd, pub(crate) dir_fd: Fd, + /// True if the receiver must close `fd` when the watcher doesn't take it. + pub(crate) owns_fd: bool, } impl WatcherData { @@ -185,6 +189,7 @@ impl WatcherData { pub(crate) const NONE: WatcherData = WatcherData { fd: Fd::INVALID, dir_fd: Fd::INVALID, + owns_fd: false, }; } @@ -295,6 +300,7 @@ impl ParseTask { known_target, // defaults: secondary_path_for_commonjs_interop: None, + opened_own_fd: false, external_free_function: ExternalFreeFunction::NONE, loader: None, task: ThreadPoolLib::Task { @@ -325,6 +331,7 @@ impl Default for ParseTask { path: Fs::Path::init(b""), secondary_path_for_commonjs_interop: None, contents_or_fd: ContentsOrFd::Contents(b""), + opened_own_fd: false, external_free_function: ExternalFreeFunction::NONE, side_effects: bun_ast::SideEffects::HasSideEffects, loader: None, @@ -376,7 +383,13 @@ unsafe fn io_task_callback(task: *mut ThreadPoolLib::Task) { // provenance covers the full `ParseTask` and the `&mut` is unique per the // CONCURRENCY note above. let parse_task = unsafe { &mut *bun_core::from_field_ptr!(ParseTask, io_task, task) }; + // Read the pool first. `run_from_thread_pool` can reschedule `*parse_task` onto a worker + // thread, which then owns it. + // SAFETY: teardown waits for this callback (`wait_for_io_tasks`), so the bundle is live. + let pool: *const crate::ThreadPool = unsafe { parse_task.ctx() }.graph.pool(); parse_worker::run_from_thread_pool(parse_task); + // SAFETY: `pool` scheduled this task, and this is the last access to the bundle. + unsafe { crate::ThreadPool::finish_io_task(pool) }; } // CONCURRENCY: see `io_task_callback` — same task, different intrusive field. @@ -609,6 +622,7 @@ pub mod parse_worker { known_target: target, // defaults: secondary_path_for_commonjs_interop: None, + opened_own_fd: false, external_free_function: ExternalFreeFunction::NONE, task: ThreadPoolLib::Task { node: ThreadPoolLib::Node::default(), @@ -2380,6 +2394,7 @@ pub mod parse_worker { file: entry.fd, dir: Fd::INVALID, }; + task.opened_own_fd = opened_own_fd && entry.fd.stdio_tag().is_none(); } *step = Step::Parse; @@ -2462,12 +2477,8 @@ pub mod parse_worker { if task.known_target == options::Target::ServerComponentsSsr && topts .framework - .as_ref() - .unwrap() - .server_components - .as_ref() - .unwrap() - .separate_ssr_graph + .and_then(|framework| framework.server_components.as_ref()) + .is_some_and(|sc| sc.separate_ssr_graph) { options::Target::ServerComponentsSsr } else { @@ -2618,7 +2629,6 @@ pub mod parse_worker { opts.framework = topts.framework.map(|f| { // `Framework` is bump-allocated below, so `Drop` never runs — use arena-owned slices. let projected = js_parser::options::Framework { - is_built_in_react: f.is_built_in_react, server_components: f.server_components.as_ref().map(|sc| { js_parser::options::FrameworkServerComponents { separate_ssr_graph: sc.separate_ssr_graph, @@ -2908,6 +2918,7 @@ pub mod parse_worker { ContentsOrFd::Fd { file, dir } => WatcherData { fd: file, dir_fd: dir, + owns_fd: this.opened_own_fd, }, ContentsOrFd::Contents(_) => WatcherData::NONE, }, diff --git a/src/bundler/ThreadPool.rs b/src/bundler/ThreadPool.rs index 0b8bdb3555c9..b1240fb3299d 100644 --- a/src/bundler/ThreadPool.rs +++ b/src/bundler/ThreadPool.rs @@ -15,7 +15,7 @@ use bun_alloc::Arena as ThreadLocalArena; use bun_collections::{ArrayHashMap, MapEntry}; use bun_core::{self, output as Output}; use bun_sys::Fd; -use bun_threading::{io_thread_pool, thread_pool as ThreadPoolLib}; +use bun_threading::{WaitGroup, io_thread_pool, thread_pool as ThreadPoolLib}; use crate::cache::{Contents, Entry as CacheEntry}; use crate::linker_context_mod::StmtList; @@ -49,6 +49,10 @@ pub struct ThreadPool { // `wake_for_idle_events`) take `&self` — so the safe `Deref` projection is // sufficient and the per-read `unsafe { p.as_ref() }` disappears. pub(crate) io_pool: Option>, + /// Tracks the `io_pool` tasks whose callbacks haven't returned. A callback's + /// last step schedules a parse on `worker_pool`, and that parse can finish + /// before the callback returns. + io_tasks_in_flight: WaitGroup, // Conditionally owned via `worker_pool_is_owned`. pub worker_pool: *mut ThreadPoolLib::ThreadPool, pub(crate) worker_pool_is_owned: bool, @@ -128,6 +132,7 @@ impl ThreadPool { } else { None }, + io_tasks_in_flight: WaitGroup::init(), // BACKREF: lifetime erased behind the raw pointer. v2: std::ptr::from_ref(v2).cast::>(), worker_pool_is_owned: false, @@ -136,9 +141,24 @@ impl ThreadPool { } } + /// Blocks until every IO callback has returned. + pub(crate) fn wait_for_io_tasks(&self) { + self.io_tasks_in_flight.wait(); + } + + /// Marks one IO task as finished. `io_task_callback` calls this last. + /// + /// # Safety + /// `this` must have scheduled the task. `this` may be freed as soon as this + /// call unblocks [`Self::wait_for_io_tasks`]. + pub(crate) unsafe fn finish_io_task(this: *const Self) { + // SAFETY: caller contract. + unsafe { WaitGroup::finish_raw(&raw const (*this).io_tasks_in_flight) }; + } + /// Explicit teardown (no Drop on /// `ThreadPool` because `Graph.pool` is `NonNull` and the arena - /// owns the storage). + /// owns the storage). Call [`Self::wait_for_io_tasks`] first. pub(crate) fn deinit(&mut self) { if self.worker_pool_is_owned { // SAFETY: worker_pool was heap-allocated in `init()` when owned. @@ -257,6 +277,7 @@ impl ThreadPool { ParseTaskStage::NeedsSourceCode => { // io_pool is Some when uses_io_pool(). let io = self.io_pool_ref().unwrap(); + self.io_tasks_in_flight.add_one(); schedule_fn( io, ThreadPoolLib::Batch::from(&raw mut (*parse_task).io_task), diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index db84c84224ae..eb72b4d389c5 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -471,9 +471,6 @@ pub mod bv2_impl { /// (`js_parser/ast/Parser.rs:1997` resolves `framework.react_fast_refresh /// .import_source`) when `features.react_fast_refresh` is on. pub(crate) react_fast_refresh: Option, - /// Mirrors `Framework.is_built_in_react` — read by - /// `linker_context::generateChunksInParallel` to gate `BakeExtra`. - pub(crate) is_built_in_react: bool, /// The routers with `clientPages`. In the browser build, the files under them lose /// their router's `serverExports`, and the imports that only those use. pub(crate) client_page_dirs: Vec, @@ -492,14 +489,12 @@ pub mod bv2_impl { built_in_modules: bun_collections::StringArrayHashMap, server_components: Option, react_fast_refresh: Option, - is_built_in_react: bool, client_page_dirs: Vec, ) -> Self { Self { built_in_modules, server_components, react_fast_refresh, - is_built_in_react, client_page_dirs, } } @@ -620,8 +615,7 @@ pub mod bv2_impl { /// `EntryPointMap`. /// Lives in the bundler (lower tier) so both `bun_runtime::bake::production` /// and `BundleV2::generate_from_bake_production_cli` share ONE nominal type - /// (PORTING.md §Layering). Router-integration methods (`InsertionHandler`) - /// are added by `bun_runtime::bake` via a local trait impl. + /// (PORTING.md §Layering). pub mod production { use super::Side; @@ -785,6 +779,8 @@ pub mod bv2_impl { safe fn JSBundlerPlugin__drainDeferred(this: &mut Plugin, rejected: bool); #[link_name = "JSBundlerPlugin__hasOnBeforeParsePlugins"] safe fn JSBundlerPlugin__hasOnBeforeParsePlugins(this: &Plugin) -> i32; + #[link_name = "JSBundlerPlugin__freezeFilters"] + safe fn JSBundlerPlugin__freezeFilters(this: &Plugin); // `ctx`/`args`/`result` are opaque cookies the C++ side round-trips // to Rust-registered native-plugin callbacks without dereferencing // in `JSBundlerPlugin.cpp` itself (same posture as `matchOnLoad` @@ -818,6 +814,11 @@ pub mod bv2_impl { JSBundlerPlugin__hasOnBeforeParsePlugins(self) != 0 } + /// Call on the JS thread before a bundle pass. After this, `addFilter` and `onBeforeParse` throw. + pub fn freeze_filters(&self) { + JSBundlerPlugin__freezeFilters(self) + } + #[inline] pub(crate) fn call_on_before_parse_plugins( &self, @@ -1031,7 +1032,7 @@ pub mod bv2_impl { { b"/" } else { - bun_resolver::fs::FileSystem::instance().top_level_dir + bun_resolver::fs::FileSystem::instance().top_level_dir() } } else { source_dir @@ -2591,7 +2592,7 @@ pub mod bv2_impl { }) .map(|((source, loader), _)| (source.path.text, source.contents(), *loader)); let checked = options::TypeChecked { - cwd: self.transpiler.fs().top_level_dir, + cwd: self.transpiler.fs().top_level_dir(), tsconfig: self.transpiler.options.tsconfig_override.as_deref(), conditions: &self.transpiler.options.custom_conditions, loaders: &self.transpiler.options.loaders, @@ -2947,7 +2948,8 @@ pub mod bv2_impl { bun_paths::resolve_path::platform::Loose, false, >( - bun_resolver::fs::FileSystem::get().top_level_dir, path.text + bun_resolver::fs::FileSystem::get().top_level_dir(), + path.text, ); // SAFETY: arena outlives the bundle pass; raw-pointer detour erases the // `&self` lifetime so the resulting `&'static [u8]` doesn't pin `self`. @@ -3330,6 +3332,9 @@ pub mod bv2_impl { this.framework = Some(bo.framework); this.linker.framework = this.framework.as_ref().map(bun_ptr::BackRef::new); this.plugins = bo.plugins; + if let Some(plugins) = this.plugins_ref() { + plugins.freeze_filters(); + } if this.transpiler.options.server_components { debug_assert!( this.client_transpiler_ref() @@ -4756,6 +4761,10 @@ pub mod bv2_impl { ) }; let mut additional_output_files: Vec = Vec::new(); + let mut has_unusable_output_path = false; + // Without an output directory the paths are only returned, so they can have any length. + let opens_output_paths = !self.transpiler.resolver.opts.output_dir.is_empty() + || self.transpiler.options.compile_mode.is_executable(); let mut templates: Vec<(usize, options::PathTemplate)> = Vec::new(); for reachable_source in reachable_files { @@ -4861,6 +4870,17 @@ pub mod bv2_impl { v.into_boxed_slice() }; + if opens_output_paths + && !template.check_output_path( + self.transpiler.log_mut(), + source.path.pretty, + &output_path, + ) + { + has_unusable_output_path = true; + continue; + } + // Hand the existing `source.contents` buffer to the // OutputFile — no copy: move the contents // out instead of `to_vec()`-cloning, @@ -4899,6 +4919,9 @@ pub mod bv2_impl { } self.graph.additional_output_files = additional_output_files; + if has_unusable_output_path { + return Err(crate::Error::BuildFailed); + } } Ok(()) } @@ -5010,7 +5033,7 @@ pub mod bv2_impl { } } } - let _load_deinit = LoadDeinitGuard(std::ptr::from_mut(load)); + let load_deinit = LoadDeinitGuard(std::ptr::from_mut(load)); bun_core::scoped_log!( Bundle, "onLoad: ({}, {:?})", @@ -5049,6 +5072,8 @@ pub mod bv2_impl { ), ); + // Drop first. The decrement below can finish a dev server bundle, which frees `load`'s arena. + drop(load_deinit); // An error occurred, prevent spinning the event loop forever this.decrement_scan_counter(); } @@ -5165,6 +5190,8 @@ pub mod bv2_impl { log.warnings += (kind == bun_ast::Kind::Warn) as u32; } + // Drop first, as above. + drop(load_deinit); // An error occurred, prevent spinning the event loop forever this.decrement_scan_counter(); } @@ -5544,6 +5571,8 @@ pub mod bv2_impl { // creating `Worker`s and reading `graph`. self.linker.source_maps.line_offset_wait_group.wait(); self.linker.source_maps.quoted_contents_wait_group.wait(); + // The last IO callback may still be using the worker pool that `pool.deinit()` frees. + self.graph.pool().wait_for_io_tasks(); { // We do this before the rest to make it harder for any dangling pointers to data to be used in there. @@ -5800,19 +5829,26 @@ pub mod bv2_impl { // Open the output directory and write the metafile relative to it, // routed through `bun_sys::File`. let mut buf = bun_paths::path_buffer_pool::get(); - let joined = bun_paths::resolve_path::join_string_buf::< + let written = match bun_paths::resolve_path::join_z_buf_checked::< bun_paths::resolve_path::platform::Auto, - >(&mut buf.0[..], &[outdir, file_path]); - // Create parent directories if needed (relative to outdir). - let parent = bun_paths::resolve_path::dirname::( - joined, - ); - if !parent.is_empty() { - let _ = bun_sys::mkdir_recursive(parent); - } - let mut zbuf = bun_paths::path_buffer_pool::get(); - let joined_z = bun_paths::resolve_path::z(joined, &mut zbuf); - match bun_sys::File::write_file(bun_core::Fd::cwd(), joined_z, content) { + >(&mut buf.0[..], &[outdir, file_path]) + { + Some(joined_z) => { + // Create parent directories if needed (relative to outdir). + let parent = bun_paths::resolve_path::dirname::< + bun_paths::resolve_path::platform::Loose, + >(joined_z.as_bytes()); + if !parent.is_empty() { + let _ = bun_sys::mkdir_recursive(parent); + } + bun_sys::File::write_file(bun_core::Fd::cwd(), joined_z, content) + } + None => Err(bun_sys::Error::from_code( + bun_sys::E::ENAMETOOLONG, + bun_sys::Tag::open, + )), + }; + match written { Ok(()) => {} Err(err) => { bun_core::warn!( @@ -6162,8 +6198,32 @@ pub mod bv2_impl { }); // Then all the distinct CSS bundles (these are JS->CSS, not CSS->CSS) + let mut css_orders = Vec::with_capacity(start.css_entry_points.count()); + let mut css_too_deep = Vec::new(); for entry_point in start.css_entry_points.keys() { - let order = crate::linker_context::find_imported_files_in_css_order::find_imported_files_in_css_order(&mut self.linker, self.graph.heap, &[*entry_point]); + match crate::linker_context::find_imported_files_in_css_order::find_imported_files_in_css_order(&mut self.linker, self.graph.heap, &[*entry_point]) { + Ok(order) => css_orders.push((*entry_point, order)), + Err(too_deep) => css_too_deep.push((*entry_point, too_deep)), + } + } + // Treat an `@import` chain that's too deep like an invalid import. The + // dev server reports the error for that file and skips its chunk. + for (entry_point, too_deep) in css_too_deep { + let sources = self.graph.input_files.items_source(); + let mut log = bun_ast::Log::init(); + too_deep.add_to_log(sources, &mut log); + dev_server + .handle_parse_task_failure( + crate::Error::InvalidCssImport, + bake::Graph::Client, + sources[entry_point.get() as usize].path.text, + &raw const log, + self, + ) + .map_err(|_| AllocError)?; + let _ = start.css_entry_points.ordered_remove(&entry_point); + } + for (entry_point, order) in css_orders { let order_len = order.len() as usize; chunks.push(Chunk { entry_point: chunk::EntryPoint::non_entry_point( @@ -6400,7 +6460,7 @@ pub mod bv2_impl { let out = generic_path_with_pretty_initialized( path, target, - self.transpiler.fs().top_level_dir, + self.transpiler.fs().top_level_dir(), bump, )?; Ok(out) @@ -6773,13 +6833,14 @@ pub mod bv2_impl { // backrefs valid for `'a` (see `init`). Compute the raw ptr first, then // deref once, so the `&mut self` borrow doesn't span the rest of the loop // body. + // `bake_graph` is the imported file's graph. Resolution failures are logged under the importer's graph. let (transpiler_ptr, bake_graph, target): ( *mut Transpiler<'a>, bake::Graph, options::Target, ) = if import_record.tag == bun_ast::ImportRecordTag::BakeResolveToSsrGraph { if self.framework.is_none() { - self.log_for_resolution_failures(source.path.text, bake::Graph::Ssr).add_error_fmt( + self.log_for_resolution_failures(source.path.text, ctx.target.bake_graph()).add_error_fmt( Some(source), import_record.range.loc, format_args!("The 'bunBakeGraph' import attribute cannot be used outside of a Bun Bake bundle"), @@ -6797,7 +6858,7 @@ pub mod bv2_impl { .unwrap() .separate_ssr_graph; if !is_supported { - self.log_for_resolution_failures(source.path.text, bake::Graph::Ssr).add_error_fmt( + self.log_for_resolution_failures(source.path.text, ctx.target.bake_graph()).add_error_fmt( Some(source), import_record.range.loc, format_args!("Framework does not have a separate SSR graph to put this import into"), @@ -6902,7 +6963,10 @@ pub mod bv2_impl { // SAFETY: log lives in DevServer/transpiler, disjoint from `self.graph`. let log: &mut bun_ast::Log = unsafe { &mut *std::ptr::from_mut::( - self.log_for_resolution_failures(source.path.text, bake_graph), + self.log_for_resolution_failures( + source.path.text, + ctx.target.bake_graph(), + ), ) }; @@ -7018,18 +7082,22 @@ pub mod bv2_impl { } else { #[cfg(windows)] let mut buf = bun_paths::path_buffer_pool::get(); + let top_level_dir = Fs::FileSystem::get().top_level_dir(); let specifier_to_use: &[u8] = if loader == Loader::Html - && import_record.path.text.starts_with( - Fs::FileSystem::instance().top_level_dir, - ) { - let specifier_to_use = &import_record.path.text - [Fs::FileSystem::instance().top_level_dir.len()..]; + && import_record.path.text.starts_with(top_level_dir) + { + let specifier_to_use = + &import_record.path.text[top_level_dir.len()..]; #[cfg(windows)] { - &*bun_paths::resolve_path::path_to_posix_buf::( - specifier_to_use, - &mut *buf, - ) + if specifier_to_use.len() <= buf.len() { + &*bun_paths::resolve_path::path_to_posix_buf::( + specifier_to_use, + &mut *buf, + ) + } else { + specifier_to_use + } } #[cfg(not(windows))] { @@ -7122,8 +7190,10 @@ pub mod bv2_impl { // blocks an assertion failure because the DevServer // reserves the HTML file's spot in IncrementalGraph for the // route definition. - let log = - self.log_for_resolution_failures(source.path.text, bake_graph); + let log = self.log_for_resolution_failures( + source.path.text, + ctx.target.bake_graph(), + ); log.add_range_error_fmt( Some(source), import_record.range, @@ -7144,15 +7214,15 @@ pub mod bv2_impl { bun_paths::resolve_path::platform::Loose, false, >( - self.transpiler.fs().top_level_dir, path.text + self.transpiler.fs().top_level_dir(), path.text ); - if loader == Loader::Html && entry.kind == bake_types::CacheKind::Asset + // A cached JSON or text module is also an `Asset`, but it has no asset URL. + if loader == Loader::Html + && entry.kind == bake_types::CacheKind::Asset + && let Some(hash) = dev_server.asset_hash(path.text) { // Overload `path.text` to point to the final URL // This information cannot be queried while printing because a lock wouldn't get held. - let hash = dev_server - .asset_hash(path.text) - .expect("cached asset not found"); import_record.path.text = path.text; import_record.path.namespace = b"file"; // SAFETY: `alloc_str` returns into the bundler arena which @@ -7658,15 +7728,16 @@ pub mod bv2_impl { let source_path = this.graph.input_files.items_source()[source_index as usize] .path .text; + let fd = parse_result.watcher_data.fd; + let mut adopted = false; if this.should_add_watcher(source_path) { - let fd = parse_result.watcher_data.fd; let dir_fd = parse_result.watcher_data.dir_fd; let hash = bun_wyhash::hash(source_path) as u32; let bun_watcher = this.bun_watcher_mut().unwrap(); // The watcher keeps the path past this bundle; borrow it // only when it is interned for the process lifetime // (`dupe_alloc` leaves other paths in the bundle arena). - let _ = if Fs::as_interned_path(source_path).is_some() { + let added = if Fs::as_interned_path(source_path).is_some() { bun_watcher.add_file::<{ cfg!(windows) }>( fd, source_path, @@ -7677,6 +7748,11 @@ pub mod bv2_impl { } else { bun_watcher.add_file::(fd, source_path, hash, dir_fd, None) }; + adopted = matches!(added, Ok(bun_watcher::FdOwnership::Watcher)); + } + // A file that's already watched keeps its first descriptor. + if !adopted && parse_result.watcher_data.owns_fd { + let _ = bun_sys::close(fd); } } } @@ -8258,9 +8334,6 @@ pub mod bv2_impl { ) -> crate::Result> { use crate::bun_fs::PathResolverExt as _; use crate::bun_node_fallbacks; - use bun_io::Write as _; - - let mut buf = bun_paths::path_buffer_pool::get(); let is_node = path.namespace == b"node"; if is_node @@ -8270,49 +8343,44 @@ pub mod bv2_impl { return Ok(*path); } - if path.is_file() || is_node { - let mut buf2 = bun_paths::path_buffer_pool::get(); - let rel = bun_paths::resolve_path::relative_platform_buf::< + // `pretty` is only displayed, never opened, so it can be longer than MAX_PATH_BYTES. + let ssr_prefix: &[u8] = if target == options::Target::ServerComponentsSsr { + b"ssr:" + } else { + b"" + }; + let mut prefixed = Vec::new(); + let mut path_clone: crate::bun_fs::Path<'_> = *path; + path_clone.pretty = if path.is_file() || is_node { + let rel = bun_paths::resolve_path::relative_platform::< bun_paths::resolve_path::platform::Loose, false, - >(&mut **buf2, top_level_dir, path.text); - let mut path_clone: crate::bun_fs::Path<'_> = *path; - if target == options::Target::ServerComponentsSsr { - let mut fbs = bun_io::FixedBufferStream::new_mut(&mut buf.0[..]); - let _ = fbs.write_all(b"ssr:"); - let _ = fbs.write_all(rel); - let written = fbs.pos; - path_clone.pretty = &buf.0[..written]; + >(top_level_dir, path.text); + if ssr_prefix.is_empty() { + rel } else { - path_clone.pretty = rel; + prefixed = [ssr_prefix, rel].concat(); + &prefixed } - path_clone.dupe_alloc_fix_pretty(bump).map_err(Into::into) } else { - let mut path_clone: crate::bun_fs::Path<'_> = *path; - let mut fbs = bun_io::FixedBufferStream::new_mut(&mut buf.0[..]); - if target == options::Target::ServerComponentsSsr { - let _ = fbs.write_all(b"ssr:"); - } - let _ = write_escaped_namespace(&mut fbs, path_clone.namespace); - let _ = fbs.write_all(b":"); - let _ = fbs.write_all(path_clone.text); - let written = fbs.pos; - path_clone.pretty = &buf.0[..written]; - path_clone.dupe_alloc_fix_pretty(bump).map_err(Into::into) - } + prefixed.reserve(ssr_prefix.len() + path.namespace.len() + 1 + path.text.len()); + prefixed.extend_from_slice(ssr_prefix); + push_escaped_namespace(&mut prefixed, path.namespace); + prefixed.extend_from_slice(b":"); + prefixed.extend_from_slice(path.text); + &prefixed + }; + path_clone.dupe_alloc_fix_pretty(bump).map_err(Into::into) } - fn write_escaped_namespace( - w: &mut W, - slice: &[u8], - ) -> bun_io::Result { - let mut rest = slice; + fn push_escaped_namespace(out: &mut Vec, namespace: &[u8]) { + let mut rest = namespace; while let Some(i) = strings::index_of_char(rest, b':') { - w.write_all(&rest[..i as usize])?; - w.write_all(b"::")?; + out.extend_from_slice(&rest[..i as usize]); + out.extend_from_slice(b"::"); rest = &rest[i as usize + 1..]; } - w.write_all(rest) + out.extend_from_slice(rest); } #[repr(u8)] diff --git a/src/bundler/defines.rs b/src/bundler/defines.rs index 2eefd5af1d07..02bc948b1d34 100644 --- a/src/bundler/defines.rs +++ b/src/bundler/defines.rs @@ -55,8 +55,9 @@ fn env_string_store_put( // `Expr.Data.Store` — `configureDefines` resets that store on return, so // the env-define payloads must outlive it. Allocate from `bump` (the // transpiler arena) so the slab is bulk-freed with the `Define` table - // instead of leaking a `Box` per env var. Value bytes alias the long-lived - // env-map storage. + // instead of leaking a `Box` per env var. Copy the value because + // `Bun__setEnvValue` frees the env-map entry it points into. + let value: &[u8] = bump.alloc_slice_copy(value); let value: ExprData = ExprData::EString(bun_ast::StoreRef::from_bump( bump.alloc(bun_ast::E::EString::init(value)), )); diff --git a/src/bundler/lib.rs b/src/bundler/lib.rs index d55ed3dde34a..7bb80e2f5695 100644 --- a/src/bundler/lib.rs +++ b/src/bundler/lib.rs @@ -27,6 +27,46 @@ pub mod bun_css { pub use crate::HTMLScanner as html_scanner; +/// Framing for the `.jsc` bytecode sidecar written by `bun build --bytecode`. +/// JSC decodes cached bytecode in place with no bounds or integrity checks, so the +/// payload is followed by `payload_len: u64 le | wyhash(payload): u64 le | MAGIC` +/// and a file that fails verification is ignored (the module is parsed from source). +/// Bytecode embedded in `--compile` executables isn't framed. +pub mod bytecode_sidecar { + pub const EXTENSION: &str = ".jsc"; + const MAGIC: [u8; 8] = *b"\0bun.jsc"; + pub const FOOTER_LEN: usize = 8 + 8 + 8; + + fn footer(payload: &[u8]) -> [u8; FOOTER_LEN] { + let mut out = [0u8; FOOTER_LEN]; + out[0..8].copy_from_slice(&(payload.len() as u64).to_le_bytes()); + out[8..16].copy_from_slice(&bun_wyhash::hash(payload).to_le_bytes()); + out[16..].copy_from_slice(&MAGIC); + out + } + + pub fn frame(payload: Box<[u8]>) -> Box<[u8]> { + let footer = footer(&payload); + let mut bytes = Vec::from(payload); + bytes.reserve_exact(FOOTER_LEN); + bytes.extend_from_slice(&footer); + bytes.into_boxed_slice() + } + + /// Returns the length of the JSC payload at the start of `file`, or `None` if the footer is invalid. + pub fn payload_len(file: &[u8]) -> Option { + let (payload, tail) = file.split_at(file.len().checked_sub(FOOTER_LEN)?); + if payload.is_empty() + || tail[16..] != MAGIC + || tail[0..8] != (payload.len() as u64).to_le_bytes() + || tail[8..16] != bun_wyhash::hash(payload).to_le_bytes() + { + return None; + } + Some(payload.len()) + } +} + pub(crate) mod index { pub(crate) use bun_ast::IndexInt as Int; } diff --git a/src/bundler/linker.rs b/src/bundler/linker.rs index bcb704fad2d1..cb9ad8092f92 100644 --- a/src/bundler/linker.rs +++ b/src/bundler/linker.rs @@ -495,12 +495,12 @@ impl Linker { pub(crate) fn resolve_result_hash_key(&self, resolve_result: &resolver::Result) -> u64 { let path = resolve_result.path_const().expect("unreachable"); - let fs = self.fs(); + let top_level_dir = self.fs().top_level_dir(); let mut hash_key = path.text; // Shorter hash key is faster to hash - if strings::starts_with(path.text, fs.top_level_dir) { - hash_key = &path.text[fs.top_level_dir.len()..]; + if strings::starts_with(path.text, top_level_dir) { + hash_key = &path.text[top_level_dir.len()..]; } bun_wyhash::hash(hash_key) diff --git a/src/bundler/linker_context/StaticRouteVisitor.rs b/src/bundler/linker_context/StaticRouteVisitor.rs index 42452ce9e5ef..68b136f1b6fd 100644 --- a/src/bundler/linker_context/StaticRouteVisitor.rs +++ b/src/bundler/linker_context/StaticRouteVisitor.rs @@ -14,6 +14,13 @@ pub(crate) struct StaticRouteVisitor<'a> { pub(crate) c: &'a LinkerContext<'a>, pub(crate) cache: ArrayHashMap, pub(crate) visited: AutoBitSet, + pub(crate) stack: Vec, +} + +/// A file being traversed and the index of its next import record to check. +pub(crate) struct Frame { + source_index: Index, + next_record: usize, } impl<'a> StaticRouteVisitor<'a> { @@ -53,11 +60,14 @@ impl<'a> StaticRouteVisitor<'a> { } /// 1. Get AST for `source_index` - /// 2. Recursively traverse its imports in import records + /// 2. Traverse its imports in import records, depth-first /// 3. If any of the imports match any item in /// `referenced_source_indices` which has `use_directive == /// .client`, then we know `source_index` is NOT fully /// static. + /// + /// The traversal uses an explicit stack. Once a file reaches a client + /// boundary, so does every file on the stack. fn has_transitive_use_client_impl( &mut self, all_import_records: &[import_record::List<'_>], @@ -65,51 +75,66 @@ impl<'a> StaticRouteVisitor<'a> { use_directives: &[UseDirective], source_index: Index, ) -> bool { - if let Some(result) = self.cache.get(&source_index.get()) { - return *result; + debug_assert!(self.stack.is_empty()); + if let Some(known) = self.enter(source_index) { + return known; } - if self.visited.is_set(source_index.get() as usize) { - return false; - } - self.visited.set(source_index.get() as usize); - let import_records = &all_import_records[source_index.get() as usize]; + let mut result = false; + while let Some(frame) = self.stack.last_mut() { + if result { + let finished = self.stack.pop().expect("stack is non-empty"); + self.cache.insert(finished.source_index.get(), true); + continue; + } - let result = 'result: { - for import_record in import_records.as_slice() { - if !import_record.source_index.is_valid() { - continue; - } + let import_records = &all_import_records[frame.source_index.get() as usize]; + let Some(import_record) = import_records.as_slice().get(frame.next_record) else { + let finished = self.stack.pop().expect("stack is non-empty"); + self.cache.insert(finished.source_index.get(), false); + continue; + }; + frame.next_record += 1; - // check if this import is a client boundary - debug_assert_eq!(referenced_source_indices.len(), use_directives.len()); - for (referenced_source_index, use_directive) in - referenced_source_indices.iter().zip(use_directives) - { - if *use_directive != UseDirective::Client { - continue; - } - // it's a client boundary - if *referenced_source_index == import_record.source_index.get() { - break 'result true; - } - } + if !import_record.source_index.is_valid() { + continue; + } - // otherwise check its children - if self.has_transitive_use_client_impl( - all_import_records, - referenced_source_indices, - use_directives, - import_record.source_index, - ) { - break 'result true; - } + // check if this import is a client boundary + debug_assert_eq!(referenced_source_indices.len(), use_directives.len()); + if referenced_source_indices.iter().zip(use_directives).any( + |(referenced_source_index, use_directive)| { + *use_directive == UseDirective::Client + && *referenced_source_index == import_record.source_index.get() + }, + ) { + result = true; + continue; } - false - }; - self.cache.insert(source_index.get(), result); + // otherwise check its children + if let Some(known) = self.enter(import_record.source_index) { + result = known; + } + } result } + + /// Pushes `source_index` onto the stack. Returns the result instead if it's + /// cached or the file was already visited. + fn enter(&mut self, source_index: Index) -> Option { + if let Some(result) = self.cache.get(&source_index.get()) { + return Some(*result); + } + if self.visited.is_set(source_index.get() as usize) { + return Some(false); + } + self.visited.set(source_index.get() as usize); + self.stack.push(Frame { + source_index, + next_record: 0, + }); + None + } } diff --git a/src/bundler/linker_context/computeChunks.rs b/src/bundler/linker_context/computeChunks.rs index bde0bdb1e091..f0cef9bba964 100644 --- a/src/bundler/linker_context/computeChunks.rs +++ b/src/bundler/linker_context/computeChunks.rs @@ -16,7 +16,9 @@ use crate::{BundleV2, Chunk, Index, IndexInt, LinkerContext}; // Typed SoA column accessors generated by ``. use super::find_all_imported_parts_in_js_order::find_all_imported_parts_in_js_order; use super::find_imported_css_files_in_js_order::find_imported_css_files_in_js_order; -use super::find_imported_files_in_css_order::find_imported_files_in_css_order; +use super::find_imported_files_in_css_order::{ + CssImportChainTooDeep, find_imported_files_in_css_order, +}; use super::merge_small_chunks::merge_small_chunks; #[inline(always)] @@ -31,6 +33,22 @@ fn make_flags(has_html_chunk: bool, is_browser_chunk_from_server_build: bool) -> f } +/// Returns `order`. If the `@import` chain is too deep, logs an error and returns +/// an empty order. The logged error fails the build after `compute_chunks` returns. +fn css_order_or_log( + this_ptr: *mut LinkerContext, + parse_graph: &crate::Graph::Graph, + order: Result, CssImportChainTooDeep>, +) -> Vec { + order.unwrap_or_else(|too_deep| { + // SAFETY: see the `this_ptr` note in `compute_chunks`. The log is + // allocation-disjoint from the linker (`LinkerContext::log_disjoint`). + let log = unsafe { &*this_ptr }.log_disjoint(); + too_deep.add_to_log(parse_graph.input_files.items_source(), log); + Vec::new() + }) +} + #[inline(never)] pub(crate) fn compute_chunks( this: &mut LinkerContext, @@ -141,10 +159,14 @@ pub(crate) fn compute_chunks( if css_asts[source_index as usize].is_some() { // SAFETY: see `this_ptr` note above — the helper only reads from // `this.graph` columns disjoint from the slices we hold here. - let order = find_imported_files_in_css_order( - unsafe { &mut *this_ptr }, - temp, - &[Index::init(source_index)], + let order = css_order_or_log( + this_ptr, + parse_graph, + find_imported_files_in_css_order( + unsafe { &mut *this_ptr }, + temp, + &[Index::init(source_index)], + ), ); // Create a chunk for the entry point here to ensure that the chunk is // always generated even if the resulting file is empty @@ -215,10 +237,14 @@ pub(crate) fn compute_chunks( find_imported_css_files_in_js_order(this, temp, Index::init(source_index)); if css_source_indices.len() > 0 { // SAFETY: see `this_ptr` note above. - let order = find_imported_files_in_css_order( - unsafe { &mut *this_ptr }, - temp, - css_source_indices.slice(), + let order = css_order_or_log( + this_ptr, + parse_graph, + find_imported_files_in_css_order( + unsafe { &mut *this_ptr }, + temp, + css_source_indices.slice(), + ), ); // Always use content-based hashing for CSS chunk deduplication. @@ -701,10 +727,20 @@ pub(crate) fn compute_chunks( bun_sys::O::PATH | bun_sys::O::DIRECTORY, 0, ) else { - break 'dir &*resolve_path::normalize_buf::( - dir_path, - &mut real_path_buf.0, - ); + let Some(normalized) = resolve_path::normalize_buf_checked::< + bun_paths::platform::Auto, + >(dir_path, &mut real_path_buf.0) else { + this.log_disjoint().add_error_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!( + "ENAMETOOLONG: Failed to get full path for directory '{}'", + bstr::BStr::new(dir_path) + ), + ); + return Err(crate::Error::BuildFailed); + }; + break 'dir &*normalized; }; match dir_file.get_path(&mut real_path_buf) { diff --git a/src/bundler/linker_context/findImportedFilesInCSSOrder.rs b/src/bundler/linker_context/findImportedFilesInCSSOrder.rs index 47192d69a2d5..9ef8da33a578 100644 --- a/src/bundler/linker_context/findImportedFilesInCSSOrder.rs +++ b/src/bundler/linker_context/findImportedFilesInCSSOrder.rs @@ -2,9 +2,9 @@ use crate::mal_prelude::*; use bstr::BStr; use bun_alloc::Arena; -use bun_ast::{ImportKind, ImportRecord, ImportRecordFlags}; +use bun_ast::{ImportKind, ImportRecord, ImportRecordFlags, Range}; use bun_collections::{ArrayHashMap, StringArrayHashMap, VecExt}; -use bun_core::handle_oom; +use bun_core::{StackCheck, handle_oom}; use crate::Graph::Graph; use crate::bun_css::css_parser::BundlerCssRule; @@ -17,8 +17,10 @@ use bun_ast::Index as AstIndex; // `CssImportOrder` values are arena-backed (the inner `Vec`s point into bump // arenas and are never individually freed). Rust's `Vec` has `Drop`, so a // literal `*entry` is not `Copy`; we duplicate the values bitwise instead. -// `conditions` slabs come from `deep_clone_conditions`, which allocates them -// from the `LinkerGraph` arena (`graph.heap`). The `Vec` headers aliasing a +// `conditions` slabs come from `fork_conditions`, which allocates them +// from the `LinkerGraph` arena (`graph.heap`). Each `ImportConditions` in a slab +// is a bitwise copy of the conditions of an `ImportRule`. The rule's stylesheet +// owns their heap and outlives the link. The `Vec` headers aliasing a // slab are `mem::forget`'d everywhere (`CssImportOrder::drop` + the // post-`visit()` forget below); the slab itself is bulk-freed with the arena. // `wip_order`/`order` shuffles use `len`-truncation rather than @@ -72,11 +74,14 @@ fn memcpy_and_reset(order: &mut Vec, wip: &mut Vec( this: &'a mut LinkerContext, temp_arena: &'a Arena, entry_points: &[Index], -) -> Vec { +) -> Result, CssImportChainTooDeep> { let _ = temp_arena; struct Visitor<'a> { @@ -96,6 +101,11 @@ pub(crate) fn find_imported_files_in_css_order<'a>( has_external_import: bool, visited: Vec, order: Vec, + + stack_check: StackCheck, + /// Set when the stack is nearly exhausted. The traversal then unwinds + /// without visiting anything else. + too_deep: Option, } impl<'a> Visitor<'a> { @@ -105,6 +115,30 @@ pub(crate) fn find_imported_files_in_css_order<'a>( BStr::new(&sources[source_index.get() as usize].path.pretty) } + /// Returns false if the traversal stopped because the chain is too deep. + fn visit_import( + &mut self, + importer: Index, + record: &ImportRecord, + wrapping_conditions: &mut Vec, + wrapping_import_records: &mut Vec, + ) -> bool { + if !self.stack_check.is_safe_to_recurse() { + self.too_deep = Some(CssImportChainTooDeep { + importer, + range: record.range, + kind: record.kind, + }); + return false; + } + self.visit( + record.source_index, + wrapping_conditions, + wrapping_import_records, + ); + self.too_deep.is_none() + } + fn visit( &mut self, source_index: Index, @@ -177,35 +211,34 @@ pub(crate) fn find_imported_files_in_css_order<'a>( // arena-owned, so wrap the local header in // `ManuallyDrop` to avoid a double-free. let mut nested_conditions = core::mem::ManuallyDrop::new( - deep_clone_conditions(wrapping_conditions, self.arena), + fork_conditions(wrapping_conditions, self.arena), ); - let mut nested_import_records = - shallow_clone_records(wrapping_import_records); - - // Clone these import conditions and append them to the state - nested_conditions.append_assume_capacity( - import_rule.conditions_with_import_records( - self.arena, - &mut nested_import_records, - ), - ); - self.visit( - record.source_index, + + // Append these import conditions to the state + // SAFETY: never dropped (see the note at `bitwise_copy`). + nested_conditions.append_assume_capacity(unsafe { + bitwise_copy(import_rule.conditions()) + }); + let followed = self.visit_import( + source_index, + record, &mut nested_conditions, wrapping_import_records, ); - // `nested_import_records` is *not* passed to `visit` (the - // outer `wrapping_import_records` is), so it is uniquely - // owned here — drop it normally to free the buffer. - drop(nested_import_records); + if !followed { + return; + } import_record_idx += 1; continue; } - self.visit( - record.source_index, + if !self.visit_import( + source_index, + record, wrapping_conditions, wrapping_import_records, - ); + ) { + return; + } import_record_idx += 1; continue; } @@ -219,15 +252,12 @@ pub(crate) fn find_imported_files_in_css_order<'a>( // CSS file using a data URL. if import_rule.has_conditions() { let mut all_conditions = - deep_clone_conditions(wrapping_conditions, self.arena); - let mut all_import_records = - shallow_clone_records(wrapping_import_records); - all_conditions.append_assume_capacity( - import_rule.conditions_with_import_records( - self.arena, - &mut all_import_records, - ), - ); + fork_conditions(wrapping_conditions, self.arena); + let all_import_records = shallow_clone_records(wrapping_import_records); + // SAFETY: never dropped (see the note at `bitwise_copy`). + all_conditions.append_assume_capacity(unsafe { + bitwise_copy(import_rule.conditions()) + }); self.order.push(CssImportOrder { kind: CssImportOrderKind::ExternalPath(record.path), conditions: all_conditions, @@ -264,12 +294,16 @@ pub(crate) fn find_imported_files_in_css_order<'a>( // matter for these because the output order is explicitly undfened // in the specification. for record in self.all_import_records[source_index.get() as usize].as_slice() { - if record.kind == ImportKind::Composes && record.source_index.is_valid() { - self.visit( - record.source_index, + if record.kind == ImportKind::Composes + && record.source_index.is_valid() + && !self.visit_import( + source_index, + record, wrapping_conditions, wrapping_import_records, - ); + ) + { + return; } } @@ -313,6 +347,8 @@ pub(crate) fn find_imported_files_in_css_order<'a>( all_import_records: all_import_records_slice, has_external_import: false, order: Vec::new(), + stack_check: StackCheck::init(), + too_deep: None, }; let mut wrapping_conditions: Vec = Vec::new(); let mut wrapping_import_records: Vec = Vec::new(); @@ -323,6 +359,13 @@ pub(crate) fn find_imported_files_in_css_order<'a>( &mut wrapping_conditions, &mut wrapping_import_records, ); + if visitor.too_deep.is_some() { + break; + } + } + + if let Some(too_deep) = visitor.too_deep { + return Err(too_deep); } let has_external_import = visitor.has_external_import; @@ -495,7 +538,7 @@ pub(crate) fn find_imported_files_in_css_order<'a>( if conditions.has_anonymous_layer() { // SAFETY: `i < entry.conditions.len() <= capacity`; // shrinking exposes no uninitialized range. The - // truncated tail is arena-owned (`deep_clone_conditions`) + // truncated tail is arena-owned (`fork_conditions`) // and bulk-freed with the arena, so skipping `Drop` is sound. unsafe { entry.conditions.set_len((i as u32) as usize) }; layers.replace(Vec::new()); @@ -539,7 +582,7 @@ pub(crate) fn find_imported_files_in_css_order<'a>( } // SAFETY: `i` was just decremented from a value // `<= len`, so `i < len <= capacity`. Truncated tail - // is arena-owned (`deep_clone_conditions`) and + // is arena-owned (`fork_conditions`) and // bulk-freed with the arena. unsafe { entry.conditions.set_len((i) as usize) }; } @@ -732,7 +775,32 @@ pub(crate) fn find_imported_files_in_css_order<'a>( CssOrderDebugStep::AfterMergingAdjacentLayerRules, ); - order + Ok(order) +} + +/// Error for an `@import` (or cross-file `composes`) chain that's too deep for +/// the thread's stack. +pub(crate) struct CssImportChainTooDeep { + importer: Index, + range: Range, + kind: ImportKind, +} + +impl CssImportChainTooDeep { + pub(crate) fn add_to_log(&self, sources: &[bun_ast::Source], log: &mut bun_ast::Log) { + log.add_range_error_fmt( + Some(&sources[self.importer.get() as usize]), + self.range, + format_args!( + "Maximum call stack size exceeded while following this \"{}\" chain", + if self.kind == ImportKind::Composes { + "composes" + } else { + "@import" + } + ), + ); + } } /// The returned list is later bitwise-copied into `CssImportOrder` entries via @@ -744,11 +812,12 @@ pub(crate) fn find_imported_files_in_css_order<'a>( /// one extra slot for the single `append_assume_capacity` each call site /// performs, so the header never reallocates. #[inline] -fn deep_clone_conditions(list: &Vec, arena: &Arena) -> Vec { +fn fork_conditions(list: &Vec, arena: &Arena) -> Vec { let cap = list.len() as usize + 1; let slab = arena.alloc_uninit_slice::(cap); for (dst, src) in slab.iter_mut().zip(list.slice_const()) { - dst.write(src.deep_clone(arena)); + // SAFETY: never dropped (see the note at `bitwise_copy`). + dst.write(unsafe { bitwise_copy(src) }); } // SAFETY: `slab[..list.len()]` was just initialized; cap is the slab // length. The resulting `Vec` is never dropped (always `mem::forget`'d) diff --git a/src/bundler/linker_context/generateChunksInParallel.rs b/src/bundler/linker_context/generateChunksInParallel.rs index 88212ec8a688..9e3251214021 100644 --- a/src/bundler/linker_context/generateChunksInParallel.rs +++ b/src/bundler/linker_context/generateChunksInParallel.rs @@ -31,8 +31,7 @@ use crate::linker_context::static_route_visitor::StaticRouteVisitor; use crate::linker_context::write_output_files_to_disk::write_output_files_to_disk; use crate::linker_context_mod::{GenerateChunkCtx, PendingPartRange}; -/// Bytecode output file extension (also defined in `writeOutputFilesToDisk.rs`). -const BYTECODE_EXTENSION: &str = ".jsc"; +use crate::bytecode_sidecar::EXTENSION as BYTECODE_EXTENSION; // `Chunk.final_rel_path` is an owned `Box<[u8]>`; assignments // below move the boxed buffer directly — no lifetime promotion needed. @@ -302,7 +301,7 @@ pub(crate) fn generate_chunks_in_parallel( // deeply nested AST) must fail the build instead of joining the chunk // as silently truncated output. Dev server excluded: its callers turn // any `Err` here into an OOM panic (see `finish_from_bake_dev_server`), - // so unprintable parts keep the old dropped-code behavior there. + // so its `finalize_bundle` records each failed part as a per-file failure. if !IS_DEV_SERVER { let mut had_print_error = false; // Without code splitting a failing file is printed once per chunk @@ -399,6 +398,10 @@ pub(crate) fn generate_chunks_in_parallel( sources: Vec>, } let mut duplicates_map: StringArrayHashMap = StringArrayHashMap::default(); + let mut has_unusable_output_path = false; + // Without an output directory the paths are only returned, so they can have any length. + let opens_output_paths = + !c.resolver().opts.output_dir.is_empty() || c.options.compile_mode.is_executable(); // Compute the final hashes of each chunk, then use those to create the final // paths of each chunk. @@ -426,6 +429,19 @@ pub(crate) fn generate_chunks_in_parallel( rel_path.drain(i..i + 2); } + let input: &[u8] = c.parse_graph().input_files.items_source() + [chunk.entry_point.source_index() as usize] + .path + .pretty; + if opens_output_paths + && !chunk + .template + .check_output_path(c.log_disjoint(), input, &rel_path) + { + has_unusable_output_path = true; + continue; + } + let claimed = path_names_map.get_or_put(&rel_path)?; if claimed.found_existing { let first = *claimed.value_ptr as usize; @@ -525,6 +541,10 @@ pub(crate) fn generate_chunks_in_parallel( return Err(crate::Error::DuplicateOutputPath); } + + if has_unusable_output_path { + return Err(crate::Error::BuildFailed); + } } // After final_rel_path is computed for all chunks, fix up module_info @@ -579,6 +599,20 @@ pub(crate) fn generate_chunks_in_parallel( } } + // The printer's module info only covers the code it printed, but every chunk also + // includes `--banner` and `--footer`. + let raw_text = c.options.raw_text_module_syntax(); + if raw_text.import_meta || raw_text.top_level_await { + for chunk in chunks.iter_mut() { + if let crate::chunk::Content::Javascript(js) = &mut chunk.content + && let Some(mi) = js.module_info.as_mut() + { + mi.flags.contains_import_meta |= raw_text.import_meta; + mi.flags.has_tla |= raw_text.top_level_await; + } + } + } + // Fix up each chunk's module_info; every chunk's strings go into one // table (`OutputKind::ModuleInfoStringTable`) their bodies index into. let mut table_ids: Vec> = Vec::new(); @@ -706,6 +740,7 @@ pub(crate) fn generate_chunks_in_parallel( c: unsafe { bun_ptr::detach_lifetime_ref::(c) }, cache: bun_collections::ArrayHashMap::default(), visited: AutoBitSet::init_empty(c.graph.files.len()).expect("oom"), + stack: Vec::new(), }; // For standalone mode, resolve JS/CSS chunks so we can inline their content into HTML. @@ -773,7 +808,6 @@ pub(crate) fn generate_chunks_in_parallel( // so the sourceMappingURL resolves relative to the HTML // file rather than a JS file next to the .map. Point at // the .map path relative to the HTML chunk's directory. - let mut relative_platform_buf = path::path_buffer_pool::get(); let [a, b]: [&[u8]; 2] = if !c.options.public_path.is_empty() { cheap_prefix_normalizer( c.options.public_path, @@ -801,13 +835,11 @@ pub(crate) fn generate_chunks_in_parallel( if html_dir.is_empty() { &source_map_final_rel_path } else { - path::resolve_path::relative_platform_buf::< + path::resolve_path::relative_platform::< path::platform::Posix, false, >( - &mut relative_platform_buf[..], - html_dir, - &source_map_final_rel_path, + html_dir, &source_map_final_rel_path ) }, ) @@ -1093,7 +1125,6 @@ pub(crate) fn generate_chunks_in_parallel( let bytecode_output_file: Option = 'brk: { if c.options.generate_bytecode_cache { if c.chunk_gets_bytecode(chunk) { - let mut fdpath = bun_paths::path_buffer_pool::get(); // For --compile builds, the bytecode URL must match the module name // that will be used at runtime. The module name is: // public_path + final_rel_path (e.g., "/$bunfs/root/app.js") @@ -1136,6 +1167,12 @@ pub(crate) fn generate_chunks_in_parallel( if bytecode_order.is_some() { linked_bytecode_url = Some(source_provider_url.clone()); } + // Executables embed the raw payload (aligned by the module graph). + let bytecode = if c.options.compile_mode.is_executable() { + bytecode + } else { + crate::bytecode_sidecar::frame(bytecode) + }; let source_provider_url_str = source_provider_url.to_utf8(); debug!( "Bytecode cache generated {}: {}", @@ -1147,11 +1184,6 @@ pub(crate) fn generate_chunks_in_parallel( } ) ); - fdpath[..chunk.final_rel_path.len()] - .copy_from_slice(&chunk.final_rel_path); - fdpath[chunk.final_rel_path.len()..][..BYTECODE_EXTENSION.len()] - .copy_from_slice(BYTECODE_EXTENSION.as_bytes()); - let mut input_path_buf: Vec = Vec::new(); input_path_buf.extend_from_slice(&chunk.final_rel_path); input_path_buf.extend_from_slice(BYTECODE_EXTENSION.as_bytes()); @@ -1312,10 +1344,10 @@ pub(crate) fn generate_chunks_in_parallel( crate::chunk::Content::Html => Box::default(), }, bake_extra: 'brk: { - if c.framework.is_none() || IS_DEV_SERVER { + let Some(framework) = c.framework else { break 'brk BakeExtra::default(); - } - if !c.framework.unwrap().is_built_in_react { + }; + if IS_DEV_SERVER { break 'brk BakeExtra::default(); } @@ -1328,12 +1360,15 @@ pub(crate) fn generate_chunks_in_parallel( if output_kind == options::OutputKind::EntryPoint && side == options::Side::Server { - extra.route = if static_route_visitor - .has_transitive_use_client(chunk.entry_point.source_index()) + // Without server components there are no "use client" + // boundaries, so the visitor would report every route as static. + extra.route = if framework.server_components.is_some() + && !static_route_visitor + .has_transitive_use_client(chunk.entry_point.source_index()) { - BakeRouteKind::Route - } else { BakeRouteKind::FullyStaticRoute + } else { + BakeRouteKind::Route }; } @@ -1708,14 +1743,21 @@ struct LinkedBytecode { names_out: Option<(&'static [u8], Vec)>, } -/// The builtins section of the executable being built: the target's, or this one's. +/// Returns the builtins section of the executable being built: `target_section`, or this bun's +/// if `None`. Returns `None` if this bun can't compile that section's modules. fn builtins_of(target_section: Option<&[u8]>) -> Option> { - let section = target_section.unwrap_or_else(|| crate::bundle_v2::dispatch::host_builtins()); - match bun_exe_format::builtins::Builtins::parse(section) { + use bun_exe_format::builtins::Builtins; + let builtins = Builtins::parse(crate::bundle_v2::dispatch::host_builtins()).and_then(|host| { + match target_section { + Some(section) => Builtins::parse(section)?.require_same_engine(&host), + None => Ok(host), + } + }); + match builtins { Ok(builtins) => Some(builtins), Err(e) => { debug!( - "Internal module bytecode: builtins section unreadable ({})", + "Internal module bytecode: builtins section unusable ({})", <&'static str>::from(&e) ); None diff --git a/src/bundler/linker_context/generateCodeForFileInChunkJS.rs b/src/bundler/linker_context/generateCodeForFileInChunkJS.rs index f7ba8e07ca3c..c0778ac7eae1 100644 --- a/src/bundler/linker_context/generateCodeForFileInChunkJS.rs +++ b/src/bundler/linker_context/generateCodeForFileInChunkJS.rs @@ -189,7 +189,7 @@ pub fn generate_code_for_file_in_chunk_js<'r, 'src>( source_ref.path.text.as_ptr(), source_ref.path.pretty.as_ptr(), ) { - let top_level_dir = bun_resolver::fs::FileSystem::get().top_level_dir; + let top_level_dir = bun_resolver::fs::FileSystem::get().top_level_dir(); let new_path = bun_core::handle_oom(generic_path_with_pretty_initialized( &source_ref.path, c.options.target, diff --git a/src/bundler/linker_context/generateCodeForLazyExport.rs b/src/bundler/linker_context/generateCodeForLazyExport.rs index 44c9a36142c8..46e7aa77928f 100644 --- a/src/bundler/linker_context/generateCodeForLazyExport.rs +++ b/src/bundler/linker_context/generateCodeForLazyExport.rs @@ -94,10 +94,34 @@ pub(crate) fn generate_code_for_lazy_export( // `defer inner_visited.deinit(...)` — handled by Drop. let mut composes_visited: ArrayHashMap = ArrayHashMap::new(); // `defer composes_visited.deinit()` — handled by Drop. + let mut stack: Vec = Vec::new(); + + /// A class being traversed and the position of its next `composes` name. + #[derive(Clone, Copy)] + struct Frame { + idx: IndexInt, + css_ref: CssRef, + compose_index: usize, + name_index: usize, + /// False for the root class because the caller appends its name. + append_name: bool, + } + + impl Frame { + fn next_name(&mut self) { + self.name_index += 1; + } + + fn next_compose(&mut self) { + self.compose_index += 1; + self.name_index = 0; + } + } struct Visitor<'a> { inner_visited: &'a mut BitSet, composes_visited: &'a mut ArrayHashMap, + stack: &'a mut Vec, parts: &'a mut Vec, all_import_records: &'a [bun_ast::import_record::List<'a>], // `BundledAst.css` SoA column. @@ -116,17 +140,33 @@ pub(crate) fn generate_code_for_lazy_export( self.composes_visited.clear_retaining_capacity(); } - fn visit_name(&mut self, ast: &BundlerStyleSheet, ref_: CssRef, idx: IndexInt) { + /// Pushes a composed class unless it was already visited. Its name + /// is appended after the classes it composes. + fn visit_name(&mut self, ref_: CssRef, idx: IndexInt) { debug_assert!(ref_.can_be_composed()); let real_ref = ref_.to_real_ref(idx); - let from_this_file = idx == self.source_index; - if (from_this_file && self.inner_visited.is_set(ref_.inner_index() as usize)) - || (!from_this_file && self.composes_visited.contains_key(&real_ref)) - { - return; + if idx == self.source_index { + if self.inner_visited.is_set(ref_.inner_index() as usize) { + return; + } + self.inner_visited.set(ref_.inner_index() as usize); + } else { + if self.composes_visited.contains_key(&real_ref) { + return; + } + self.composes_visited.insert(real_ref, ()); } - self.visit_composes(ast, ref_, idx); + self.stack.push(Frame { + idx, + css_ref: ref_, + compose_index: 0, + name_index: 0, + append_name: true, + }); + } + + fn append_name(&mut self, real_ref: Ref) { self.parts.push(E::TemplatePart { value: Expr::init( E::NameOfSymbol { @@ -138,12 +178,6 @@ pub(crate) fn generate_code_for_lazy_export( tail: E::TemplateContents::Cooked(E::String::init(b" ")), tail_loc: self.loc, }); - - if from_this_file { - self.inner_visited.set(ref_.inner_index() as usize); - } else { - self.composes_visited.insert(real_ref, ()); - } } fn warn_non_single_class_composes( @@ -174,116 +208,130 @@ pub(crate) fn generate_code_for_lazy_export( ); } - fn visit_composes( - &mut self, - ast: &BundlerStyleSheet, - css_ref: CssRef, - idx: IndexInt, - ) { - let ref_ = css_ref.to_real_ref(idx); - if ast.composes.count() > 0 { - let Some(composes) = ast.composes.get(&ref_) else { - return; + /// Appends the name of every class that `css_ref` transitively + /// composes, in postorder. Each loop iteration handles one + /// `composes` name from the top frame. + fn visit_composes(&mut self, css_ref: CssRef, idx: IndexInt) { + debug_assert!(self.stack.is_empty()); + self.stack.push(Frame { + idx, + css_ref, + compose_index: 0, + name_index: 0, + append_name: false, + }); + + let all_css_asts = self.all_css_asts; + while let Some(frame) = self.stack.last_mut() { + let idx = frame.idx; + let css_ref = frame.css_ref; + let ast: &BundlerStyleSheet = all_css_asts[idx as usize] + .as_deref() + .expect("composed class comes from a CSS file"); + let composes = ast.composes.get(&css_ref.to_real_ref(idx)); + let Some(compose) = composes.and_then(|composes| { + composes.composes.slice().get(frame.compose_index) + }) else { + let frame = self.stack.pop().expect("stack is non-empty"); + if frame.append_name { + self.append_name(css_ref.to_real_ref(idx)); + } + continue; }; // while parsing we check that we only allow `composes` on single class selectors debug_assert!(css_ref.tag().contains(CssRefTag::CLASS)); - for compose in composes.composes.slice() { - match &compose.from { - // it is imported - Some(CssSpecifier::ImportRecordIndex(import_record_idx)) => { - let import_records = &self.all_import_records[idx as usize]; - let import_record = - &import_records[*import_record_idx as usize]; - if import_record.source_index.is_valid() { - let Some(other_file) = self.all_css_asts - [import_record.source_index.get() as usize] - .as_deref() - else { - self.log.add_error_fmt( - &self.all_sources[idx as usize], - compose.loc, - format_args!( - "Cannot use the \"composes\" property with the {} file (it is not a CSS file)", - bun_fmt::quote( - self.all_sources - [import_record.source_index.get() as usize] - .path - .pretty - ), - ), - ); - continue; - }; - for name in compose.names.slice() { - let name_v = name.v(); - let Some(other_name_entry) = - other_file.local_scope.get(name_v) - else { - continue; - }; - let other_name_ref = other_name_entry.ref_; - if !other_name_ref.can_be_composed() { - self.warn_non_single_class_composes( - other_file, - other_name_ref, - import_record.source_index.get(), - compose.loc, - ); - } else { - self.visit_name( - other_file, - other_name_ref, - import_record.source_index.get(), - ); - } - } - } + match &compose.from { + // it is imported + Some(CssSpecifier::ImportRecordIndex(import_record_idx)) => { + let import_record = &self.all_import_records[idx as usize] + [*import_record_idx as usize]; + if !import_record.source_index.is_valid() { + frame.next_compose(); + continue; + } + let other_idx = import_record.source_index.get(); + let Some(other_file) = all_css_asts[other_idx as usize].as_deref() + else { + frame.next_compose(); + self.log.add_error_fmt( + &self.all_sources[idx as usize], + compose.loc, + format_args!( + "Cannot use the \"composes\" property with the {} file (it is not a CSS file)", + bun_fmt::quote( + self.all_sources[other_idx as usize].path.pretty + ), + ), + ); + continue; + }; + let Some(name) = compose.names.slice().get(frame.name_index) else { + frame.next_compose(); + continue; + }; + frame.next_name(); + let Some(other_name_entry) = other_file.local_scope.get(name.v()) + else { + continue; + }; + let other_name_ref = other_name_entry.ref_; + if !other_name_ref.can_be_composed() { + self.warn_non_single_class_composes( + other_file, + other_name_ref, + other_idx, + compose.loc, + ); + } else { + self.visit_name(other_name_ref, other_idx); } - Some(CssSpecifier::Global) => { - // E.g.: `composes: foo from global` - // - // In this example `foo` is global and won't be rewritten to a locally scoped - // name, so we can just add it as a string. - for name in compose.names.slice() { - let name_v = name.v(); - self.parts.push(E::TemplatePart { - value: Expr::init(E::String::init(name_v), self.loc), - tail: E::TemplateContents::Cooked(E::String::init( - b" ", - )), - tail_loc: self.loc, - }); - } + } + Some(CssSpecifier::Global) => { + // E.g.: `composes: foo from global` + // + // In this example `foo` is global and won't be rewritten to a locally scoped + // name, so we can just add it as a string. + frame.next_compose(); + for name in compose.names.slice() { + let name_v = name.v(); + self.parts.push(E::TemplatePart { + value: Expr::init(E::String::init(name_v), self.loc), + tail: E::TemplateContents::Cooked(E::String::init(b" ")), + tail_loc: self.loc, + }); } - None => { - // it is from the current file - for name in compose.names.slice() { - let name_v = name.v(); - let Some(name_entry) = ast.local_scope.get(name_v) else { - self.log.add_error_fmt( - &self.all_sources[idx as usize], - compose.loc, - format_args!( - "The name {} never appears in {} as a CSS modules locally scoped class name. Note that \"composes\" only works with single class selectors.", - bun_fmt::quote(name_v), - bun_fmt::quote(self.all_sources[idx as usize].path.pretty), - ), - ); - continue; - }; - let name_ref = name_entry.ref_; - if !name_ref.can_be_composed() { - self.warn_non_single_class_composes( - ast, - name_ref, - idx, - compose.loc, - ); - } else { - self.visit_name(ast, name_ref, idx); - } - } + } + None => { + // it is from the current file + let Some(name) = compose.names.slice().get(frame.name_index) else { + frame.next_compose(); + continue; + }; + frame.next_name(); + let name_v = name.v(); + let Some(name_entry) = ast.local_scope.get(name_v) else { + self.log.add_error_fmt( + &self.all_sources[idx as usize], + compose.loc, + format_args!( + "The name {} never appears in {} as a CSS modules locally scoped class name. Note that \"composes\" only works with single class selectors.", + bun_fmt::quote(name_v), + bun_fmt::quote(self.all_sources[idx as usize].path.pretty), + ), + ); + continue; + }; + let name_ref = name_entry.ref_; + if !name_ref.can_be_composed() { + self.warn_non_single_class_composes( + ast, + name_ref, + idx, + compose.loc, + ); + } else { + self.visit_name(name_ref, idx); } } } @@ -315,6 +363,7 @@ pub(crate) fn generate_code_for_lazy_export( let mut visitor = Visitor { inner_visited: &mut inner_visited, composes_visited: &mut composes_visited, + stack: &mut stack, source_index, parts: &mut template_parts, all_import_records, @@ -329,7 +378,7 @@ pub(crate) fn generate_code_for_lazy_export( visitor.clear_all(); visitor.inner_visited.set(ref_.inner_index() as usize); if ref_.tag().contains(CssRefTag::CLASS) { - visitor.visit_composes(css_ast, ref_, source_index); + visitor.visit_composes(ref_, source_index); } if !template_parts.is_empty() { diff --git a/src/bundler/linker_context/prepareCssAstsForChunk.rs b/src/bundler/linker_context/prepareCssAstsForChunk.rs index f4aee82aef56..3eb3615dbe24 100644 --- a/src/bundler/linker_context/prepareCssAstsForChunk.rs +++ b/src/bundler/linker_context/prepareCssAstsForChunk.rs @@ -463,8 +463,6 @@ fn wrap_rules_with_conditions( temp_bump: &Bump, conditions: &Vec, ) { - let mut dummy_import_records: Vec = Vec::new(); - let mut i: usize = conditions.len() as usize; while i > 0 { i -= 1; @@ -522,8 +520,8 @@ fn wrap_rules_with_conditions( ast.rules = arena_rule_list_one( temp_bump, BundlerCssRule::Supports(BundlerSupportsRule { - condition: supports - .clone_with_import_records(temp_bump, &mut dummy_import_records), + // SAFETY: as for `layer` above. + condition: unsafe { core::ptr::read(supports) }, rules: core::mem::take(&mut ast.rules), loc: Location::dummy(), }), @@ -537,17 +535,14 @@ fn wrap_rules_with_conditions( ast.rules = arena_rule_list_one( temp_bump, BundlerCssRule::Media(BundlerMediaRule { - query: item - .media - .clone_with_import_records(temp_bump, &mut dummy_import_records), + // SAFETY: as for `layer` above. + query: unsafe { core::ptr::read(&raw const item.media) }, rules: core::mem::take(&mut ast.rules), loc: Location::dummy(), }), ); } } - - debug_assert!(dummy_import_records.len() == 0); } pub use crate::ThreadPool; diff --git a/src/bundler/linker_context/scanImportsAndExports.rs b/src/bundler/linker_context/scanImportsAndExports.rs index 4611b2fe6f8d..ffca995c85b8 100644 --- a/src/bundler/linker_context/scanImportsAndExports.rs +++ b/src/bundler/linker_context/scanImportsAndExports.rs @@ -448,6 +448,7 @@ pub(crate) fn scan_imports_and_exports( export_star_records: &*export_star_import_records, output_format, wrap_stack: Vec::new(), + export_star_stack: Vec::new(), } }; for source_index_ in &reachable { @@ -524,7 +525,10 @@ pub(crate) fn scan_imports_and_exports( import_records_list, export_star_records: export_star_import_records, imports_to_bind: imports_to_bind_list, - source_index_stack: Vec::with_capacity(32), + stack: Vec::with_capacity(32), + on_stack: bun_collections::AutoBitSet::init_empty( + col_ref!(named_exports).len(), + )?, exports_kind, named_exports, ast_flags: ast_flags_list, @@ -1453,40 +1457,68 @@ struct DependencyWrapper<'a> { export_star_records: &'a [bun_alloc::AstVec], output_format: options::Format, wrap_stack: Vec, + export_star_stack: Vec, +} + +/// A file in an `export *` traversal and the index of its next export star record. +struct ExportStarFrame { + source_index: IndexInt, + next_record: usize, +} + +enum ExportStarEntry { + /// The file already has dynamic exports. + Dynamic, + /// The file was already visited from this root. + Visited, + /// The file is on the stack and its export stars need to be followed. + Pushed, } impl DependencyWrapper<'_> { + /// Marks `source_index` as `EsmWithDynamicFallback` if it transitively + /// `export *`s from a file whose exports are not statically analyzable + /// (CommonJS, or an unresolved external `export *`), along with every file + /// on the `export *` path to that file. Returns true if `source_index` has + /// dynamic exports. fn has_dynamic_exports_due_to_export_star(&mut self, source_index: IndexInt) -> bool { - // Terminate the traversal now if this file already has dynamic exports - let export_kind = self.exports_kind[source_index as usize]; - match export_kind { - ExportsKind::Cjs | ExportsKind::EsmWithDynamicFallback => return true, - _ => {} + debug_assert!(self.export_star_stack.is_empty()); + match self.enter_export_star(source_index) { + ExportStarEntry::Dynamic => return true, + ExportStarEntry::Visited => return false, + ExportStarEntry::Pushed => {} } - // Avoid infinite loops due to cycles in the export star graph - let has_visited = self - .export_star_map - .get_or_put(source_index) - .expect("unreachable"); - if has_visited.found_existing { - return false; - } + while let Some(frame) = self.export_star_stack.last_mut() { + let source_index = frame.source_index; + let Some(&id) = self.export_star_records[source_index as usize].get(frame.next_record) + else { + self.export_star_stack.pop(); + continue; + }; + frame.next_record += 1; - for id in self.export_star_records[source_index as usize].iter() { // This file has dynamic exports if the exported imports are from a file // that either has dynamic exports directly or transitively by itself // having an export star from a file with dynamic exports. let kind = self.entry_point_kinds[source_index as usize]; let rec_source_index = - self.import_records[source_index as usize].as_slice()[*id as usize].source_index; - if (rec_source_index.is_invalid() - && (!kind.is_entry_point() || !self.output_format.keep_es6_import_export_syntax())) - || (rec_source_index.is_valid() - && rec_source_index.get() != source_index - && self.has_dynamic_exports_due_to_export_star(rec_source_index.get())) - { - self.exports_kind[source_index as usize] = ExportsKind::EsmWithDynamicFallback; + self.import_records[source_index as usize].as_slice()[id as usize].source_index; + let found_dynamic = if rec_source_index.is_invalid() { + !kind.is_entry_point() || !self.output_format.keep_es6_import_export_syntax() + } else if rec_source_index.get() != source_index { + matches!( + self.enter_export_star(rec_source_index.get()), + ExportStarEntry::Dynamic + ) + } else { + false + }; + if found_dynamic { + for frame in self.export_star_stack.drain(..) { + self.exports_kind[frame.source_index as usize] = + ExportsKind::EsmWithDynamicFallback; + } return true; } } @@ -1494,6 +1526,31 @@ impl DependencyWrapper<'_> { false } + fn enter_export_star(&mut self, source_index: IndexInt) -> ExportStarEntry { + // Terminate the traversal now if this file already has dynamic exports + match self.exports_kind[source_index as usize] { + ExportsKind::Cjs | ExportsKind::EsmWithDynamicFallback => { + return ExportStarEntry::Dynamic; + } + _ => {} + } + + // Avoid infinite loops due to cycles in the export star graph + let has_visited = self + .export_star_map + .get_or_put(source_index) + .expect("unreachable"); + if has_visited.found_existing { + return ExportStarEntry::Visited; + } + + self.export_star_stack.push(ExportStarFrame { + source_index, + next_record: 0, + }); + ExportStarEntry::Pushed + } + fn wrap(&mut self, source_index: IndexInt) { // Explicit worklist (was per-edge recursive). Only flag bits are // written and never re-read to decide later iterations, so @@ -1538,7 +1595,9 @@ impl DependencyWrapper<'_> { // ────────────────────────────────────────────────────────────────────────── struct ExportStarContext<'a> { import_records_list: *mut [ImportRecordList<'a>], - source_index_stack: Vec, + stack: Vec, + /// Has a bit set for each source index on `stack`. + on_stack: bun_collections::AutoBitSet, exports_kind: *mut [ExportsKind], named_exports: *mut [NamedExports], imports_to_bind: *mut [RefImportData], @@ -1547,31 +1606,37 @@ struct ExportStarContext<'a> { } impl<'a> ExportStarContext<'a> { - /// Recursively merge re-exports from `source_index` into - /// `resolved_exports[target_id]`. + /// Merges the re-exports reachable through `source_index`'s `export *` + /// statements into `resolved_exports[target_id]`. `stack` is the + /// `export *` path from `source_index` to the file being merged. fn add_exports( &mut self, resolved_exports: *mut [ResolvedExports], target_id: usize, source_index: IndexInt, ) { - // Avoid infinite loops due to cycles in the export star graph - for i in self.source_index_stack.iter() { - if *i == source_index { - return; - } - } - self.source_index_stack.push(source_index); - let stack_end_pos = self.source_index_stack.len(); + debug_assert!(self.stack.is_empty()); + self.push(source_index); + + while let Some(frame) = self.stack.last_mut() { + let source_index = frame.source_index; + let Some(&import_id) = + col_ref!(self.export_star_records)[source_index as usize].get(frame.next_record) + else { + self.stack.pop(); + self.on_stack.unset(source_index as usize); + continue; + }; + frame.next_record += 1; - // A lifted file's export star was `module.exports = ns`: it hands over `default` too. - let reexports_default = self.source_index_stack[..stack_end_pos].iter().all(|i| { - col_ref!(self.ast_flags)[*i as usize].contains(AstFlags::COMMONJS_LIFTED_TO_ESM) - }); + // A lifted file's export star was `module.exports = ns`, so it re-exports `default` too. + let reexports_default = self.stack.iter().all(|frame| { + col_ref!(self.ast_flags)[frame.source_index as usize] + .contains(AstFlags::COMMONJS_LIFTED_TO_ESM) + }); - for import_id in col_ref!(self.export_star_records)[source_index as usize].iter() { let other_source_index = col_ref!(self.import_records_list)[source_index as usize] - .as_slice()[*import_id as usize] + .as_slice()[import_id as usize] .source_index .get(); @@ -1611,8 +1676,10 @@ impl<'a> ExportStarContext<'a> { } // This export star is shadowed if any file in the stack has a matching real named export - for prev in &self.source_index_stack[0..stack_end_pos] { - if col_ref!(self.named_exports)[*prev as usize].contains(alias_slice) { + for prev in self.stack.iter() { + if col_ref!(self.named_exports)[prev.source_index as usize] + .contains(alias_slice) + { continue 'next_export; } } @@ -1661,12 +1728,20 @@ impl<'a> ExportStarContext<'a> { } } - // Search further through this file's export stars - self.add_exports(resolved_exports, target_id, other_source_index); + // Search further through this file's export stars, unless it's + // already on the stack (a cycle) + if !self.on_stack.is_set(other_id) { + self.push(other_source_index); + } } + } - // Scope-end truncation (no early returns after the push). - self.source_index_stack.truncate(stack_end_pos - 1); + fn push(&mut self, source_index: IndexInt) { + self.on_stack.set(source_index as usize); + self.stack.push(ExportStarFrame { + source_index, + next_record: 0, + }); } } @@ -1779,9 +1854,17 @@ mod __css_validation { range: bun_ast::Range, } + /// A traversal step for the class `Ref` in the CSS file `IndexInt`. + #[derive(Clone, Copy)] + enum Frame { + Enter(IndexInt, bun_ast::Ref), + Leave(IndexInt, bun_ast::Ref), + } + struct Visitor<'a, 'bump> { visited: ArrayHashMap, properties: StringArrayHashMap, + stack: Vec, all_import_records: *mut [ImportRecordList<'bump>], all_css_asts: *mut [CssCol], all_symbols: &'a symbol::Map, @@ -1876,63 +1959,92 @@ mod __css_validation { self.properties.clear_retaining_capacity(); } - fn visit(&mut self, idx: IndexInt, ast: &BundlerStyleSheet, r#ref: bun_ast::Ref) { - if self.visited.contains(&r#ref) { - return; - } - self.visited.put(r#ref, ()).expect("unreachable"); - - // This local name was in a style rule that - if let Some(composes) = ast.composes.get(&r#ref) { - for compose in composes.composes.slice_const() { - // is an import - if let Some(from) = compose.from.as_ref() { - if let Specifier::ImportRecordIndex(import_record_idx) = from { - let record = &col_ref!(self.all_import_records)[idx as usize] - .as_slice()[*import_record_idx as usize]; - if record.source_index.is_invalid() { - continue; + /// Frames only reference classes found in a CSS AST, so `idx` always has one. + fn css_ast(&self, idx: IndexInt) -> &'a BundlerStyleSheet { + col_ref!(self.all_css_asts)[idx as usize] + .as_deref() + .expect("composes walk only reaches files with a CSS AST") + } + + /// Records the properties of `root` and everything it transitively + /// composes, and warns when two files set the same property. Visits + /// in postorder, so a class's properties are recorded after those of + /// the classes it composes. + fn visit(&mut self, idx: IndexInt, root: bun_ast::Ref) { + debug_assert!(self.stack.is_empty()); + self.stack.push(Frame::Enter(idx, root)); + + while let Some(frame) = self.stack.pop() { + let (idx, r#ref) = match frame { + Frame::Leave(idx, r#ref) => { + self.record_properties(idx, r#ref); + continue; + } + Frame::Enter(idx, r#ref) => (idx, r#ref), + }; + + if self.visited.contains(&r#ref) { + continue; + } + self.visited.put(r#ref, ()).expect("unreachable"); + + let ast = self.css_ast(idx); + let mark = self.stack.len(); + // This local name was in a style rule that + if let Some(composes) = ast.composes.get(&r#ref) { + for compose in composes.composes.slice_const() { + // is an import + if let Some(from) = compose.from.as_ref() { + if let Specifier::ImportRecordIndex(import_record_idx) = from { + let record = &col_ref!(self.all_import_records)[idx as usize] + .as_slice() + [*import_record_idx as usize]; + if record.source_index.is_invalid() { + continue; + } + let other_idx = record.source_index.get(); + let Some(other_ast) = + col_ref!(self.all_css_asts)[other_idx as usize].as_deref() + else { + continue; + }; + for name in compose.names.slice() { + let name_v = name.v(); + let Some(other_name) = other_ast.local_scope.get(name_v) + else { + continue; + }; + self.stack.push(Frame::Enter( + other_idx, + other_name.ref_.to_real_ref(other_idx), + )); + } + } else { + debug_assert!(matches!(from, Specifier::Global)); + // Otherwise it is composed from the global scope. + // + // See comment above for why we are skipping checking this for now. } - // Read-only deref — recursion may revisit the - // same allocation as `ast`, so bind shared. - let Some(other_ast) = col_ref!(self.all_css_asts) - [record.source_index.get() as usize] - .as_deref() - else { - continue; - }; + } else { + // inside this file for name in compose.names.slice() { let name_v = name.v(); - let Some(other_name) = other_ast.local_scope.get(name_v) else { + let Some(name_entry) = ast.local_scope.get(name_v) else { continue; }; - let other_name_ref = - other_name.ref_.to_real_ref(record.source_index.get()); - self.visit( - record.source_index.get(), - other_ast, - other_name_ref, - ); + self.stack + .push(Frame::Enter(idx, name_entry.ref_.to_real_ref(idx))); } - } else { - debug_assert!(matches!(from, Specifier::Global)); - // Otherwise it is composed from the global scope. - // - // See comment above for why we are skipping checking this for now. - } - } else { - // inside this file - for name in compose.names.slice() { - let name_v = name.v(); - let Some(name_entry) = ast.local_scope.get(name_v) else { - continue; - }; - self.visit(idx, ast, name_entry.ref_.to_real_ref(idx)); } } } + self.stack.push(Frame::Leave(idx, r#ref)); + self.stack[mark..].reverse(); } + } + fn record_properties(&mut self, idx: IndexInt, r#ref: bun_ast::Ref) { + let ast = self.css_ast(idx); let Some(property_usage) = ast.local_properties.get(&r#ref) else { return; }; @@ -1972,6 +2084,7 @@ mod __css_validation { let mut visitor = Visitor { visited: ArrayHashMap::::default(), properties: StringArrayHashMap::::default(), + stack: Vec::new(), all_import_records: import_records_list, all_css_asts, all_symbols: &this.graph.symbols, @@ -1982,7 +2095,7 @@ mod __css_validation { }; for local in root_css_ast.local_scope.values() { visitor.clear_retaining_capacity(); - visitor.visit(index, root_css_ast, local.ref_.to_real_ref(index)); + visitor.visit(index, local.ref_.to_real_ref(index)); } } } diff --git a/src/bundler/linker_context/writeOutputFilesToDisk.rs b/src/bundler/linker_context/writeOutputFilesToDisk.rs index a039739f2752..82403fa9e140 100644 --- a/src/bundler/linker_context/writeOutputFilesToDisk.rs +++ b/src/bundler/linker_context/writeOutputFilesToDisk.rs @@ -25,8 +25,7 @@ use bun_sys::{ write_file_with_path_buffer, }; -/// Bytecode output file extension (also defined in `generateChunksInParallel.rs`). -const BYTECODE_EXTENSION: &str = ".jsc"; +use crate::bytecode_sidecar::EXTENSION as BYTECODE_EXTENSION; pub(crate) fn write_output_files_to_disk( c: &mut LinkerContext, @@ -402,6 +401,7 @@ pub(crate) fn write_output_files_to_disk( c.options.optimize_bytecode, None, ) { + let bytecode = crate::bytecode_sidecar::frame(bytecode); let source_provider_url_str = source_provider_url.to_utf8(); debug!( "Bytecode cache generated {}: {}", diff --git a/src/bundler/options.rs b/src/bundler/options.rs index ac49bb525032..60b2c4fd0058 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -1931,9 +1931,10 @@ impl<'a> BundleOptions<'a> { // Reborrow the raw `*mut Log` // for the duration of this call only. + let top_level_dir = fs.top_level_dir(); opts.external = init_external_modules( &mut fs.fs, - fs.top_level_dir, + top_level_dir, &transform .external .iter() @@ -2493,6 +2494,34 @@ impl PathTemplate { sanitize_parent_dirs, ) } + + /// Max output path length. A `PathBuffer` also needs room for a `.map` or `.jsc` suffix and the NUL. + pub(crate) const MAX_OUTPUT_PATH_LEN: usize = bun_paths::MAX_PATH_BYTES - 1 - ".map".len(); + + /// Logs an error and returns `false` when `output_path` exceeds `MAX_OUTPUT_PATH_LEN`. + pub(crate) fn check_output_path( + &self, + log: &mut bun_ast::Log, + input: &[u8], + output_path: &[u8], + ) -> bool { + if output_path.len() <= Self::MAX_OUTPUT_PATH_LEN { + return true; + } + log.add_range_error_fmt_with_note( + None, + bun_ast::Range::NONE, + format_args!( + "Output path for {} is too long ({} bytes, the limit on this platform is {})", + bun_core::fmt::quote(input), + output_path.len(), + Self::MAX_OUTPUT_PATH_LEN, + ), + format_args!("naming template is {}", bun_core::fmt::quote(&self.data)), + bun_ast::Range::NONE, + ); + false + } } #[derive(Debug, Clone, Default)] diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index a496e8e35ad5..e7dd5a0b4ef7 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -332,7 +332,7 @@ impl<'a> Transpiler<'a> { } fn _resolve_entry_point(&mut self, entry_point: &[u8]) -> crate::Result { - let top_level_dir = self.fs().top_level_dir; + let top_level_dir = self.fs().top_level_dir(); let first = match self.resolver.resolve_with_framework( top_level_dir, entry_point, @@ -388,7 +388,7 @@ impl<'a> Transpiler<'a> { // so compute `busted` directly instead. let busted: bool = 'name: { // Neither buster name below would fit `cache_bust_buf`. - if self.fs().top_level_dir.len() + entry_point.len() + 4 + if self.fs().top_level_dir().len() + entry_point.len() + 4 > bun_paths::MAX_PATH_BYTES { break 'name false; @@ -414,13 +414,15 @@ impl<'a> Transpiler<'a> { // `".."` needs no platform separator rewrite. let parts: [&[u8]; 2] = [entry_point, b".."]; - let top_level_dir = self.fs().top_level_dir; + let top_level_dir = self.fs().top_level_dir(); - let buster_name = bun_paths::resolve_path::join_abs_string_buf_z::< - bun_paths::platform::Auto, - >( - top_level_dir, &mut cache_bust_buf[..], &parts - ); + let Some(buster_name) = + bun_paths::resolve_path::join_abs_string_buf_z_checked::< + bun_paths::platform::Auto, + >(top_level_dir, &mut cache_bust_buf[..], &parts) + else { + break 'name false; + }; self.resolver.bust_dir_cache( bun_paths::string_paths::without_trailing_slash_windows_path( buster_name.as_bytes(), @@ -611,7 +613,7 @@ impl<'a> Transpiler<'a> { if auto_jsx { // Most of the time, this will already be cached - let top_level_dir = self.fs().top_level_dir; + let top_level_dir = self.fs().top_level_dir(); if let Ok(Some(root_dir)) = self.resolver.read_dir_info(top_level_dir) { if let Some(tsconfig) = self.resolver.tsconfig_json(&root_dir) { // If we don't explicitly pass JSX, try to get it from the root tsconfig @@ -668,7 +670,7 @@ impl<'a> Transpiler<'a> { // (or a parent) is unreadable, readDirInfo may return null; // bail out of .env file loading in that case, but process // env vars were already loaded above. - let top_level_dir = self.fs().top_level_dir; + let top_level_dir = self.fs().top_level_dir(); let dir_info = match self.resolver.read_dir_info(top_level_dir) { Ok(Some(d)) => d, _ => return Ok(()), @@ -1631,9 +1633,9 @@ impl<'a> Transpiler<'a> { | js_ast::AlreadyBundled::Bytecode => 'brk: { // When the parser // saw `// @bun @bytecode`, attempt to load the - // sidecar `.jsc` cached bytecode. Only - // fall back to re-parsing source on read - // failure / empty file. + // sidecar `.jsc` cached bytecode. Fall + // back to re-parsing source on read failure or + // when the sidecar fails verification. let is_cjs = matches!(already_bundled, js_ast::AlreadyBundled::BytecodeCjs); let default_value = if is_cjs { @@ -1644,9 +1646,8 @@ impl<'a> Transpiler<'a> { if this_parse.virtual_source.is_none() && this_parse.allow_bytecode_cache { - // No shared const for the bytecode extension - // in `bun_core` yet, so inline the literal. - const BYTECODE_EXT: &[u8] = b".jsc"; + const BYTECODE_EXT: &[u8] = + crate::bytecode_sidecar::EXTENSION.as_bytes(); let mut path_buf2 = bun_paths::path_buffer_pool::get(); let n = path.text.len(); let total = n + BYTECODE_EXT.len(); @@ -1668,7 +1669,13 @@ impl<'a> Transpiler<'a> { // `read_from` directly. let dir = dirname_fd.unwrap_valid().unwrap_or_else(FD::cwd); match bun_sys::File::read_from(dir, zpath) { - Ok(contents) if !contents.is_empty() => { + Ok(mut contents) => { + let Some(payload_len) = + crate::bytecode_sidecar::payload_len(&contents) + else { + break 'brk default_value; + }; + contents.truncate(payload_len); break 'brk if is_cjs { AlreadyBundled::BytecodeCjs( contents.into_boxed_slice(), @@ -1679,7 +1686,7 @@ impl<'a> Transpiler<'a> { ) }; } - _ => {} + Err(_) => {} } } default_value @@ -1874,15 +1881,13 @@ fn parse_data_loader<'a>( // also random-access `decls[prev]`. for i in 0..n { let prop = &mut properties[i]; - // SAFETY: data-format parsers always emit - // `e_string` keys. let key = prop.key.as_mut().unwrap(); let key_loc = key.loc; - let name: &[u8] = key - .data - .e_string_mut() - .expect("infallible: variant checked") - .slice(arena); + // Skip named exports for YAML or JSON5 keys that aren't strings. + let Some(key_string) = key.data.e_string_mut() else { + continue; + }; + let name: &[u8] = key_string.slice(arena); // Do not make named exports for "default" exports if name == b"default" { continue; @@ -1941,38 +1946,44 @@ fn parse_data_loader<'a>( } decls.truncate(count); - let stmt0 = bun_ast::Stmt::alloc( - bun_ast::S::Local { - decls: bun_ast::G::DeclList::move_from_list(decls), - kind: bun_ast::S::Kind::KVar, - ..Default::default() - }, - bun_ast::Loc { start: 0 }, - ); - let stmt1 = bun_ast::Stmt::alloc( - bun_ast::S::ExportClause { - items: bun_ast::StoreSlice::new_mut(&mut export_clauses[..count]), - is_single_line: false, - }, - bun_ast::Loc { start: 0 }, - ); - let stmt2 = bun_ast::Stmt::alloc( - bun_ast::S::ExportDefault { - value: bun_ast::StmtOrExpr::Expr(expr), - default_name: bun_ast::LocRef { - loc: bun_ast::Loc::default(), - ref_: bun_ast::Ref::NONE, + symbols.truncate(count); + + // `print_decls` aborts on a `var` with no declarations (`{"default": 1}`). + if count > 0 { + let stmt0 = bun_ast::Stmt::alloc( + bun_ast::S::Local { + decls: bun_ast::G::DeclList::move_from_list(decls), + kind: bun_ast::S::Kind::KVar, + ..Default::default() }, - }, - bun_ast::Loc { start: 0 }, - ); + bun_ast::Loc { start: 0 }, + ); + let stmt1 = bun_ast::Stmt::alloc( + bun_ast::S::ExportClause { + items: bun_ast::StoreSlice::new_mut(&mut export_clauses[..count]), + is_single_line: false, + }, + bun_ast::Loc { start: 0 }, + ); + let stmt2 = bun_ast::Stmt::alloc( + bun_ast::S::ExportDefault { + value: bun_ast::StmtOrExpr::Expr(expr), + default_name: bun_ast::LocRef { + loc: bun_ast::Loc::default(), + ref_: bun_ast::Ref::NONE, + }, + }, + bun_ast::Loc { start: 0 }, + ); - let stmts = - bun_ast::StoreSlice::new_mut(arena.alloc_slice_copy(&[stmt0, stmt1, stmt2])); - break 'parts Box::new([bun_ast::Part { - stmts, - ..Default::default() - }]); + let stmts = bun_ast::StoreSlice::new_mut( + arena.alloc_slice_copy(&[stmt0, stmt1, stmt2]), + ); + break 'parts Box::new([bun_ast::Part { + stmts, + ..Default::default() + }]); + } } } @@ -2561,7 +2572,7 @@ impl<'a> Transpiler<'a> { // snapshot entry points so the `&mut self` resolver call // does not conflict with the `&self.options` borrow. let entries: Vec> = self.options.entry_points.to_vec(); - let top_level_dir = self.fs().top_level_dir; + let top_level_dir = self.fs().top_level_dir(); for _entry in entries.iter() { let entry: &[u8] = if NORMALIZE_ENTRY_POINT { @@ -2796,7 +2807,7 @@ impl<'a> Transpiler<'a> { let mut file_path = Fs::Path::init(file_path_text); - let top_level_dir = self.fs().top_level_dir; + let top_level_dir = self.fs().top_level_dir(); let rel = bun_paths::resolve_path::relative(top_level_dir, file_path_text); file_path.pretty = crate::linker::dupe(rel); diff --git a/src/codegen/bundle-functions.ts b/src/codegen/bundle-functions.ts index 2bda8d231a18..9c991a68c09a 100644 --- a/src/codegen/bundle-functions.ts +++ b/src/codegen/bundle-functions.ts @@ -786,6 +786,7 @@ JSBuiltinInternalFunctions::JSBuiltinInternalFunctions(JSC::VM& vm) : m_vm(vm) } // namespace WebCore `; // Handle builtin names + let privateNames: string[]; { const BunBuiltinNamesHeader = require("fs").readFileSync( path.join(import.meta.dir, "../js/builtins/BunBuiltinNames.h"), @@ -831,6 +832,7 @@ JSBuiltinInternalFunctions::JSBuiltinInternalFunctions(JSC::VM& vm) : m_vm(vm) `; writeIfNotChanged(path.join(CODEGEN_DIR, "BunBuiltinNames+extras.h"), additionalPrivateNamesHeader); + privateNames = [...definedBuiltinNames, ...(additionalPrivateNames as Set)]; } writeIfNotChanged(path.join(CODEGEN_DIR, "WebCoreJSBuiltins.h"), bundledHeader); writeIfNotChanged(path.join(CODEGEN_DIR, "WebCoreJSBuiltins.cpp"), bundledCPP); @@ -872,6 +874,6 @@ JSBuiltinInternalFunctions::JSBuiltinInternalFunctions(JSC::VM& vm) : m_vm(vm) // bundle-modules.ts prepends this to InternalModuleRegistryConstants.bin so the // same linked blob carries both the builtin function sources and the internal - // module sources. - return { combinedSourceCode }; + // module sources. `privateNames` lists every private name registered by BunBuiltinNames. + return { combinedSourceCode, privateNames }; } diff --git a/src/codegen/bundle-modules.ts b/src/codegen/bundle-modules.ts index 3e3856badc68..490a18bbc6ed 100644 --- a/src/codegen/bundle-modules.ts +++ b/src/codegen/bundle-modules.ts @@ -16,7 +16,7 @@ import jsclasses from "./../jsc/bindings/js_classes"; import { sliceSourceCode } from "./builtin-parser"; import { createAssertClientJS, createLogClientJS } from "./client-js"; import { getJS2NativeCPP, getJS2NativeRust } from "./generate-js2native"; -import { cap, checkAscii, sourceStamp, writeIfNotChanged, writeIfNotChangedBinary } from "./helpers"; +import { cap, checkAscii, engineStamp, sourceStamp, writeIfNotChanged, writeIfNotChangedBinary } from "./helpers"; import { createInternalModuleRegistry } from "./internal-module-registry-scanner"; import { define } from "./replacements"; @@ -24,12 +24,15 @@ const BASE = path.join(import.meta.dir, "../js"); const debug = process.argv[2] === "--debug=ON"; const CMAKE_BUILD_ROOT = process.argv[3]; const TYPES_DIR = process.argv[4]; +const WEBKIT_VERSION = process.argv.find(arg => arg.startsWith("--webkit-version="))?.slice("--webkit-version=".length); const timeString = 'Bundled "src/js" for ' + (debug ? "development" : "production"); console.time(timeString); -if (!CMAKE_BUILD_ROOT || !TYPES_DIR) { - console.error("Usage: bun bundle-modules.ts --debug=[OFF|ON] "); +if (!CMAKE_BUILD_ROOT || !TYPES_DIR || !WEBKIT_VERSION) { + console.error( + "Usage: bun bundle-modules.ts --debug=[OFF|ON] --webkit-version=", + ); process.exit(1); } @@ -368,7 +371,7 @@ function idToPublicSpecifierOrEnumName(id: string) { return idToEnumName(id); } -const { combinedSourceCode: functionsSource } = await bundleBuiltinFunctions({ +const { combinedSourceCode: functionsSource, privateNames } = await bundleBuiltinFunctions({ typesDir: TYPES_DIR, requireTransformer, }); @@ -413,7 +416,7 @@ writeIfNotChanged( // // Layout (little-endian u32 unless noted; offsets in the index are relative to `data`): // header: magic[8] "BUNBLTNS", version, sourceStamp, moduleCount, modulesOffset, -// depOffsetsOffset, depsOffset, dataOffset, dataLength, 0, 0 +// depOffsetsOffset, depsOffset, dataOffset, dataLength, engineStamp, 0 // modules: moduleCount × { nameOffset, nameLength, urlOffset, urlLength, codeOffset, codeLength } // depOffsets: u16[moduleCount + 1], deps: u16[] (see internalModuleDependencyTable) // data: [builtin functions combined source][\0][module 0][\0][module 1][\0]...[name\0url\0]... @@ -421,7 +424,8 @@ writeIfNotChanged( // // A debug build's runtime reads the module sources from disk instead (BUN_DYNAMIC_JS_LOAD_PATH), but they are still // here so that a debug bun works as a `--compile` target like any other. -const BUILTINS_FORMAT_VERSION = 1; +// Bumped to 2 because bun 1.4.1 to 1.4.3 compile any version 1 section without checking engineStamp. +const BUILTINS_FORMAT_VERSION = 2; const BUILTINS_HEADER_SIZE = 48; // Identifies these module sources to bytecode generated from them ahead of time (bun build --compile embeds bytecode for @@ -504,7 +508,7 @@ let blobDataOffset: number; depsOffset, blobDataOffset, data.length, - 0, + engineStamp(WEBKIT_VERSION, privateNames), 0, ].forEach((v, i) => blob.writeUInt32LE(v >>> 0, 8 + i * 4)); records.forEach((v, i) => blob.writeUInt32LE(v, modulesOffset + i * 4)); @@ -562,7 +566,8 @@ struct Header { uint32_t depsOffset; uint32_t dataOffset; uint32_t dataLength; - uint32_t reserved[2]; + uint32_t engineStamp; + uint32_t reserved; }; static_assert(sizeof(Header) == ${BUILTINS_HEADER_SIZE}); diff --git a/src/codegen/helpers.ts b/src/codegen/helpers.ts index b62d20dfdaca..be62c3aca71e 100644 --- a/src/codegen/helpers.ts +++ b/src/codegen/helpers.ts @@ -51,6 +51,14 @@ export function sourceStamp(sources: Iterable): number { return hash.digest().readUInt32BE(0); } +/** + * Hashes the WebKit version and private names that a bun must share to compile the builtins section. + * Never returns 0, which means the section has no stamp. + */ +export function engineStamp(webkitVersion: string, privateNames: Iterable): number { + return sourceStamp([webkitVersion, ...[...privateNames].sort()].map(part => part + "\n")) || 1; +} + export function writeIfNotChanged(file: string, contents: string) { if (Array.isArray(contents)) contents = contents.join(""); contents = contents.replaceAll("\r\n", "\n").trim() + "\n"; diff --git a/src/collections/pool.rs b/src/collections/pool.rs index 1fe739052dc4..901dd3b1b8dd 100644 --- a/src/collections/pool.rs +++ b/src/collections/pool.rs @@ -51,6 +51,7 @@ impl Node { } } +/// Doesn't own its nodes, so dropping the list doesn't free them. pub struct SinglyLinkedList { // INTRUSIVE: list head; pop_first hands node to caller pub first: *mut Node, @@ -64,26 +65,6 @@ impl Default for SinglyLinkedList { } } -impl Drop for SinglyLinkedList { - fn drop(&mut self) { - // The free list owns its nodes (each `release()` hands ownership back). - // Without this, the TLS-backed pool's `DataStruct` strands every cached - // node when the thread exits. - let mut next = core::mem::replace(&mut self.first, ptr::null_mut()); - while !next.is_null() { - let node = next; - next = Node::next_of(node); - // SAFETY: free-list nodes always carry initialized `data` - // (`release()` only stores nodes that were used) and are - // exclusively owned by the list. - unsafe { - (*node).data.assume_init_drop(); - drop(bun_core::heap::take(node)); - } - } - } -} - impl SinglyLinkedList { /// Insert a new node at the head. /// @@ -154,6 +135,21 @@ impl Default for DataStruct { } } +impl Drop for DataStruct { + fn drop(&mut self) { + // Free the nodes still cached in the pool when its thread exits. + while let Some(node) = self.list.pop_first() { + // SAFETY: free-list nodes always carry initialized `data` + // (`release()` only stores nodes that were used), are heap boxes + // from `push`/`get_node`, and are exclusively owned by the pool. + unsafe { + (*node).data.assume_init_drop(); + drop(bun_core::heap::take(node)); + } + } + } +} + /// Object pool with a singly-linked free list. /// /// `THREADSAFE == true` ⇒ storage is thread-local (one free list per thread). @@ -606,28 +602,43 @@ mod tests { assert_eq!(unsafe { *(*node).data.assume_init_ref() }, expect); } assert!(list.pop_first().is_none()); - // Nothing was handed to the list's `Drop` (it is empty), so `nodes` - // still owns every allocation. - core::mem::forget(list); } - /// The list owns whatever is still on it at `Drop` — dropping it must run - /// each element's destructor exactly once and free each node. #[test] - fn singly_linked_list_drop_frees_nodes() { + fn singly_linked_list_drop_does_not_free_nodes() { let _serial = serial(); let before = drops(); + let mut node = Box::new(Node { + next: ptr::null_mut(), + data: MaybeUninit::new(Tracked(Box::new(7))), + }); let mut list: SinglyLinkedList = SinglyLinkedList::default(); + list.prepend(&mut node); + drop(list); + assert_eq!(drops(), before); + // SAFETY: `data` was initialized above and the list did not drop it. + unsafe { + assert_eq!(*node.data.assume_init_ref().0, 7); + node.data.assume_init_drop(); + } + assert_eq!(drops(), before + 1); + } + + #[test] + fn data_struct_drop_frees_nodes() { + let _serial = serial(); + let before = drops(); + let mut data: DataStruct = DataStruct::default(); for i in 0..3 { let node = bun_core::heap::into_raw(Box::new(Node { next: ptr::null_mut(), data: MaybeUninit::new(Tracked(Box::new(i))), })); // SAFETY: freshly allocated and exclusively owned; ownership moves - // to the list, whose `Drop` frees it. - list.prepend(unsafe { &mut *node }); + // to the pool storage, whose `Drop` frees it. + data.list.prepend(unsafe { &mut *node }); } - drop(list); + drop(data); assert_eq!(drops(), before + 3); } diff --git a/src/crash_handler/lib.rs b/src/crash_handler/lib.rs index 4ae24e90d6b9..b948ec7c3972 100644 --- a/src/crash_handler/lib.rs +++ b/src/crash_handler/lib.rs @@ -3647,24 +3647,23 @@ mod draft { ); } + /// Sets the thread's action to `Dlopen(path)`. `CrashHandler__endDlOpenAction` restores the previous action. /// # Safety - /// `action` must be null or a valid NUL-terminated C string that outlives the dlopen call. + /// `path` must be a valid NUL-terminated C string that outlives the returned guard. #[unsafe(no_mangle)] - unsafe extern "C" fn CrashHandler__setDlOpenAction(action: *const c_char) { - if !action.is_null() { - debug_assert!(CURRENT_ACTION.with(|c| c.get()).is_none()); - // SAFETY: action is a valid NUL-terminated C string for the duration of the dlopen call - let s = unsafe { bun_core::ffi::cstr(action) }.to_bytes(); - // SAFETY: noreturn-on-crash usage; the C string outlives the action via caller contract - let s: &'static [u8] = unsafe { bun_collections::detach_lifetime(s) }; - CURRENT_ACTION.with(|c| c.set(Some(Action::Dlopen(s)))); - } else { - debug_assert!(matches!( - CURRENT_ACTION.with(|c| c.get()), - Some(Action::Dlopen(_)) - )); - CURRENT_ACTION.with(|c| c.set(None)); - } + unsafe extern "C" fn CrashHandler__beginDlOpenAction(path: *const c_char) -> *mut ActionGuard { + // SAFETY: path is a valid NUL-terminated C string (caller contract) + let s = unsafe { bun_core::ffi::cstr(path) }.to_bytes(); + // SAFETY: noreturn-on-crash usage; the C string outlives the action via caller contract + let s: &'static [u8] = unsafe { bun_collections::detach_lifetime(s) }; + bun_core::heap::into_raw(Box::new(scoped_action(Action::Dlopen(s)))) + } + + /// SAFETY: `guard` must come from `CrashHandler__beginDlOpenAction`, on this thread, and must not be used again. + #[unsafe(no_mangle)] + unsafe extern "C" fn CrashHandler__endDlOpenAction(guard: *mut ActionGuard) { + // SAFETY: guard is a live Box from CrashHandler__beginDlOpenAction (caller contract) + unsafe { bun_core::heap::destroy(guard) }; } pub fn fix_dead_code_elimination() { diff --git a/src/css/css_parser.rs b/src/css/css_parser.rs index 02e1717da06f..54f041ba317d 100644 --- a/src/css/css_parser.rs +++ b/src/css/css_parser.rs @@ -2336,6 +2336,19 @@ impl StyleSheet { } } +/// Positions are `i32`, and a column at the end of the input is `len + 1`. +const MAX_INPUT_LEN: usize = i32::MAX as usize - 1; + +fn check_input_len(code: &[u8]) -> Maybe<(), Err> { + if code.len() > MAX_INPUT_LEN { + return Err(Err { + kind: ParserError::input_too_large, + loc: None, + }); + } + Ok(()) +} + // ── StyleSheet behavior (parse/minify/to_css) ──────────────────────────────── mod stylesheet_impl { use super::*; @@ -2567,6 +2580,7 @@ mod stylesheet_impl { // returned `StyleSheet`. // TODO(refactor): re-thread the lifetime through `CssRuleList<'bump, R>` // and drop the `'static` bound on `arena`. + check_input_len(code)?; let mut composes = ComposesMap::default(); let mut parser_extra = ParserExtra { local_scope: LocalScope::default(), @@ -2669,6 +2683,7 @@ mod stylesheet_impl { // TODO: 'bump lifetime threading — `DeclarationBlock<'static>` in // `StyleAttribute` vs `Parser<'a>` here; `arena: &'static Bump` // matches the crate-wide erasure (see `parse_with`). + check_input_len(code)?; let mut parser_extra = ParserExtra { local_scope: LocalScope::default(), symbols: SymbolList::default(), @@ -2982,6 +2997,8 @@ bitflags::bitflags! { #[derive(Clone, Copy, PartialEq, Eq, Default)] pub struct ParserOpts: u8 { const CSS_MODULES = 0b1; + /// Makes `TokenList::parse` keep `url()` as a raw token instead of creating an import record. + const RAW_URL_TOKENS = 0b10; } } impl ParserOpts { @@ -2989,6 +3006,11 @@ impl ParserOpts { pub(crate) fn css_modules(self) -> bool { self.contains(Self::CSS_MODULES) } + + #[inline] + pub(crate) fn raw_url_tokens(self) -> bool { + self.contains(Self::RAW_URL_TOKENS) + } } pub struct Parser<'a> { @@ -3231,6 +3253,17 @@ impl<'a> Parser<'a> { result } + pub(crate) fn parse_with_raw_url_tokens( + &mut self, + func: impl FnOnce(&mut Parser) -> CssResult, + ) -> CssResult { + let saved = self.flags; + self.flags.insert(ParserOpts::RAW_URL_TOKENS); + let result = func(self); + self.flags = saved; + result + } + #[inline] pub(crate) fn parse_nested_block( &mut self, @@ -3543,10 +3576,6 @@ impl<'a> Parser<'a> { } pub(crate) fn position(&self) -> usize { - debug_assert!(strings::is_on_char_boundary( - self.input.tokenizer.src, - self.input.tokenizer.position - )); self.input.tokenizer.position } @@ -3910,7 +3939,7 @@ pub mod nth { } else if strings::eql_case_insensitive_asciii_check_length(unit, b"n-") { return parse_signless_b(input, a, -1); } else { - if let Ok(b) = parse_n_dash_digits(unit) { + if let Ok(b) = parse_n_dash_digits(input.arena(), unit) { return Ok((a, b)); } else { return Err(input.new_unexpected_token_error(Token::Ident(unit))); @@ -3938,7 +3967,7 @@ pub mod nth { } else { (value, 1) }; - if let Ok(b) = parse_n_dash_digits(slice) { + if let Ok(b) = parse_n_dash_digits(input.arena(), slice) { return Ok((a, b)); } return Err(input.new_unexpected_token_error(Token::Ident(value))); @@ -3953,7 +3982,7 @@ pub mod nth { } else if strings::eql_case_insensitive_asciii_check_length(value, b"-n") { return parse_signless_b(input, 1, -1); } else { - if let Ok(b) = parse_n_dash_digits(value) { + if let Ok(b) = parse_n_dash_digits(input.arena(), value) { return Ok((1, b)); } else { return Err(input.new_unexpected_token_error(Token::Ident(value))); @@ -4008,21 +4037,20 @@ pub mod nth { Err(input.new_unexpected_token_error(tok)) } - fn parse_n_dash_digits(str: &[u8]) -> Maybe { + fn parse_n_dash_digits(arena: &Bump, str: &[u8]) -> Maybe { let bytes = str; if bytes.len() >= 3 && strings::eql_case_insensitive_asciii_check_length(&bytes[0..2], b"n-") && bytes[2..].iter().all(|&b| b >= b'0' && b <= b'9') { - parse_number_saturate(&str[1..]) // Include the minus sign + parse_number_saturate(arena, &str[1..]) // Include the minus sign } else { Err(()) } } - fn parse_number_saturate(string: &[u8]) -> Maybe { - let arena = Bump::new(); - let mut input = ParserInput::new(string, &arena); + fn parse_number_saturate(arena: &Bump, string: &[u8]) -> Maybe { + let mut input = ParserInput::new(string, arena); let mut parser = Parser::new(&mut input, None, ParserOpts::default(), None); let tok = match parser.next_including_whitespace_and_comments() { Ok(v) => v, @@ -4110,7 +4138,6 @@ impl<'a> Tokenizer<'a> { } pub(crate) fn get_position(&self) -> usize { - debug_assert!(strings::is_on_char_boundary(self.src, self.position)); self.position } diff --git a/src/css/error.rs b/src/css/error.rs index 70d16fa48773..e5fdc66d6d95 100644 --- a/src/css/error.rs +++ b/src/css/error.rs @@ -74,7 +74,8 @@ impl Err { data: bun_ast::Data { location: match &self.loc { Some(loc) => Some(loc.to_location(source)?), - None => None, + // An error with no location still reports the file name. + None => bun_ast::Location::init_or_null(Some(source), bun_ast::Range::NONE), }, text: text.into(), }, @@ -296,6 +297,7 @@ pub enum ParserError { expected: Str, received: Str, }, + input_too_large, } impl fmt::Display for ParserError { @@ -326,6 +328,7 @@ impl fmt::Display for ParserError { Self::unexpected_value { expected, received } => { write!(f, "Expected {}, received {}", bs(*expected), bs(*received)) } + Self::input_too_large => f.write_str("CSS file is too large to parse (2 GiB maximum)"), } } } diff --git a/src/css/media_query.rs b/src/css/media_query.rs index 2d1295755348..1258998f178e 100644 --- a/src/css/media_query.rs +++ b/src/css/media_query.rs @@ -1182,7 +1182,11 @@ impl MediaFeatureValue { // The real `ParserOptions` are threaded down from // `QueryFeature::parse`; `depth = 0` since this is a fresh value. - if let Ok(env) = input.try_parse(|i| EnvironmentVariable::parse(i, options, 0)) { + // Keep `url()` raw because the linker prints an `@import`'s media query with another file's + // import records. + if let Ok(env) = input.try_parse(|i| { + i.parse_with_raw_url_tokens(|i| EnvironmentVariable::parse(i, options, 0)) + }) { return Ok(MediaFeatureValue::Env(env)); } @@ -1249,17 +1253,6 @@ impl MediaList { Self { media_queries } } - /// `MediaList` carries no - /// `ImportRecord` indices so this is just `deep_clone`. - #[inline] - pub fn clone_with_import_records( - &self, - bump: &bun_alloc::Arena, - _import_records: &mut Vec, - ) -> Self { - self.deep_clone(bump) - } - /// Structural eq. #[inline] pub fn eql(&self, other: &Self) -> bool { diff --git a/src/css/properties/custom.rs b/src/css/properties/custom.rs index 92a4cc164e4e..28ff348c2397 100644 --- a/src/css/properties/custom.rs +++ b/src/css/properties/custom.rs @@ -608,7 +608,7 @@ impl TokenList { } } } - if strings::eql(*f, b"url") { + if strings::eql(*f, b"url") && !input.flags.raw_url_tokens() { input.reset(&state); tokens.push(TokenOrValue::Url(ext::url_parse(input)?)); last_is_delim = false; @@ -657,7 +657,7 @@ impl TokenList { last_is_whitespace = false; continue; } - Token::UnquotedUrl(_) => { + Token::UnquotedUrl(_) if !input.flags.raw_url_tokens() => { input.reset(&state); tokens.push(TokenOrValue::Url(ext::url_parse(input)?)); last_is_delim = false; diff --git a/src/css/rules/import.rs b/src/css/rules/import.rs index df95395a0989..a9e1fd5786ce 100644 --- a/src/css/rules/import.rs +++ b/src/css/rules/import.rs @@ -6,7 +6,6 @@ use crate::media_query::MediaList; use crate::{PrintErr, Printer}; use bun_alloc::Arena; -use bun_ast::ImportRecord; /// Layer slot used in both `ImportConditions.layer` and `ImportRule.layer`. /// The two uses are layout-identical, so a single type serves both. @@ -50,14 +49,6 @@ pub struct ImportConditions { } impl ImportConditions { - pub fn deep_clone(&self, bump: &Arena) -> Self { - Self { - layer: self.layer.as_ref().map(|l| l.deep_clone(bump)), - supports: self.supports.as_ref().map(|s| s.deep_clone(bump)), - media: super::dc::media_list(&self.media, bump), - } - } - pub fn has_anonymous_layer(&self) -> bool { matches!(&self.layer, Some(l) if l.v.is_none()) } @@ -154,6 +145,16 @@ impl ImportRule { } } + pub fn conditions(&self) -> &ImportConditions { + // SAFETY: both structs are `repr(C)`, and `layer`, `supports` and `media` are + // consecutive fields of `ImportRule` with the types and order of `ImportConditions`. + unsafe { + &*std::ptr::from_ref(self) + .byte_add(core::mem::offset_of!(Self, layer)) + .cast::() + } + } + pub fn conditions_mut(&mut self) -> &mut ImportConditions { // SAFETY: see `conditions()` above. Derived from `&mut self` (full-struct // provenance) via byte offset so the returned `&mut ImportConditions` may @@ -165,27 +166,6 @@ impl ImportRule { } } - /// The `import_records` here is preserved from esbuild in the case that we do need it, it doesn't seem necessary now - pub fn conditions_with_import_records( - &self, - arena: &Arena, - import_records: &mut Vec, - ) -> ImportConditions { - ImportConditions { - layer: self.layer.as_ref().map(|layer| Layer { - v: layer - .v - .as_ref() - .map(|l| l.clone_with_import_records(arena, import_records)), - }), - supports: self - .supports - .as_ref() - .map(|supp| supp.clone_with_import_records(arena, import_records)), - media: self.media.clone_with_import_records(arena, import_records), - } - } - pub fn has_conditions(&self) -> bool { self.layer.is_some() || self.supports.is_some() || !self.media.media_queries.is_empty() } diff --git a/src/css/rules/layer.rs b/src/css/rules/layer.rs index 38bc69ba6080..08b773d1de04 100644 --- a/src/css/rules/layer.rs +++ b/src/css/rules/layer.rs @@ -1,7 +1,6 @@ use core::fmt; use bun_alloc::Arena; -use bun_ast::ImportRecord; use crate as css; use crate::css_rules::{CssRuleList, Location}; @@ -21,7 +20,7 @@ pub struct LayerName { // Trait `Clone` (not just inherent `deep_clone`) so the bundler's // `Chunk::Layers::to_owned` can `deep_clone_with(|l| l.clone())`. Segments are // arena-borrowed `&'static [u8]` (Copy), so this is the same shallow -// `SmallList` copy as `deep_clone` / `clone_with_import_records`. +// `SmallList` copy as `deep_clone`. impl Clone for LayerName { fn clone(&self) -> Self { LayerName { v: self.v.clone() } @@ -29,25 +28,6 @@ impl Clone for LayerName { } impl LayerName { - pub(crate) fn clone_with_import_records( - &self, - bump: &Arena, - _: &mut Vec, - ) -> Self { - // Segments are arena-borrowed, not owned, so this is a shallow - // `SmallList` copy. No import records to rewrite — layer names - // contain no URLs. - // - // Allocate the segment-pointer slab from `bump` (not the global - // allocator): callers store the result in arena-owned `ImportConditions` - // / `BundlerCssRule` slabs whose elements are never `Drop`'d (the - // bundler `mem::forget`s / `set_len(0)`s them at chunk teardown), so a - // global heap spill here would leak. - LayerName { - v: SmallList::from_arena_iter(bump, self.v.slice().iter().copied()), - } - } - pub fn eql(&self, rhs: &LayerName) -> bool { if self.v.len() != rhs.v.len() { return false; @@ -98,8 +78,7 @@ impl LayerName { } pub(crate) fn deep_clone(&self, _bump: &Arena) -> Self { - // Segments are arena-owned (identity copy). Same body as - // `clone_with_import_records` above. + // Segments are arena-owned (identity copy). LayerName { v: self.v.clone() } } } diff --git a/src/css/rules/supports.rs b/src/css/rules/supports.rs index 01f6d3cb187d..04c1d052d5e2 100644 --- a/src/css/rules/supports.rs +++ b/src/css/rules/supports.rs @@ -62,14 +62,6 @@ impl Declaration { } impl SupportsCondition { - pub fn clone_with_import_records( - &self, - bump: &bun_alloc::Arena, - _: &mut Vec, - ) -> Self { - self.deep_clone(bump) - } - pub(crate) fn deep_clone(&self, bump: &bun_alloc::Arena) -> SupportsCondition { // Hand-rolled variant-walk — // `#[derive(DeepClone)]` can't be used while `Selector`/`Unknown` diff --git a/src/dotenv/env_loader.rs b/src/dotenv/env_loader.rs index dc1e5546d9b9..8f3bac3683f6 100644 --- a/src/dotenv/env_loader.rs +++ b/src/dotenv/env_loader.rs @@ -162,6 +162,18 @@ static NODE_PATH_TO_USE_SET_ONCE: bun_core::RwLock>> = bun_core // PORTING.md §Concurrency: OnceLock — set once from CLI flag, read many. pub static HAS_NO_CLEAR_SCREEN_CLI_FLAG: OnceLock = OnceLock::new(); +/// Compares like [`HashTable`] keys, which are ASCII case-insensitive on Windows. +fn is_ipc_channel_key(key: &[u8]) -> bool { + const KEYS: [&[u8]; 2] = [b"NODE_CHANNEL_FD", b"NODE_CHANNEL_SERIALIZATION_MODE"]; + KEYS.iter().any(|&k| { + if cfg!(windows) { + strings::eql_case_insensitive_ascii(key, k, true) + } else { + strings::eql(key, k) + } + }) +} + impl Loader { /// Shared "empty-ish" predicate for proxy env vars: an unset/empty value, /// or a literal empty-quote pair left over from shell `export FOO=""` / @@ -555,6 +567,20 @@ impl Loader { }) } + /// Clones the loader for a macro VM. The clone remembers which files `self` loaded, so it only + /// reads the ones `self` skipped. + pub fn clone_loaded(&self) -> Result { + Ok(Loader { + map: self.map.clone_with_allocator()?, + default_files_loaded: self.default_files_loaded, + custom_files_loaded: self.custom_files_loaded.clone()?, + quiet: self.quiet, + did_load_process: self.did_load_process, + reject_unauthorized: Cell::new(None), + aws_credentials: None, + }) + } + pub fn load_process(&mut self) -> Result<(), AllocError> { if self.did_load_process { return Ok(()); @@ -569,6 +595,11 @@ impl Loader { let key = &env[..i as usize]; let value = &env[i as usize + 1..]; if !key.is_empty() { + // Child processes don't inherit the IPC fd. `bun run` passes the fd on and + // adds these variables back. + if is_ipc_channel_key(key) { + continue; + } self.map.put(key, value)?; } } else { diff --git a/src/event_loop/ConcurrentTask.rs b/src/event_loop/ConcurrentTask.rs index b08674e5eaa3..45b518c2e49a 100644 --- a/src/event_loop/ConcurrentTask.rs +++ b/src/event_loop/ConcurrentTask.rs @@ -139,6 +139,9 @@ pub mod task_tag { ShellAsyncCpTask, StreamPending, ThreadSafeFunction, + UvDnsPollFree, // a closed c-ares uv_poll_t (windows) + UvHandleClose, // an addon's uv_close (posix) + UvLoopDispatch, // an addon's uv_async_send (posix) ValkeyDeferredClose, ValkeyDeferredFailure, WindowsNamedPipeContext, diff --git a/src/event_loop/EventLoopTimer.rs b/src/event_loop/EventLoopTimer.rs index 2684be327e75..246c76c4c6da 100644 --- a/src/event_loop/EventLoopTimer.rs +++ b/src/event_loop/EventLoopTimer.rs @@ -209,6 +209,8 @@ pub enum Tag { CronJob, GcRepeating, QuicEndpoint, + /// An N-API addon's `uv_timer_t` (POSIX, where Bun emulates libuv). + UvTimer, } impl Tag { @@ -301,8 +303,7 @@ pub enum Kind { } impl Kind { - /// Widen to the `u32`-repr [`KindBig`] used in [`ID`](Timer::ID) so the - /// `{i32, u32}` pair `bitcast`s to a `u64` async-id. + /// Returns the [`KindBig`] that [`ID`](Timer::ID) packs into the `u64` async-id. #[inline] pub fn big(self) -> KindBig { match self { @@ -313,8 +314,7 @@ impl Kind { } } -/// Same variants as [`Kind`] but `#[repr(u32)]` so `ID { i32, KindBig }` -/// is exactly one pointer / `u64`. +/// Same variants as [`Kind`]. Stored in the two low bits of the `u64` async-id. #[repr(u32)] #[derive(Copy, Clone, PartialEq, Eq)] pub enum KindBig { diff --git a/src/exe_format/builtins.rs b/src/exe_format/builtins.rs index 095b310c8dfd..fa3f1babd7dc 100644 --- a/src/exe_format/builtins.rs +++ b/src/exe_format/builtins.rs @@ -18,7 +18,7 @@ use crate::pe::{DOSHeader, PEHeader, SectionHeader}; use crate::read_struct; const MAGIC: &[u8; 8] = b"BUNBLTNS"; -const FORMAT_VERSION: u32 = 1; +const FORMAT_VERSION: u32 = 2; const HEADER_SIZE: usize = 48; const RECORD_SIZE: usize = 6 * 4; @@ -40,6 +40,9 @@ pub enum BuiltinsError { Invalid, #[error("UnsupportedBuiltinsVersion")] UnsupportedVersion, + /// The sources were built for a different JavaScriptCore version or set of private names than this bun's. + #[error("BuiltinsEngineMismatch")] + EngineMismatch, } #[derive(Clone, Copy)] @@ -57,6 +60,8 @@ pub struct Module<'a> { pub struct Builtins<'a> { /// Identifies these sources to bytecode generated from them (JSC's `decodeBuiltinFunction` stamp). pub source_stamp: u32, + /// Identifies the JavaScriptCore version and private names of the bun that wrote the section (`engineStamp`). + engine_stamp: Option, count: u32, records: &'a [u8], dep_offsets: &'a [u8], @@ -82,6 +87,7 @@ impl<'a> Builtins<'a> { let deps_offset = field(5) as usize; let data_offset = field(6) as usize; let data_len = field(7) as usize; + let engine_stamp = core::num::NonZeroU32::new(field(8)); let n = count as usize; let records = sub( @@ -120,6 +126,7 @@ impl<'a> Builtins<'a> { Ok(Self { source_stamp, + engine_stamp, count, records, dep_offsets, @@ -128,6 +135,15 @@ impl<'a> Builtins<'a> { }) } + /// Returns `self` if the bun that owns the `host` section can compile these sources. + pub fn require_same_engine(self, host: &Builtins<'_>) -> Result { + if self.engine_stamp.is_some() && self.engine_stamp == host.engine_stamp { + Ok(self) + } else { + Err(BuiltinsError::EngineMismatch) + } + } + pub fn module(&self, id: u32) -> Option> { if id >= self.count { return None; diff --git a/src/glob/matcher.rs b/src/glob/matcher.rs index 5f1aa98c0bc6..c53c029392ce 100644 --- a/src/glob/matcher.rs +++ b/src/glob/matcher.rs @@ -3391,6 +3391,8 @@ fn take_fixed_choice( } = brackets_at(glob, open as usize)?; let is_separator = |it: u8| it == b'/' || is_windows && it == b'\\'; let rest = &path[path_index as usize..]; + // Each set is a u64 with one bit per byte offset from 0 to 63, so a token can only match within the first 63 bytes. + let window = &rest[..rest.len().min(u64::BITS as usize - 1)]; let mut levels = [Level::default(); 4]; let mut depth = 0; levels[0] = Level { @@ -3431,7 +3433,10 @@ fn take_fixed_choice( while from != 0 { let len = from.trailing_zeros() as usize; from &= from - 1; - let Some(&found) = rest.get(len) else { + let Some(&found) = window.get(len) else { + if len < rest.len() { + return None; + } continue; }; let is_same = match ch { @@ -3442,7 +3447,7 @@ fn take_fixed_choice( _ => found == ch, }; if is_same { - to |= 2_u64.checked_shl(len as u32)?; + to |= 2_u64 << len; } } ends = to; diff --git a/src/http/compress_body.rs b/src/http/compress_body.rs index 0e1e1ecd18be..2d4cc829bd86 100644 --- a/src/http/compress_body.rs +++ b/src/http/compress_body.rs @@ -84,7 +84,8 @@ pub(crate) fn compress_into( } /// libdeflate one-shot fast path into `state.shared_buffer`. Returns `None` -/// when the bound exceeds the shared buffer or no compressor can be allocated — caller falls back to +/// when the bound exceeds the shared buffer, the compressor can't be allocated, +/// or the output doesn't fit. The caller falls back to /// [`compress_zlib_streaming`]. fn compress_libdeflate_fast( state: &mut LibdeflateState, @@ -92,7 +93,7 @@ fn compress_libdeflate_fast( enc: bun_libdeflate_sys::libdeflate::Encoding, level: Option, ) -> Option { - use bun_libdeflate_sys::libdeflate::{Compressor, OwnedCompressor}; + use bun_libdeflate_sys::libdeflate::{Compressor, OwnedCompressor, Status}; // Split-borrow so the compressor handle and `shared_buffer` can be used // together. @@ -120,7 +121,8 @@ fn compress_libdeflate_fast( _ => cached, }; - Some(compressor.compress(input, shared_buffer, enc).written) + let result = compressor.compress(input, shared_buffer, enc); + (result.status == Status::Success).then_some(result.written) } /// Slow path for gzip/deflate when the libdeflate one-shot bound exceeds the diff --git a/src/http_jsc/headers_jsc.rs b/src/http_jsc/headers_jsc.rs index 5f3229505048..2568bb1e5efa 100644 --- a/src/http_jsc/headers_jsc.rs +++ b/src/http_jsc/headers_jsc.rs @@ -5,7 +5,17 @@ use core::sync::atomic::Ordering; use bun_http::Headers; use bun_http::headers::{EntryList, api}; -use bun_jsc::{CallFrame, FetchHeaders, HTTPHeaderName, JSGlobalObject, JSValue, JsResult}; +use bun_jsc::{ + CallFrame, FetchHeaders, HTTPHeaderName, JSGlobalObject, JSValue, JsError, JsResult, +}; + +/// `Headers` indexes its buffer with `u32` `StringPointer`s, so the total size can't exceed `u32::MAX`. +fn throw_headers_too_large(global: &JSGlobalObject) -> JsError { + global.throw_value(global.create_range_error_instance(format_args!( + "Headers exceed the maximum total size of {} bytes", + u32::MAX + ))) +} /// Moved up from `bun_http` so it can /// name `FetchHeaders` directly instead of dispatching through a vtable. @@ -14,34 +24,35 @@ use bun_jsc::{CallFrame, FetchHeaders, HTTPHeaderName, JSGlobalObject, JSValue, /// content-type (callers gate on `has_content_type_from_user()` before passing /// `content_type()`); `None` means no body or no user-set content-type. pub fn from_fetch_headers( + global: &JSGlobalObject, fetch_headers: Option<&FetchHeaders>, body_content_type: Option<&[u8]>, -) -> Headers { - // `FetchHeaders::{count,fast_has_,copy_to}` take `&mut self` but - // are read-only FFI shims; cast through `*mut` (matching the prior - // `link_interface!` impl which did `from_ref(h).cast_mut()`). - let h_ptr: Option<*mut FetchHeaders> = fetch_headers.map(|h| core::ptr::from_ref(h).cast_mut()); - - let mut header_count: u32 = 0; - let mut buf_len: u32 = 0; - if let Some(h) = h_ptr { - // SAFETY: `h` is a valid `&FetchHeaders` for the call; FFI is read-only. - unsafe { (*h).count(&mut header_count, &mut buf_len) }; - } +) -> JsResult { + let (fetch_header_count, buf_len_before_content_type) = + match fetch_headers.map(FetchHeaders::count) { + None => (0, 0), + Some(Some(counts)) => counts, + Some(None) => return Err(throw_headers_too_large(global)), + }; + let (mut header_count, mut buf_len) = (fetch_header_count, buf_len_before_content_type); let mut headers = Headers { entries: EntryList::default(), buf: Vec::new(), }; - let buf_len_before_content_type = buf_len; let needs_content_type = 'brk: { if let Some(body_ct) = body_content_type { - // SAFETY: see `count` above. - let has_ct_header = h_ptr - .map(|h| unsafe { (*h).fast_has_(HTTPHeaderName::ContentType as u8) }) - .unwrap_or(false); + let has_ct_header = + fetch_headers.is_some_and(|h| h.fast_has_(HTTPHeaderName::ContentType as u8)); if !has_ct_header { - header_count += 1; - buf_len += u32::try_from(body_ct.len() + b"Content-Type".len()).unwrap(); + let Some((new_count, new_len)) = header_count.checked_add(1).zip( + u32::try_from(b"Content-Type".len() + body_ct.len()) + .ok() + .and_then(|ct_len| buf_len.checked_add(ct_len)), + ) else { + return Err(throw_headers_too_large(global)); + }; + header_count = new_count; + buf_len = new_len; break 'brk true; } } @@ -76,9 +87,19 @@ pub fn from_fetch_headers( core::ptr::write_bytes(names_ptr, 0, header_count as usize); core::ptr::write_bytes(values_ptr, 0, header_count as usize); } - if let Some(h) = h_ptr { - // SAFETY: `h` is a valid `&FetchHeaders` for the call; columns sized by `count` above. - unsafe { (*h).copy_to(names_ptr, values_ptr, headers.buf.as_mut_ptr()) }; + if let Some(h) = fetch_headers { + // SAFETY: each column has `header_count >= fetch_header_count` zeroed, unborrowed slots. + let (names, values) = unsafe { + ( + core::slice::from_raw_parts_mut(names_ptr, fetch_header_count as usize), + core::slice::from_raw_parts_mut(values_ptr, fetch_header_count as usize), + ) + }; + h.copy_to( + names, + values, + &mut headers.buf[..buf_len_before_content_type as usize], + ); } // TODO: maybe we should send Content-Type header first instead of last? @@ -106,7 +127,7 @@ pub fn from_fetch_headers( } } - headers + Ok(headers) } struct H2TestingAPIs; diff --git a/src/http_jsc/websocket_client.rs b/src/http_jsc/websocket_client.rs index 285ac726e8c7..d2644d51c009 100644 --- a/src/http_jsc/websocket_client.rs +++ b/src/http_jsc/websocket_client.rs @@ -2537,14 +2537,14 @@ impl Copy<'_> { match self { Copy::Utf16(utf16) => { let encoded = strings::copy_utf16_into_utf8_impl::(parts.payload, utf16); - debug_assert_eq!(encoded.written as usize, content_byte_len); - debug_assert_eq!(encoded.read as usize, utf16.len()); + debug_assert_eq!(encoded.written, content_byte_len); + debug_assert_eq!(encoded.read, utf16.len()); } Copy::Latin1(latin1) => { let encoded = strings::copy_latin1_into_utf8(parts.payload, latin1); - debug_assert_eq!(encoded.written as usize, content_byte_len); + debug_assert_eq!(encoded.written, content_byte_len); // latin1 can contain non-ascii - debug_assert_eq!(encoded.read as usize, latin1.len()); + debug_assert_eq!(encoded.read, latin1.len()); } Copy::Bytes(bytes) => { match parts.mask { diff --git a/src/ini/lib.rs b/src/ini/lib.rs index e0f1e6b280f2..e8a9132730b3 100644 --- a/src/ini/lib.rs +++ b/src/ini/lib.rs @@ -1096,7 +1096,16 @@ mod draft { npmrc_path, bun_ast::ToSourceOptions { convert_bom: true }, ) { - Ok(source) => sources.push(source), + Ok(source) => { + if source + .check_parseable_len(&mut log, "INI document") + .is_err() + { + report_log(&mut log, diagnostics); + continue; + } + sources.push(source); + } Err(_) => continue, } diff --git a/src/install/PackageInstall.rs b/src/install/PackageInstall.rs index 99b75428a4bc..f8862a62610e 100644 --- a/src/install/PackageInstall.rs +++ b/src/install/PackageInstall.rs @@ -2305,7 +2305,7 @@ impl<'a> PackageInstall<'a> { } let res = strings::copy_utf16_into_utf8(&mut dest_buf[..], &wbuf[..i]); - let mut offset: usize = res.written as usize; + let mut offset: usize = res.written; if dest_buf[offset - 1] != bun_paths::SEP_WINDOWS { dest_buf[offset] = bun_paths::SEP_WINDOWS; offset += 1; diff --git a/src/install/PackageInstaller.rs b/src/install/PackageInstaller.rs index 4bf7d6807312..0cb992357bf4 100644 --- a/src/install/PackageInstaller.rs +++ b/src/install/PackageInstaller.rs @@ -206,6 +206,8 @@ impl DeferredLinks { } } +const NAME_TOO_LONG: crate::Error = crate::Error::Sys(bun_errno::SystemErrno::ENAMETOOLONG); + /// A cheaper `join_z_buf` for two paths that are already normalized. A warm install calls this for /// every package. fn join_normalized_z<'b>(buf: &'b mut [u8], folder: &[u8], file_path: &[u8]) -> &'b ZStr { @@ -351,12 +353,19 @@ impl NodeModulesFolder { res.map_err(|e| e.to_zig_err().into()) } + #[cfg(unix)] + fn path_z<'b>(&self, buf: &'b mut PathBuffer) -> crate::Result<&'b ZStr> { + if self.path.len() >= MAX_PATH_BYTES { + return Err(NAME_TOO_LONG); + } + Ok(bun_paths::resolve_path::z(self.path.as_slice(), buf)) + } + pub(crate) fn open_dir(&self, root: &Dir) -> crate::Result { #[cfg(unix)] { - // Copy into a NUL-terminated PathBuffer. let mut path_buf = bun_paths::path_buffer_pool::get(); - let path_z = bun_paths::resolve_path::z(self.path.as_slice(), &mut path_buf); + let path_z = self.path_z(&mut path_buf)?; return root .open_at_with(path_z.as_bytes(), 0) .map_err(|e| e.to_zig_err().into()); @@ -385,7 +394,7 @@ impl NodeModulesFolder { return Ok(root.make_open_path(self.path.as_slice(), Default::default())?); } let mut path_buf = bun_paths::path_buffer_pool::get(); - let path = bun_paths::resolve_path::z(self.path.as_slice(), &mut path_buf); + let path = self.path_z(&mut path_buf)?; Ok(package_install::make_open_real_dir(root, path)?) } @@ -499,7 +508,7 @@ fn abs_node_modules_path( lockfile: &Lockfile, string_buf: &[u8], tree_id: lockfile::tree::Id, -) -> AbsPath { +) -> Option { let mut rel_buf = bun_paths::path_buffer_pool::get(); rel_buf[..b"node_modules".len()].copy_from_slice(b"node_modules"); let mut depth_buf = lockfile::tree::depth_buf_uninit(); @@ -515,8 +524,8 @@ fn abs_node_modules_path( &mut depth_buf, ); let mut abs = AbsPath::init_top_level_dir(); - abs.append(rel.as_bytes()).unwrap_or_oom(); - abs + abs.try_append(rel.as_bytes()).ok()?; + Some(abs) } /// For a workspace or a `link:` package, this only removes the link. @@ -525,8 +534,10 @@ pub(crate) fn remove_placed_package( tree_id: lockfile::tree::Id, alias: &[u8], ) { - let path = abs_node_modules_path(lockfile, lockfile.buffers.string_bytes.as_slice(), tree_id); - let Ok(node_modules) = Dir::open(path.slice()) else { + let string_buf = lockfile.buffers.string_bytes.as_slice(); + let Some(Ok(node_modules)) = + abs_node_modules_path(lockfile, string_buf, tree_id).map(|path| Dir::open(path.slice())) + else { return; }; // Rename first: a delete can stop midway, and a half-deleted package must not pass `verify`. @@ -814,11 +825,11 @@ impl<'a> PackageInstaller<'a> { defer_this_bin = true; break 'native_binlink_optimization; } - target_node_modules_path_opt = Some(abs_node_modules_path( - lockfile, - string_buf, - target_tree_id, - )); + target_node_modules_path_opt = + abs_node_modules_path(lockfile, string_buf, target_tree_id); + if target_node_modules_path_opt.is_none() { + break 'native_binlink_optimization; + } } let replacement_name = @@ -1644,6 +1655,15 @@ impl<'a> PackageInstaller<'a> { } } + /// The absolute path of `alias` in the current tree's `node_modules`. + fn package_path(&self, alias: &[u8]) -> crate::Result { + let mut path = AutoAbsPath::init(); + path.try_append(self.node_modules.path.as_slice()) + .and_then(|()| path.try_append(alias)) + .map_err(|_| NAME_TOO_LONG)?; + Ok(path) + } + fn get_installed_package_scripts_count( &mut self, alias: &[u8], @@ -1883,10 +1903,19 @@ impl<'a> PackageInstaller<'a> { // transitive folder dependencies are not hoisted let is_transitive = !is_workspace && !self.lockfile().is_workspace_tree_id(self.current_tree_id); + if folder.len() >= self.folder_path_buf.len() { + if log_level != Options::LogLevel::Silent { + Output::err( + "ENAMETOOLONG", + "folder path for package {} is too long", + (bstr::BStr::new(pkg_name.slice(string_buf!())),), + ); + } + return self.fail(is_pending_package_install, log_level); + } if is_transitive - && (folder.len() >= self.folder_path_buf.len() - || (bin::bin_target_escapes_package_dir(folder) - && !self.lockfile().is_trusted_folder_dependency(dependency_id))) + && bin::bin_target_escapes_package_dir(folder) + && !self.lockfile().is_trusted_folder_dependency(dependency_id) { if log_level != Options::LogLevel::Silent { bun_core::pretty_errorln!( @@ -2486,18 +2515,17 @@ impl<'a> PackageInstaller<'a> { && self.metas[package_id as usize].has_install_script() { // `hasInstallScript` is a false positive when binding.gyp is not published. - let mut folder_path = - AutoAbsPath::from(self.node_modules.path.as_slice()).unwrap_or_oom(); - folder_path - .append(alias.slice(string_buf!())) - .unwrap_or_oom(); - - let count = self.get_installed_package_scripts_count( - alias.slice(string_buf!()), - package_id, - resolution.tag, - &mut folder_path, - log_level, + let count = self.package_path(alias.slice(string_buf!())).map_or( + 0, + |mut folder_path| { + self.get_installed_package_scripts_count( + alias.slice(string_buf!()), + package_id, + resolution.tag, + &mut folder_path, + log_level, + ) + }, ); if count > 0 { if log_level.is_verbose() { @@ -2640,8 +2668,13 @@ impl<'a> PackageInstaller<'a> { let (alias, alias_hash) = (dep.name, dep.name_hash); let pkg_name_hash = self.pkg_name_hashes[package_id as usize]; - let mut folder_path = AutoAbsPath::from(self.node_modules.path.as_slice()).unwrap_or_oom(); - folder_path.append(alias.slice(string_buf)).unwrap_or_oom(); + let mut folder_path = match self.package_path(alias.slice(string_buf)) { + Ok(folder_path) => folder_path, + Err(err) => { + let name = alias.slice(string_buf); + return self.on_scripts_error(log_level, "enqueue lifecycle", name, err); + } + }; if self .manager() diff --git a/src/install/PackageManager/PackageManagerEnqueue.rs b/src/install/PackageManager/PackageManagerEnqueue.rs index 09fab00e732d..97e5ca021193 100644 --- a/src/install/PackageManager/PackageManagerEnqueue.rs +++ b/src/install/PackageManager/PackageManagerEnqueue.rs @@ -1305,7 +1305,9 @@ fn look_ahead_from_project_folder(this: &mut PackageManager, id: DependencyID, a }; let path = this.lockfile.str_detached(&path); let mut buf = bun_paths::path_buffer_pool::get(); - let abs = project_path(path, &mut buf[..]); + let Some(abs) = project_path(path, &mut buf[..]) else { + return; + }; if let Some(listed) = FolderResolution::listed_dependencies(asked.version.tag, abs, this) { let locked = names_locked_for_folder(this, &listed); look_ahead_from(this, &listed, &locked); @@ -2878,16 +2880,14 @@ fn enqueue_local_tarball( // can be reallocated concurrently by the main thread while processing // other dependencies (e.g. `appendPackage` / `StringBuilder.allocate` // in `Package.fromNPM`). - let mut abs_buf = bun_paths::path_buffer_pool::get(); - let (tarball_path, normalize): (&[u8], bool) = + let (tarball_path, normalize) = match local_tarball_base_dir(&this.lockfile, dependency_id, path) { - None => (path, true), + None => (stored_string(path), true), Some(base_dir) => ( - Path::resolve_path::join_abs_string_buf::( + stored_string(Path::resolve_path::join_abs_string::( FileSystem::instance().top_level_dir(), - &mut abs_buf, &[base_dir, path], - ), + )), false, ), }; @@ -2912,7 +2912,7 @@ fn enqueue_local_tarball( in_trusted_dependencies: false, github_resolved: StringOrTinyString::init(b""), }, - tarball_path: stored_string(tarball_path), + tarball_path, normalize, }), }, @@ -3917,16 +3917,19 @@ fn resolve_project_folder( // `get_or_put` copies the path into the lockfile string buffer before any other mutation. let path = this.lockfile.str_detached(&path); let mut buf = bun_paths::path_buffer_pool::get(); - let abs = project_path(path, &mut buf[..]); + let Some(abs) = project_path(path, &mut buf[..]) else { + return FolderResolutionValue::Err(crate::Error::Sys(bun_errno::SystemErrno::ENAMETOOLONG)); + }; FolderResolution::get_or_put(GlobalOrRelative::Relative(version.tag), version, abs, this) } -/// Returns `path` as an absolute path. A relative path is relative to the project. -fn project_path<'a>(path: &'a [u8], buf: &'a mut [u8]) -> &'a [u8] { +/// Returns `path` as an absolute path, or `None` if it's too long for `buf`. Relative paths are +/// resolved against the project root. +fn project_path<'a>(path: &'a [u8], buf: &'a mut [u8]) -> Option<&'a [u8]> { if bun_paths::is_absolute(path) { - return path; + return (path.len() < buf.len()).then_some(path); } - Path::resolve_path::join_abs_string_buf::( + Path::resolve_path::join_abs_string_buf_checked::( FileSystem::instance().top_level_dir(), buf, &[path], diff --git a/src/install/PackageManager/workspace_selection.rs b/src/install/PackageManager/workspace_selection.rs index da527d91dc05..b44433dbded5 100644 --- a/src/install/PackageManager/workspace_selection.rs +++ b/src/install/PackageManager/workspace_selection.rs @@ -5,9 +5,7 @@ use bun_collections::{DynamicBitSet, HashMap, index_sort}; use bun_core::{Global, Output, UnwrapOrOom as _, strings}; use bun_glob::Prepared; use bun_paths::path_buffer_pool; -use bun_paths::resolve_path::{ - join_abs_string_buf, join_abs_string_buf_checked, platform, platform_to_posix_in_place, -}; +use bun_paths::resolve_path::{join_abs_string_buf_checked, platform, platform_to_posix_in_place}; use crate::bun_fs::FileSystem; use crate::dependency::Behavior; @@ -141,14 +139,13 @@ fn parse(raw: &[u8], original_cwd: &[u8], path_buf: &mut [u8]) -> Selector { } let resolve = |part: &[u8], path_buf: &mut [u8]| -> Prepared> { - Prepared::new( - strings::without_trailing_slash(join_abs_string_buf::( - original_cwd, - path_buf, - &[part], - )) - .into(), - ) + let Some(joined) = + join_abs_string_buf_checked::(original_cwd, path_buf, &[part]) + else { + Output::err_generic("--filter \"{}\" path is too long", (BStr::new(raw),)); + Global::crash(); + }; + Prepared::new(strings::without_trailing_slash(joined).into()) }; let base = if remain == b"*" || remain == b"**" { diff --git a/src/install/bin.rs b/src/install/bin.rs index d30ceb35c5a1..e8392c2328df 100644 --- a/src/install/bin.rs +++ b/src/install/bin.rs @@ -1922,13 +1922,16 @@ pub(crate) fn link_free_bins( ) { // Two copies of the same path, because the linker reads `target_node_modules_path` while it // holds `node_modules_path` by `&mut`. - let abs_node_modules = || -> AbsPath { + let abs_node_modules = || -> Option { let mut dir = AbsPath::init_top_level_dir(); - let _ = dir.append(node_modules); - dir + dir.try_append(node_modules).ok()?; + Some(dir) + }; + let (Some(target_node_modules_path), Some(mut node_modules_path)) = + (abs_node_modules(), abs_node_modules()) + else { + return; }; - let (target_node_modules_path, mut node_modules_path) = - (abs_node_modules(), abs_node_modules()); let mut seen: StringHashMap<()> = StringHashMap::default(); match global_bin_path { diff --git a/src/install/isolated_install.rs b/src/install/isolated_install.rs index 88bb7acb851f..bea3cdf5d555 100644 --- a/src/install/isolated_install.rs +++ b/src/install/isolated_install.rs @@ -1617,13 +1617,19 @@ fn move_entries_aside( } let (from_len, to_len) = (from.len(), to.len()); - from.append(name).assume_ok(); - to.append(name).assume_ok(); - - if entry.kind == sys::EntryKind::Directory && name[0] == b'@' { + let moved = if from.try_append(name).is_err() || to.try_append(name).is_err() { + Err(sys::Error::from_code( + sys::E::ENAMETOOLONG, + sys::Tag::rename, + )) + } else if entry.kind == sys::EntryKind::Directory && name[0] == b'@' { move_entries_aside(from, to, keep_links, log_level); let _ = sys::rmdir(from.slice_z()); - } else if let Err(err) = move_or_delete(from.slice_z(), to.slice_z()) + Ok(()) + } else { + move_or_delete(from.slice_z(), to.slice_z()) + }; + if let Err(err) = moved && log_level != LogLevel::Silent { bun_core::warn!( @@ -2328,8 +2334,9 @@ pub(crate) fn install_isolated_packages( if !is_new_bun_modules { use bun_sys::FdExt as _; let mut old_copy: AbsPath = AbsPath::init_top_level_dir(); - installer.append_local_store_entry_path(&mut old_copy, entry_id); - let _ = Fd::cwd().delete_tree(old_copy.slice()); + let _ = installer + .append_local_store_entry_path(&mut old_copy, entry_id) + .and_then(|()| Fd::cwd().delete_tree(old_copy.slice())); } installer.set_step(entry_id, Step::Done); installer.on_task_complete(entry_id, CompleteState::Skipped); @@ -2347,8 +2354,10 @@ pub(crate) fn install_isolated_packages( ResolutionTag::Symlink => { // Same target check as the hoisted linker's `install_from_link`. let mut link_target = bun_paths::AutoAbsPath::init_top_level_dir(); - installer.append_store_path(&mut link_target, entry_id); - match expect_directory(link_target.slice_z()) { + match installer + .append_store_path(&mut link_target, entry_id) + .and_then(|()| expect_directory(link_target.slice_z())) + { Ok(()) => installer.start_link_task(entry_id, link_target.slice_z()), Err(err) => installer.on_task_fail(entry_id, &TaskError::LinkPackage(err)), } @@ -2385,9 +2394,11 @@ pub(crate) fn install_isolated_packages( // folders are always hardlinked to keep them up-to-date if !is_new_bun_modules && !installer.manager().options.enable.force_install() { let mut store_path: AbsPath = AbsPath::init_top_level_dir(); - installer.append_real_store_path(&mut store_path, entry_id, Which::Final); - if let (Ok(src), Ok(dest)) = - (sys::Dir::open(folder), sys::Dir::open(store_path.slice())) + if installer + .append_real_store_path(&mut store_path, entry_id, Which::Final) + .is_ok() + && let (Ok(src), Ok(dest)) = + (sys::Dir::open(folder), sys::Dir::open(store_path.slice())) && crate::package_install::is_linked_folder(&src, &dest) { installer.start_relink_task(entry_id); @@ -2426,35 +2437,39 @@ pub(crate) fn install_isolated_packages( // staging path and renamed into place as the // final step, so the directory existing at its // final path is the completeness signal. - installer.append_global_store_entry_path(&mut store_path, entry_id, Which::Final); - break 'needs_install !sys::directory_exists_at(Fd::cwd(), store_path.slice_z()) + break 'needs_install !installer + .append_global_store_entry_path(&mut store_path, entry_id, Which::Final) + .and_then(|()| sys::directory_exists_at(Fd::cwd(), store_path.slice_z())) .unwrap_or(false); } // Likewise, the package directory appears here only once fully linked. - installer.append_local_store_entry_path(&mut store_path, entry_id); + if installer.append_local_store_entry_path(&mut store_path, entry_id).is_err() { + break 'needs_install true; + } let local_entry_len = store_path.len(); - store_path.append(b"node_modules").assume_ok(); - store_path.append(pkg_name.slice(string_buf)).assume_ok(); + if store_path.try_append(b"node_modules").is_err() + || store_path.try_append(pkg_name.slice(string_buf)).is_err() + { + break 'needs_install true; + } let package_len = store_path.len(); let mut hash_buf: install::BuntagHashBuf = Default::default(); - store_path - .append(installer::installed_marker(pkg_res.tag, &patch_info, &mut hash_buf)) - .assume_ok(); + let marker = installer::installed_marker(pkg_res.tag, &patch_info, &mut hash_buf); // `exists` follows the link of an entry that left the global store. - if !sys::exists_z(store_path.slice_z()) { + if store_path.try_append(marker).is_err() || !sys::exists_z(store_path.slice_z()) { break 'needs_install true; } // Like the hoisted linker, replace a tarball copy that holds other bytes than bun.lock pins. if matches!(pkg_res.tag, ResolutionTag::LocalTarball | ResolutionTag::RemoteTarball) { store_path.set_length(package_len); - store_path.append(crate::package_install::BUN_TAG).assume_ok(); - let is_current = sys::File::read_from(Fd::cwd(), store_path.slice()).is_ok_and(|tag| { - crate::package_install::tarball_tag_matches( - &tag, - &pkgs.items_meta()[pkg_id as usize].integrity, - installer.manager().skips_verify(pkg_res.tag), - ) - }); + let is_current = store_path.try_append(crate::package_install::BUN_TAG).is_ok() + && sys::File::read_from(Fd::cwd(), store_path.slice()).is_ok_and(|tag| { + crate::package_install::tarball_tag_matches( + &tag, + &pkgs.items_meta()[pkg_id as usize].integrity, + installer.manager().skips_verify(pkg_res.tag), + ) + }); if !is_current { break 'needs_install true; } @@ -2473,8 +2488,8 @@ pub(crate) fn install_isolated_packages( } else { b"../../.." }; - store_path.append(up).assume_ok(); - !sys::stat(store_path.slice_z()).is_ok_and(|st| Some((st.st_dev, st.st_ino)) == store_inode) + store_path.try_append(up).is_err() + || !sys::stat(store_path.slice_z()).is_ok_and(|st| Some((st.st_dev, st.st_ino)) == store_inode) }; if !needs_install { diff --git a/src/install/isolated_install/Installer.rs b/src/install/isolated_install/Installer.rs index df1b4d9a42f0..c7e365c9093d 100644 --- a/src/install/isolated_install/Installer.rs +++ b/src/install/isolated_install/Installer.rs @@ -59,6 +59,28 @@ type OsAutoAbsPath = AbsPath; type OsAutoPath = paths::Path; type DefaultAbsPath = AbsPath; +/// What the `append_*_path` helpers need from a `Path` of any unit, kind and separators. +pub(crate) trait PathLike { + fn clear(&mut self); + fn append(&mut self, bytes: &[u8]) -> sys::Result<()>; + fn append_fmt(&mut self, args: core::fmt::Arguments<'_>) -> sys::Result<()>; +} +impl PathLike for paths::Path { + fn clear(&mut self) { + paths::Path::clear(self); + } + fn append(&mut self, bytes: &[u8]) -> sys::Result<()> { + self.try_append(bytes).map_err(name_too_long) + } + fn append_fmt(&mut self, args: core::fmt::Arguments<'_>) -> sys::Result<()> { + self.try_append_fmt(args).map_err(name_too_long) + } +} + +fn name_too_long(_: paths::path_options::Error) -> sys::Error { + sys::Error::from_code(sys::E::ENAMETOOLONG, sys::Tag::symlink) +} + pub struct Installer<'a> { /// BACKREF, as `manager`. pub lockfile: *mut Lockfile, @@ -218,13 +240,16 @@ impl<'a> Installer<'a> { .filter(|dep| dep.entry_id == entry_id) .any(|dep| { let mut link = AutoPath::init_top_level_dir(); - self.append_store_node_modules_path(&mut link, dependent); - append_dependency_link_name( - &mut link, - lockfile.str(&lockfile.buffers.dependencies[dep.dep_id as usize].name), - self.entry_store_node_modules_package_name(dependent), - ); - sys::stat(link.slice_z()) + self.append_store_node_modules_path(&mut link, dependent) + .and_then(|()| { + append_dependency_link_name( + &mut link, + lockfile + .str(&lockfile.buffers.dependencies[dep.dep_id as usize].name), + self.entry_store_node_modules_package_name(dependent), + ) + }) + .and_then(|()| sys::stat(link.slice_z())) .is_ok_and(|st| st.st_ino == target.st_ino && st.st_dev == target.st_dev) }) }) @@ -504,15 +529,17 @@ impl<'a> Installer<'a> { // random — but it's wasted disk). if self.entry_uses_global_store(entry_id) { let mut staging = AutoAbsPath::init(); - self.append_global_store_entry_path(&mut staging, entry_id, Which::Staging); - let _ = Fd::cwd().delete_tree(staging.slice()); + let _ = self + .append_global_store_entry_path(&mut staging, entry_id, Which::Staging) + .and_then(|()| Fd::cwd().delete_tree(staging.slice())); } else if let Some(lock) = self.staging_lock.get() && pkg_res.can_enqueue_install_task() { // A staged link that failed only wrote to the staging directory. let mut staging = AutoPath::init(); - self.append_local_staging_path(&mut staging, entry_id, lock.mode); - let _ = Fd::cwd().delete_tree(staging.slice()); + let _ = self + .append_local_staging_path(&mut staging, entry_id, lock.mode) + .and_then(|()| Fd::cwd().delete_tree(staging.slice())); } // Like the hoisted linker, exit without saving the lockfile when a patch does not apply. @@ -740,15 +767,20 @@ impl<'a> Installer<'a> { /// Deletes the `installed_marker` file first, because `delete_tree` can stop midway. fn delete_local_store_package_in_place(&self, entry_id: StoreEntryId) { let mut path = AutoAbsPath::init_top_level_dir(); - self.append_store_path(&mut path, entry_id); + if self.append_store_path(&mut path, entry_id).is_err() { + return; + } let package_len = path.len(); if is_link(path.slice_z()) == Some(false) && let Ok(patch_info) = self.package_patch_info(self.store.ids(entry_id).pkg_id) { let mut hash_buf = install::BuntagHashBuf::default(); let tag = self.resolution(entry_id).tag; - let _ = path.append(installed_marker(tag, &patch_info, &mut hash_buf)); - let _ = sys::unlink(path.slice_z()); + if PathLike::append(&mut path, installed_marker(tag, &patch_info, &mut hash_buf)) + .is_ok() + { + let _ = sys::unlink(path.slice_z()); + } path.set_length(package_len); } let _ = Fd::cwd().delete_tree(path.slice()); @@ -757,7 +789,7 @@ impl<'a> Installer<'a> { /// Of `/node_modules/.bun`, which `install_isolated_packages` looked at. fn store_path_len() -> usize { let mut store = AutoPath::init_top_level_dir(); - let _ = store.append(b"node_modules/.bun"); + let _ = store.try_append(b"node_modules/.bun"); store.len() } @@ -768,13 +800,13 @@ impl<'a> Installer<'a> { return Ok(()); } let mut bin = AutoPath::init_top_level_dir(); - self.append_store_node_modules_path(&mut bin, entry_id); + self.append_store_node_modules_path(&mut bin, entry_id)?; let node_modules_len = bin.len(); - let _ = bin.append(b".bin"); + PathLike::append(&mut bin, b".bin")?; crate::prune::remove_planted_links(bin.slice(), real_len)?; let mut package = AutoPath::init_top_level_dir(); - self.append_store_path(&mut package, entry_id); + self.append_store_path(&mut package, entry_id)?; crate::prune::remove_planted_links( // A cached package replaces whatever is at its path. A folder's files are linked into // the existing directory. @@ -1195,7 +1227,11 @@ impl Task { .filter(|name| !name.is_empty()) { installer - .link_bins(&mut installer.bin_links(entry_id), pkg_id, own_name, false) + .bin_links(entry_id) + .map_err(crate::Error::from) + .and_then(|mut links| { + installer.link_bins(&mut links, pkg_id, own_name, false) + }) .map_err(TaskError::Binaries)?; } installer @@ -1295,7 +1331,9 @@ impl Task { } let mut cwd = AutoAbsPath::init_top_level_dir(); - installer.append_store_path(&mut cwd, self.entry_id); + installer + .append_store_path(&mut cwd, self.entry_id) + .map_err(|err| TaskError::RunScripts(err.into()))?; let mut log = Log::init(); let Some(list) = pkg_scripts .get_list(&mut log, lockfile, &mut cwd, dep_name, pkg_res, is_trusted) @@ -1329,11 +1367,16 @@ impl Task { let dep_name = lockfile.str(&lockfile.buffers.dependencies[dep_id as usize].name); let pkg_res = installer.resolution(entry_id); let mut pkg_dir = AutoAbsPath::init_top_level_dir(); - installer.append_real_store_path(&mut pkg_dir, entry_id, which); let mut log = Log::init(); - // `true` counts the scripts that would run, like the hoisted installer does. - match pkg_scripts.get_list(&mut log, lockfile, &mut pkg_dir, dep_name, pkg_res, true) { + let list = installer + .append_real_store_path(&mut pkg_dir, entry_id, which) + .map_err(crate::Error::from) + // `true` counts the scripts that would run, like the hoisted installer does. + .and_then(|()| { + pkg_scripts.get_list(&mut log, lockfile, &mut pkg_dir, dep_name, pkg_res, true) + }); + match list { Ok(Some(list)) => { if manager.options.log_level.is_verbose() { bun_core::pretty_error!( @@ -1550,16 +1593,16 @@ impl<'a> Installer<'a> { // The hardlink and copy walks below only visit what the folder contains now. First remove // what it no longer has from the old copy, and any symlink that they would write through. let mut copy = AutoPath::init(); - self.append_store_path(&mut copy, entry_id); + self.append_store_path(&mut copy, entry_id)?; if let (Ok(src), Ok(dest)) = (sys::Dir::open(path), sys::Dir::open(copy.slice())) { crate::prune::remove_entries_missing_from(&src, &dest); } let hardlinked = { let mut src = OsAutoAbsPath::init_top_level_dir_long_path(); - let _ = src.append_join(path); + src.try_append_join(path).map_err(name_too_long)?; let mut dest = OsAutoPath::init(); - self.append_store_path(&mut dest, entry_id); + self.append_store_path(&mut dest, entry_id)?; let mut hardlinker = bun_core::handle_oom(Hardlinker::init(folder_dir, src, dest, skip_dirnames)); @@ -1622,7 +1665,7 @@ impl<'a> Installer<'a> { } let mut dest = OsAutoPath::init(); - self.append_store_path(&mut dest, entry_id); + self.append_store_path(&mut dest, entry_id)?; let mut file_copier = bun_core::handle_oom(FileCopier::init(folder_dir, src_path, dest, skip_dirnames)); @@ -1674,7 +1717,8 @@ impl<'a> Installer<'a> { let staging = if uses_global_store { // Clear any leftover staging directory from a crashed earlier run with the same suffix. let mut staging = AutoAbsPath::init(); - self.append_global_store_entry_path(&mut staging, entry_id, Which::Staging); + self.append_global_store_entry_path(&mut staging, entry_id, Which::Staging) + .map_err(TaskError::LinkPackage)?; let _ = Fd::cwd().delete_tree(staging.slice()); Staging::Off } else { @@ -1688,7 +1732,8 @@ impl<'a> Installer<'a> { if !uses_global_store && (!self.is_new_bun_modules || link_in_place) { // An entry that lost global-store eligibility is still a link into that store. let mut local = AutoPath::init_top_level_dir(); - self.append_local_store_entry_path(&mut local, entry_id); + self.append_local_store_entry_path(&mut local, entry_id) + .map_err(TaskError::LinkPackage)?; let is_stale_link = is_link(local.slice_z()); if is_stale_link == Some(true) && let Err(err) = remove_link(local.slice_z()) @@ -1713,10 +1758,12 @@ impl<'a> Installer<'a> { let linked: sys::Result<()> = 'backend: loop { // Rebuilt per iteration; this only re-runs once on an EXDEV/OPNOTSUPP retry. let mut dest_subpath = OsAutoPath::init(); - if uses_global_store || link_in_place { - self.append_real_store_path(&mut dest_subpath, entry_id, Which::Staging); + if let Err(err) = if uses_global_store || link_in_place { + self.append_real_store_path(&mut dest_subpath, entry_id, Which::Staging) } else { - self.append_local_staging_path(&mut dest_subpath, entry_id, staging); + self.append_local_staging_path(&mut dest_subpath, entry_id, staging) + } { + break 'backend Err(err); } match backend { InstallMethod::Clonefile => { @@ -1771,7 +1818,9 @@ impl<'a> Installer<'a> { let mut src = OsAutoAbsPath::from_long_path(cache_dir_path.slice()).assume_ok(); - let _ = src.append_join(subpath.slice()); // OOM/capacity: fire-and-forget + if let Err(err) = src.try_append_join(subpath.slice()) { + break 'backend Err(name_too_long(err)); + } let mut hardlinker = bun_core::handle_oom(Hardlinker::init(dir, src, dest_subpath, &[])); @@ -1805,7 +1854,9 @@ impl<'a> Installer<'a> { let _dir_guard = sys::CloseOnDrop::new(dir); let mut src = OsAutoAbsPath::from(cache_dir_path.slice()).assume_ok(); - let _ = src.append(subpath.slice()); // OOM/capacity: fire-and-forget + if let Err(err) = PathLike::append(&mut src, subpath.slice()) { + break 'backend Err(err); + } let mut file_copier = bun_core::handle_oom(FileCopier::init(dir, src, dest_subpath, &[])); @@ -1977,7 +2028,8 @@ impl<'a> Installer<'a> { && self.resolution(entry_id).tag == ResolutionTag::Workspace => { let mut node_modules = AutoPath::init_top_level_dir(); - self.append_store_node_modules_path(&mut node_modules, entry_id); + self.append_store_node_modules_path(&mut node_modules, entry_id) + .map_err(TaskError::SymlinkDependencies)?; let Some(linked) = LinkedNodeModules::at(node_modules.slice_z()) else { return Err(TaskError::SymlinkDependencies(err)); }; @@ -2006,7 +2058,8 @@ impl<'a> Installer<'a> { let uses_global_store = self.entry_uses_global_store(entry_id); let mut dest = AutoPath::init_top_level_dir(); - self.append_real_store_node_modules_path(&mut dest, entry_id, Which::Staging); + self.append_real_store_node_modules_path(&mut dest, entry_id, Which::Staging) + .map_err(TaskError::SymlinkDependencies)?; // A relative symlink is resolved from its real location, so `dest` needs its real path when // `node_modules` or the workspace's directory is itself a symlink. let is_linked = (self.linked.iter().chain(own)).any(|linked| linked.resolve(&mut dest)); @@ -2020,7 +2073,7 @@ impl<'a> Installer<'a> { && let Some(member) = linked_member { dest = AutoPath::from(&*member.real_path).assume_ok(); - dest.append(b"node_modules").assume_ok(); + PathLike::append(&mut dest, b"node_modules").map_err(TaskError::SymlinkDependencies)?; } let base_len = dest.len(); // The links go directly into the symlinked `node_modules` of the project or of a workspace. @@ -2081,7 +2134,8 @@ impl<'a> Installer<'a> { let dep_name = dependencies[dep.dep_id as usize].name.slice(string_buf); dest.set_length(base_len); - append_dependency_link_name(&mut dest, dep_name, entry_node_modules_name); + append_dependency_link_name(&mut dest, dep_name, entry_node_modules_name) + .map_err(TaskError::SymlinkDependencies)?; if real_len > base_len && !(dep_name.starts_with(real_scope) && dep_name.get(real_scope.len()) == Some(&b'/')) @@ -2118,9 +2172,11 @@ impl<'a> Installer<'a> { continue; } else if uses_global_store { debug_assert!(self.entry_uses_global_store(dep.entry_id)); - self.append_real_store_path(&mut dep_store_path, dep.entry_id, Which::Final); + self.append_real_store_path(&mut dep_store_path, dep.entry_id, Which::Final) + .map_err(TaskError::SymlinkDependencies)?; } else { - self.append_store_path(&mut dep_store_path, dep.entry_id); + self.append_store_path(&mut dep_store_path, dep.entry_id) + .map_err(TaskError::SymlinkDependencies)?; } let (linked_dest, looked_len, written) = link(dest, dep_store_path, real_len); @@ -2160,7 +2216,8 @@ impl<'a> Installer<'a> { } dest.set_length(base_len); - append_dependency_link_name(&mut dest, dep_name, entry_node_modules_name); + append_dependency_link_name(&mut dest, dep_name, entry_node_modules_name) + .map_err(TaskError::SymlinkDependencies)?; let (linked_dest, _, written) = link(dest, shipped, real_len.min(base_len)); dest = linked_dest; if written? { @@ -2191,13 +2248,16 @@ impl<'a> Installer<'a> { continue; } let mut dest = AutoPath::init_top_level_dir(); - self.append_real_store_node_modules_path(&mut dest, entry_id, Which::Final); - append_dependency_link_name( - &mut dest, - lockfile.str(&lockfile.buffers.dependencies[dep.dep_id as usize].name), - self.entry_store_node_modules_package_name(entry_id), - ); - if is_link(dest.slice_z()) == Some(true) { + let fits = self + .append_real_store_node_modules_path(&mut dest, entry_id, Which::Final) + .and_then(|()| { + append_dependency_link_name( + &mut dest, + lockfile.str(&lockfile.buffers.dependencies[dep.dep_id as usize].name), + self.entry_store_node_modules_package_name(entry_id), + ) + }); + if fits.is_ok() && is_link(dest.slice_z()) == Some(true) { let _ = remove_link(dest.slice_z()); } } @@ -2237,7 +2297,9 @@ impl<'a> Installer<'a> { let pkgs = lockfile.packages.slice(); let pkg_bins = pkgs.items_bin(); - let mut links = self.bin_links(parent_entry_id); + let mut links = self + .bin_links(parent_entry_id) + .map_err(|err| TaskError::Binaries(err.into()))?; let mut failed = Vec::new(); // Public hoisting gives the root entry dependencies that the root package does not declare. @@ -2279,8 +2341,8 @@ impl<'a> Installer<'a> { if !deps_with_bins.is_empty() && is_importer { let path = &mut links.node_modules_path; let node_modules_len = path.len(); - let _ = path.append(b".bin"); - let looked = crate::prune::remove_planted_link(&sys::Dir::cwd(), path.slice_z()); + let looked = PathLike::append(path, b".bin") + .and_then(|()| crate::prune::remove_planted_link(&sys::Dir::cwd(), path.slice_z())); path.set_length(node_modules_len); looked.map_err(|err| TaskError::Binaries(err.into()))?; } @@ -2306,23 +2368,23 @@ impl<'a> Installer<'a> { Err(TaskError::DependencyBinaries(failed.into_boxed_slice())) } - fn bin_links(&self, entry_id: StoreEntryId) -> BinLinks { + fn bin_links(&self, entry_id: StoreEntryId) -> sys::Result { let mut node_modules_path = DefaultAbsPath::init_top_level_dir(); - self.append_real_store_node_modules_path(&mut node_modules_path, entry_id, Which::Staging); + self.append_real_store_node_modules_path(&mut node_modules_path, entry_id, Which::Staging)?; let is_linked = (self.linked.iter()).any(|linked| linked.resolve(&mut node_modules_path)); let is_behind_link = match self.resolution(entry_id).tag { ResolutionTag::Root => Some(is_linked), ResolutionTag::Workspace => None, _ => Some(false), }; - BinLinks { + Ok(BinLinks { node_modules_path, is_behind_link, seen: StringHashMap::default(), abs_target_buf: paths::path_buffer_pool::get(), abs_dest_buf: paths::path_buffer_pool::get(), rel_buf: paths::path_buffer_pool::get(), - } + }) } /// Links the bins of `pkg_id`, which is `package_name` in `links.node_modules_path`. @@ -2340,15 +2402,16 @@ impl<'a> Installer<'a> { .maybe_replace_node_modules_path(pkg_id) .map(|entry_id| { let mut path = DefaultAbsPath::init_top_level_dir(); - self.append_real_store_node_modules_path(&mut path, entry_id, Which::Final); + self.append_real_store_node_modules_path(&mut path, entry_id, Which::Final)?; if let Some(linked) = &self.linked { linked.resolve(&mut path); } - ( + Ok::<_, sys::Error>(( path, strings::StringOrTinyString::init(self.pkg_name(entry_id)), - ) - }); + )) + }) + .transpose()?; // A bin link to a replacement package points outside the workspace's `node_modules`. If // that directory is a symlink, the relative target has to start from its real path. if replacement.is_some() && links.is_behind_link != Some(false) { @@ -2424,13 +2487,13 @@ impl<'a> Installer<'a> { /// step renames staging → final. pub(crate) fn append_global_store_entry_path( &self, - buf: &mut impl paths::PathLike, + buf: &mut impl PathLike, entry_id: StoreEntryId, which: Which, - ) { + ) -> sys::Result<()> { debug_assert!(self.entry_uses_global_store(entry_id)); buf.clear(); - buf.append(self.global_store_path.as_ref().unwrap().as_bytes()); + buf.append(self.global_store_path.as_ref().unwrap().as_bytes())?; match which { Which::Final => buf.append_fmt(format_args!( "{}", @@ -2455,9 +2518,9 @@ impl<'a> Installer<'a> { return Ok(()); } let mut staging = AutoAbsPath::init(); - self.append_global_store_entry_path(&mut staging, entry_id, Which::Staging); + self.append_global_store_entry_path(&mut staging, entry_id, Which::Staging)?; let mut final_ = AutoAbsPath::init(); - self.append_global_store_entry_path(&mut final_, entry_id, Which::Final); + self.append_global_store_entry_path(&mut final_, entry_id, Which::Final)?; let mut retry = RenameRetry::new(); let Err(err) = retry.rename(Fd::cwd(), staging.slice_z(), Fd::cwd(), final_.slice_z()) @@ -2492,12 +2555,24 @@ impl<'a> Installer<'a> { } } let mut old = AutoAbsPath::init(); - let _ = old.append(self.global_store_path.as_ref().unwrap().as_bytes()); // OOM/capacity: fire-and-forget - let _ = old.append_fmt(format_args!( - "{}.old-{:x}", - store::entry::fmt_global_store_path(entry_id, self.store, self.lockfile()), - bun_core::fast_random(), - )); + let fits = PathLike::append( + &mut old, + self.global_store_path.as_ref().unwrap().as_bytes(), + ) + .and_then(|()| { + PathLike::append_fmt( + &mut old, + format_args!( + "{}.old-{:x}", + store::entry::fmt_global_store_path(entry_id, self.store, self.lockfile()), + bun_core::fast_random(), + ), + ) + }); + if let Err(err) = fits { + let _ = Fd::cwd().delete_tree(staging.slice()); + return Err(err); + } if let Err(err) = retry.rename(Fd::cwd(), final_.slice_z(), Fd::cwd(), old.slice_z()) { let _ = Fd::cwd().delete_tree(staging.slice()); return Err(global_store_publish_error(err, &final_)); @@ -2521,17 +2596,17 @@ impl<'a> Installer<'a> { /// could exceed the path length limit when the package's relative path does not. fn append_local_staging_path( &self, - buf: &mut impl paths::PathLike, + buf: &mut impl PathLike, entry_id: StoreEntryId, mode: Staging, - ) { + ) -> sys::Result<()> { debug_assert!(!self.entry_uses_global_store(entry_id) && mode != Staging::Off); - self.append_local_store_entry_path(buf, entry_id); - buf.append(b"node_modules"); + self.append_local_store_entry_path(buf, entry_id)?; + buf.append(b"node_modules")?; buf.append_fmt(format_args!( "{}", StagingPath(self.pkg_name(entry_id), mode), - )); + )) } /// Frees the final path, and the staging path of a dead install's rest. On an error, link in place. @@ -2542,9 +2617,9 @@ impl<'a> Installer<'a> { wait: bool, ) -> sys::Maybe<()> { let mut final_ = AutoPath::init(); - self.append_store_path(&mut final_, entry_id); + self.append_store_path(&mut final_, entry_id)?; let mut staging = AutoPath::init(); - self.append_local_staging_path(&mut staging, entry_id, mode); + self.append_local_staging_path(&mut staging, entry_id, mode)?; // A delete can be interrupted halfway, so rename the existing package away from its final // path first. @@ -2577,9 +2652,14 @@ impl<'a> Installer<'a> { /// it. Returns false if the caller should link in place instead. fn commit_local_store_package(&self, entry_id: StoreEntryId, mode: Staging) -> bool { let mut staging = AutoPath::init(); - self.append_local_staging_path(&mut staging, entry_id, mode); let mut final_ = AutoPath::init(); - self.append_store_path(&mut final_, entry_id); + if self + .append_local_staging_path(&mut staging, entry_id, mode) + .and_then(|()| self.append_store_path(&mut final_, entry_id)) + .is_err() + { + return false; + } if rename_staging_into_place(Fd::cwd(), staging.slice_z(), final_.slice_z()).is_ok() { return true; @@ -2592,13 +2672,13 @@ impl<'a> Installer<'a> { /// points at the global virtual-store entry). Relative to top-level dir. pub(crate) fn append_local_store_entry_path( &self, - buf: &mut impl paths::PathLike, + buf: &mut impl PathLike, entry_id: StoreEntryId, - ) { + ) -> sys::Result<()> { buf.append_fmt(format_args!( "node_modules/.bun/{}", store::entry::fmt_store_path(entry_id, self.store, self.lockfile()), - )); + )) } /// Create the project-level symlink `node_modules/.bun/` → @@ -2607,10 +2687,10 @@ impl<'a> Installer<'a> { /// `readlink` and no write. Returns true if the project had nothing at that path before. pub(crate) fn link_project_to_global_store(&self, entry_id: StoreEntryId) -> sys::Result { let mut dest = AutoPath::init_top_level_dir(); - self.append_local_store_entry_path(&mut dest, entry_id); + self.append_local_store_entry_path(&mut dest, entry_id)?; let mut target_abs = AutoAbsPath::init(); - self.append_global_store_entry_path(&mut target_abs, entry_id, Which::Final); + self.append_global_store_entry_path(&mut target_abs, entry_id, Which::Final)?; // Absolute target so the link is independent of where node_modules // lives (project root may itself be behind a symlink). Symlinker's @@ -2646,13 +2726,16 @@ impl<'a> Installer<'a> { // The link points to an entry with a different hash. Replace it atomically. Ok(_) if cfg!(not(windows)) => { let mut tmp = AutoPath::init_top_level_dir(); - let _ = tmp.append_fmt(format_args!( - "node_modules/.bun/{}", - StagingPath( - paths::basename(dest.slice()), - Staging::Unlocked(self.global_store_tmp_suffix), + PathLike::append_fmt( + &mut tmp, + format_args!( + "node_modules/.bun/{}", + StagingPath( + paths::basename(dest.slice()), + Staging::Unlocked(self.global_store_tmp_suffix), + ), ), - )); + )?; do_symlink(tmp.slice_z(), target_abs.slice_z())?; let renamed = sys::renameat(Fd::cwd(), tmp.slice_z(), Fd::cwd(), dest.slice_z()); @@ -2696,18 +2779,18 @@ impl<'a> Installer<'a> { pub(crate) fn append_store_node_modules_path( &self, - buf: &mut impl paths::PathLike, + buf: &mut impl PathLike, entry_id: StoreEntryId, - ) { + ) -> sys::Result<()> { let pkg_res = self.resolution(entry_id); match pkg_res.tag { ResolutionTag::Root => {} ResolutionTag::Workspace => { - append_workspace_path(buf, self.lockfile().str(pkg_res.workspace())); + append_workspace_path(buf, self.lockfile().str(pkg_res.workspace()))?; } - _ => self.append_local_store_entry_path(buf, entry_id), + _ => self.append_local_store_entry_path(buf, entry_id)?, } - buf.append(b"node_modules"); + buf.append(b"node_modules") } /// Like `appendStoreNodeModulesPath`, but resolves to the *physical* @@ -2716,33 +2799,31 @@ impl<'a> Installer<'a> { /// otherwise. See `Which` for when to pass `.staging` vs `.final`. pub(crate) fn append_real_store_node_modules_path( &self, - buf: &mut impl paths::PathLike, + buf: &mut impl PathLike, entry_id: StoreEntryId, which: Which, - ) { + ) -> sys::Result<()> { if self.entry_uses_global_store(entry_id) { - self.append_global_store_entry_path(buf, entry_id, which); - buf.append(b"node_modules"); - return; + self.append_global_store_entry_path(buf, entry_id, which)?; + return buf.append(b"node_modules"); } - self.append_store_node_modules_path(buf, entry_id); + self.append_store_node_modules_path(buf, entry_id) } /// `appendStorePath` resolved to the entry's *physical* location. See /// `Which` for when to pass `.staging` vs `.final`. pub(crate) fn append_real_store_path( &self, - buf: &mut impl paths::PathLike, + buf: &mut impl PathLike, entry_id: StoreEntryId, which: Which, - ) { + ) -> sys::Result<()> { if self.entry_uses_global_store(entry_id) { - self.append_global_store_entry_path(buf, entry_id, which); - buf.append(b"node_modules"); - buf.append(self.pkg_name(entry_id)); - return; + self.append_global_store_entry_path(buf, entry_id, which)?; + buf.append(b"node_modules")?; + return buf.append(self.pkg_name(entry_id)); } - self.append_store_path(buf, entry_id); + self.append_store_path(buf, entry_id) } /// `check_link_entries` bounded the length. Normalized: a Windows junction resolves no `..`. @@ -2762,26 +2843,31 @@ impl<'a> Installer<'a> { &buf[..len] } - pub(crate) fn append_store_path(&self, buf: &mut impl paths::PathLike, entry_id: StoreEntryId) { + pub(crate) fn append_store_path( + &self, + buf: &mut impl PathLike, + entry_id: StoreEntryId, + ) -> sys::Result<()> { let pkg_res = self.resolution(entry_id); match pkg_res.tag { ResolutionTag::Workspace => { - append_workspace_path(buf, self.lockfile().str(pkg_res.workspace())); + append_workspace_path(buf, self.lockfile().str(pkg_res.workspace())) } ResolutionTag::Symlink => { let mut join_buf = paths::path_buffer_pool::get(); buf.clear(); buf.append( self.link_target_path(self.lockfile().str(pkg_res.symlink()), &mut join_buf), - ); + ) } _ => { // `None`: the root. `buf` is already top_level_dir. - if let Some(name) = self.entry_store_node_modules_package_name(entry_id) { - self.append_local_store_entry_path(buf, entry_id); - buf.append(b"node_modules"); - buf.append(name); - } + let Some(name) = self.entry_store_node_modules_package_name(entry_id) else { + return Ok(()); + }; + self.append_local_store_entry_path(buf, entry_id)?; + buf.append(b"node_modules")?; + buf.append(name) } } } @@ -2799,8 +2885,8 @@ impl<'a> Installer<'a> { return Ok(false); } - self.append_real_store_node_modules_path(buf, entry_id, Which::Staging); - buf.append(package_dir_name).assume_ok(); + self.append_real_store_node_modules_path(buf, entry_id, Which::Staging)?; + PathLike::append(buf, package_dir_name)?; // `folder` comes from a third-party manifest, so its length is unchecked. if buf.len() + 1 + folder.len() >= paths::MAX_PATH_BYTES { return Err(sys::Error::from_code( @@ -2858,7 +2944,7 @@ struct BinLinks { /// `buf` is the project's directory. Appends the path of a workspace. On Windows, the path of a /// workspace outside the project is normalized, because the `..` in the path of a new link isn't /// resolved there. Elsewhere the kernel resolves it, also when the project is behind a symlink. -fn append_workspace_path(buf: &mut impl paths::PathLike, workspace_path: &[u8]) { +fn append_workspace_path(buf: &mut impl PathLike, workspace_path: &[u8]) -> sys::Result<()> { if cfg!(not(windows)) || !crate::bin::path_climbs_out(workspace_path) { return buf.append(workspace_path); } @@ -2869,21 +2955,22 @@ fn append_workspace_path(buf: &mut impl paths::PathLike, workspace_path: &[u8]) &[workspace_path], ); buf.clear(); - buf.append(absolute); + buf.append(absolute) } fn append_dependency_link_name( dest: &mut AutoPath, dep_name: &[u8], entry_node_modules_name: Option<&[u8]>, -) { - let _ = dest.append(dep_name); // OOM/capacity: fire-and-forget +) -> sys::Result<()> { + PathLike::append(dest, dep_name)?; if entry_node_modules_name.is_some_and(|name| strings::eql_long(dep_name, name, true)) { // It has the same name as the entry itself, so nest it one `node_modules` deeper to avoid // the collision. - let _ = dest.append(b"node_modules"); // OOM/capacity: fire-and-forget - let _ = dest.append(dep_name); // OOM/capacity: fire-and-forget + PathLike::append(dest, b"node_modules")?; + PathLike::append(dest, dep_name)?; } + Ok(()) } /// The next install skips a project-local package that has this file. A discard deletes it first. diff --git a/src/install/isolated_install/Symlinker.rs b/src/install/isolated_install/Symlinker.rs index 31214866b321..9de4c7a4b58f 100644 --- a/src/install/isolated_install/Symlinker.rs +++ b/src/install/isolated_install/Symlinker.rs @@ -127,7 +127,12 @@ impl Symlinker { _ => { let has_package_json = { let mut dest = self.dest.save(); - let _ = dest.append(b"package.json"); + if dest.try_append(b"package.json").is_err() { + return Err(bun_sys::Error::from_code( + Errno::ENAMETOOLONG, + bun_sys::Tag::access, + )); + } bun_sys::exists_z(dest.slice_z()) }; if has_package_json { diff --git a/src/install/lockfile/Package/Scripts.rs b/src/install/lockfile/Package/Scripts.rs index 0b2822a0320e..dc81fe741569 100644 --- a/src/install/lockfile/Package/Scripts.rs +++ b/src/install/lockfile/Package/Scripts.rs @@ -6,7 +6,7 @@ use bun_core::strings; use bun_install::lockfile::Lockfile; use bun_install::lockfile::Scripts as LockfileScripts; use bun_install::{Resolution, ResolutionTag, initialize_store}; -use bun_paths::resolve_path::{join_abs_string_buf_z, platform}; +use bun_paths::resolve_path::{join_abs_string_buf_z_checked, platform}; use bun_paths::{self, SEP_STR}; use bun_semver::String as SemverString; use bun_sys::{self, Fd}; @@ -120,9 +120,12 @@ impl Scripts { return false; } let mut buf = bun_paths::path_buffer_pool::get(); - let binding_gyp = - join_abs_string_buf_z::(package_dir, &mut buf.0, &[b"binding.gyp"]); - if !bun_sys::exists_z(binding_gyp) { + let binding_gyp = join_abs_string_buf_z_checked::( + package_dir, + &mut buf.0, + &[b"binding.gyp"], + ); + if !binding_gyp.is_some_and(bun_sys::exists_z) { return false; } gypfile.unwrap_or_else(|| read_gypfile(package_dir, &mut buf.0)) @@ -405,7 +408,11 @@ pub(crate) fn gypfile_from_json(json: Expr) -> bool { /// An unreadable package.json keeps the default script. fn read_gypfile(package_dir: &[u8], buf: &mut [u8]) -> bool { - let path = join_abs_string_buf_z::(package_dir, buf, &[b"package.json"]); + let Some(path) = + join_abs_string_buf_z_checked::(package_dir, buf, &[b"package.json"]) + else { + return true; + }; let Ok(json_buf) = bun_sys::File::read_from(Fd::cwd(), path) else { return true; }; diff --git a/src/install/lockfile/bun.lock.rs b/src/install/lockfile/bun.lock.rs index be5e114f5e28..2bd38d7c4bf8 100644 --- a/src/install/lockfile/bun.lock.rs +++ b/src/install/lockfile/bun.lock.rs @@ -2699,7 +2699,11 @@ pub(crate) fn parse_into_binary_lockfile( // keep the path-traversal guard intact at every version. let enforce_safe_tag = tag == ResolutionTag::Github || lockfile_version.at_least(Version::V2); - if enforce_safe_tag && !crate::repository::is_safe_resolved_tag(bun_tag_str) { + let too_long = bun_tag_str.len() > crate::repository::MAX_RESOLVED_TAG_LEN; + if too_long + || (enforce_safe_tag + && !crate::repository::is_safe_resolved_tag(bun_tag_str)) + { fail!( InvalidPackageInfo, at_item(key_loc, bun_tag_idx), diff --git a/src/install/repository.rs b/src/install/repository.rs index 4a5fc642fcfa..d4fd7564f782 100644 --- a/src/install/repository.rs +++ b/src/install/repository.rs @@ -103,6 +103,9 @@ pub(crate) fn locked_scp_like(repo: &[u8]) -> Option> { }) } +/// The tag becomes part of a cache folder name that must fit in a path buffer. +pub(crate) const MAX_RESOLVED_TAG_LEN: usize = 256; + /// `resolved` is the `.bun-tag` value persisted to the lockfile (a commit SHA for /// `git`, or `--` for `github`). It is concatenated into a cache /// directory name and passed to `git checkout`, so it must be a single safe path @@ -110,7 +113,7 @@ pub(crate) fn locked_scp_like(repo: &[u8]) -> Option> { /// option. pub(crate) fn is_safe_resolved_tag(resolved: &[u8]) -> bool { !resolved.is_empty() - && resolved.len() <= 256 + && resolved.len() <= MAX_RESOLVED_TAG_LEN && resolved[0] != b'-' && resolved != b"." && resolved != b".." diff --git a/src/io/PipeReader.rs b/src/io/PipeReader.rs index 6373abeaf783..dcbb385a9310 100644 --- a/src/io/PipeReader.rs +++ b/src/io/PipeReader.rs @@ -812,7 +812,7 @@ impl PosixBufferedReader { } else if streaming { vtable.on_read_chunk( Chunk::Scratch(&scratch[..filled]), - Self::read_state(stop.as_ref(), received_hup), + Self::read_state(stop.as_ref()), ) } else { // SAFETY: caller contract; borrow ends at `;`. @@ -833,7 +833,7 @@ impl PosixBufferedReader { // Moved out so a re-entrant read cannot alias or reallocate it under the consumer. // SAFETY: caller contract; borrow ends at `;`. let mut buffer = unsafe { mem::take(&mut (*this)._buffer) }; - let state = Self::read_state(stop.as_ref(), received_hup); + let state = Self::read_state(stop.as_ref()); if matches!(stop, Some(Stop::Eof | Stop::OverBudget | Stop::Error(_))) { vtable.on_read_chunk(Chunk::Owned(buffer), state) } else { @@ -926,13 +926,11 @@ impl PosixBufferedReader { } } - fn read_state(stop: Option<&Stop>, received_hup: bool) -> ReadState { + fn read_state(stop: Option<&Stop>) -> ReadState { match stop { Some(Stop::Eof | Stop::OverBudget) => ReadState::Eof, Some(Stop::WouldBlock) => ReadState::Drained, - Some(Stop::Error(_)) => ReadState::Progress, - None if received_hup => ReadState::Eof, - None => ReadState::Progress, + Some(Stop::Error(_)) | None => ReadState::Progress, } } @@ -1761,7 +1759,7 @@ impl WindowsBufferedReader { // `iov`) whenever it completes; this reader may be // dropped before then, so the buffer goes with the File. if self.flags.contains(WindowsFlags::HAS_INFLIGHT_READ) { - (*raw).orphaned_read_buf = core::mem::take(&mut self._buffer); + (*raw).orphaned_buf = core::mem::take(&mut self._buffer); } if self.flags.contains(WindowsFlags::CLOSE_HANDLE) { (*raw).detach(); @@ -1841,7 +1839,7 @@ impl WindowsBufferedReader { // linked into the loop's handle queue → UAF. Restore the source // so close_impl can do the proper take + hand-off to libuv // (into_raw + uv_close). close_impl also parks `_buffer` on - // the File for an in-flight uv_fs_read (orphaned_read_buf), + // the File for an in-flight uv_fs_read (orphaned_buf), // which is why `_buffer` is freed after this, not before. self.source = Some(source); self.close_impl::(); diff --git a/src/io/PipeWriter.rs b/src/io/PipeWriter.rs index b298598ee8d3..4c9688d87a8b 100644 --- a/src/io/PipeWriter.rs +++ b/src/io/PipeWriter.rs @@ -1208,6 +1208,21 @@ pub trait BaseWindowsPipeWriter: Sized { fn closed_without_reporting(&self) -> bool; fn set_closed_without_reporting(&mut self, v: bool); + /// Moves out the payload of the last write passed to libuv. Returns an empty Vec if the parent owns it. + fn take_write_payload(&mut self) -> Vec { + Vec::new() + } + + /// Returns true if a `uv_write` on the current pipe or tty source hasn't completed. + fn has_inflight_stream_write(&self) -> bool { + false + } + + /// Returns true if chunks are queued behind the in-flight write. + fn has_queued_write(&self) -> bool { + false + } + /// `uv::open_handles` closes this writer's stream through here at teardown. unsafe fn stop_for_vm_teardown(this: *mut c_void) { // SAFETY: recorded via `Source::set_owner` by this live writer; cleared @@ -1235,6 +1250,11 @@ pub trait BaseWindowsPipeWriter: Sized { } fn close(&mut self) { + self.close_impl(true); + } + + /// If `cancel_inflight_write` is set, cancels a `uv_fs_write` that hasn't started yet. + fn close_impl(&mut self, cancel_inflight_write: bool) { self.set_is_done(true); let Some(source) = self.source_mut().take() else { return; @@ -1261,17 +1281,30 @@ pub trait BaseWindowsPipeWriter: Sized { // SAFETY: raw is heap-allocated by Source::open_file; libuv holds // the only remaining reference via the fs_t it points into. unsafe { + if has_inflight_write { + // The threadpool write still reads the payload, so move it into the File. + (*raw).orphaned_buf = self.take_write_payload(); + } if self.owns_fd() { // Use state machine to handle close after operation completes. // detach() schedules start_close() (now or after the pending // op completes); on_close_complete heap::take()s `raw`. - (*raw).detach(); - } else if !(*raw).detach_borrowed_fd() { - // Idle and the fd is parent-owned: nothing pending, - // nothing to close. Reclaim and drop the Box. - drop(bun_core::heap::take(raw)); + if cancel_inflight_write { + (*raw).detach(); + } else { + (*raw).detach_after_inflight(); + } + } else { + let pending = if cancel_inflight_write { + (*raw).detach_borrowed_fd() + } else { + (*raw).detach_borrowed_fd_after_inflight() + }; + // An idle file with a parent-owned fd has nothing to close. + if !pending { + drop(bun_core::heap::take(raw)); + } } - // else: on_fs_write_complete heap::take()s the detached Box. } } Source::Pipe(pipe) => { @@ -1342,8 +1375,30 @@ pub trait BaseWindowsPipeWriter: Sized { self.start_with_current_pipe() } + /// Closes the old source before a restart, like the POSIX writer. + fn close_current_source(&mut self) -> sys::Result<()> { + match self.source() { + None => {} + // `uv_close` would cancel the `uv_write`, and the write callback would then close the new source. + Some(Source::Pipe(_) | Source::Tty(_)) if self.has_inflight_stream_write() => { + return sys::Result::Err(sys::Error::from_code(sys::E::BUSY, sys::Tag::write)); + } + // Canceling an in-flight write would drop its bytes, so let it finish writing to the old file. + Some(_) => self.close_impl(false), + } + // Free a failed write's payload. `close_impl` already moved out a payload that libuv still reads. + drop(self.take_write_payload()); + // `set_parent` only records the new source's owner while `is_done` is false. + self.set_is_done(false); + sys::Result::Ok(()) + } + fn start_sync(&mut self, fd: Fd, _pollable: bool) -> sys::Result<()> { - debug_assert!(self.source().is_none()); + // A `SyncFile` writes each chunk synchronously and can't drain a queue. + if self.has_queued_write() { + return sys::Result::Err(sys::Error::from_code(sys::E::BUSY, sys::Tag::write)); + } + self.close_current_source()?; let mut source = Source::SyncFile(Source::open_file(fd)); source.set_data(core::ptr::from_mut(self).cast::()); *self.source_mut() = Some(source); @@ -1365,7 +1420,7 @@ pub trait BaseWindowsPipeWriter: Sized { // TODO: MovableIfWindowsFd overload — add a separate start_movable(). fn start(&mut self, rawfd: Fd, _pollable: bool) -> sys::Result<()> { let fd = rawfd; - debug_assert!(self.source().is_none()); + self.close_current_source()?; // Use the event loop from the parent, not the global one // This is critical for spawnSync to use its isolated loop // SAFETY: parent is BACKREF set via set_parent; valid while writer alive. @@ -1546,6 +1601,10 @@ impl BaseWindowsPipeWriter for WindowsBuffe self.closed_without_reporting = v; } + fn has_inflight_stream_write(&self) -> bool { + self.pending_payload_size > 0 + } + fn start_with_current_pipe(&mut self) -> sys::Result<()> { debug_assert!(self.source.is_some()); self.is_done = false; @@ -2095,9 +2154,28 @@ impl BaseWindowsPipeWriter self.closed_without_reporting = v; } + fn take_write_payload(&mut self) -> Vec { + // Moving the Vec doesn't move its heap bytes, so libuv's copy of `write_buffer` still points to them. + self.current_payload.cursor = 0; + mem::take(&mut self.current_payload.list) + } + + fn has_inflight_stream_write(&self) -> bool { + // `on_write_complete` resets it when the `uv_write` completes. + self.current_payload.is_not_empty() + } + + fn has_queued_write(&self) -> bool { + self.outgoing.is_not_empty() + } + fn start_with_current_pipe(&mut self) -> sys::Result<()> { debug_assert!(self.source.is_some()); self.is_done = false; + // The old source's write never reports completion, so send the queued chunks here. + if self.outgoing.is_not_empty() && !matches!(self.source, Some(Source::SyncFile(_))) { + self.process_send(); + } sys::Result::Ok(()) } } diff --git a/src/io/heap.rs b/src/io/heap.rs index 8c98a576f3a2..b1fdc6076c42 100644 --- a/src/io/heap.rs +++ b/src/io/heap.rs @@ -78,59 +78,64 @@ impl> Intrusive { /// Count the number of elements in the heap. This is an O(N) operation. pub unsafe fn count(&self) -> usize { + let mut result: usize = 0; // SAFETY: all reachable nodes from `self.root` are valid for the heap's lifetime. - Self::count_internal(self.root) - } - - unsafe fn count_internal(node: *mut T) -> usize { - if node.is_null() { - return 0; - } - let current = node; - let mut result: usize = 1; - - // Count children - // SAFETY: `current` is non-null and valid (checked above / invariant). - result += Self::count_internal((*current).heap().child); - - // Count siblings - result += Self::count_internal((*current).heap().next); - + self.for_each(|_| result += 1); result } /// Look at the next maximum value but do not remove it. This is an O(N) operation. pub unsafe fn find_max(&self) -> *mut T { - if self.root.is_null() { - return ptr::null_mut(); - } - let root = self.root; - // SAFETY: `root` is non-null and valid. - Self::find_max_internal(&self.context, root, root) + let mut max_so_far = self.root; + // SAFETY: all reachable nodes from `self.root` are valid for the heap's lifetime. + self.for_each(|node| { + if self.context.less(max_so_far, node) { + max_so_far = node; + } + }); + max_so_far } - unsafe fn find_max_internal(ctx: &Context, node: *mut T, current_max: *mut T) -> *mut T { - let mut max_so_far = current_max; - - // Update max if current node is greater - if ctx.less(max_so_far, node) { - max_so_far = node; + /// Visits every node depth-first without recursion. A heap can degenerate + /// into a single chain, so recursing could overflow the stack. + /// + /// # Safety + /// All nodes reachable from `self.root` must be valid. + unsafe fn for_each(&self, mut f: impl FnMut(*mut T)) { + let mut node = self.root; + if node.is_null() { + return; } + 'walk: loop { + f(node); + + // SAFETY: `node` is non-null and valid (invariant). + let child = (*node).heap().child; + if !child.is_null() { + node = child; + continue; + } - // Traverse children - // SAFETY: `node` is a valid heap node (caller invariant). - let child = (*node).heap().child; - if !child.is_null() { - max_so_far = Self::find_max_internal(ctx, child, max_so_far); - } + loop { + let next = (*node).heap().next; + if !next.is_null() { + node = next; + continue 'walk; + } - // Traverse siblings - let next_sibling = (*node).heap().next; - if !next_sibling.is_null() { - max_so_far = Self::find_max_internal(ctx, next_sibling, max_so_far); + // `prev` is the left sibling, or the parent for the leftmost child. + // Climb to the parent and try its `next`. + let mut prev = (*node).heap().prev; + while !prev.is_null() && (*prev).heap().child != node { + node = prev; + prev = (*node).heap().prev; + } + if prev.is_null() { + return; + } + node = prev; + } } - - max_so_far } /// Delete the minimum value from the heap and return it. diff --git a/src/io/lib.rs b/src/io/lib.rs index d6f7ea9a3491..8d4870736197 100644 --- a/src/io/lib.rs +++ b/src/io/lib.rs @@ -785,7 +785,7 @@ pub struct IoRequestLoop { pub(crate) kqueue_fd: Fd, } -// §Concurrency: `OnceLock` for init gate; the singleton itself stays raw because +// §Concurrency: `STAGE` under `INIT_LOCK` is the init gate; the singleton itself stays raw because // the IO thread mutates fields concurrently with `schedule()` callers (which only // touch the lock-free `pending` queue + `waker`), so wrapping the whole struct in a // `Mutex` would be wrong. @@ -793,34 +793,58 @@ pub struct IoRequestLoop { // `ThreadCell` (not `RacyCell`) to encode "IO-thread-only after init" in the // type. `claim()` is invoked from `on_spawn_io_thread`. Cross-thread // `schedule()` callers go through `get_unchecked` and touch only the -// lock-free `pending` + `waker` (see `schedule`); `ONCE` provides the +// lock-free `pending` + `waker` (see `schedule`); `STAGE` provides the // happens-before for init. static LOOP: bun_core::ThreadCell> = bun_core::ThreadCell::new(core::mem::MaybeUninit::uninit()); #[cfg(not(windows))] -static ONCE: std::sync::OnceLock<()> = std::sync::OnceLock::new(); +static INIT_LOCK: bun_threading::Mutex = bun_threading::Mutex::new(); +#[cfg(not(windows))] +static STAGE: core::sync::atomic::AtomicU8 = core::sync::atomic::AtomicU8::new(STAGE_EMPTY); +#[cfg(not(windows))] +const STAGE_EMPTY: u8 = 0; +/// `LOOP` is initialized but the IO thread hasn't started. +#[cfg(not(windows))] +const STAGE_LOADED: u8 = 1; +#[cfg(not(windows))] +const STAGE_RUNNING: u8 = 2; impl IoRequestLoop { + /// Initializes `LOOP`. On failure, closes the fds it opened so a later call can retry. #[cfg(not(windows))] - fn load() { - // SAFETY: called exactly once via `ONCE.get_or_init`; no other access - // until this returns. `get_unchecked` because this runs on the + fn load() -> sys::Result<()> { + #[cfg(not(target_os = "macos"))] + use bun_sys::FdExt as _; + + let waker = Waker::init().map_err(|err| { + let errno = match err { + crate::Error::Sys(errno) => errno, + _ => E::ENOMEM, + }; + sys::Error::from_code(errno, sys::Tag::open) + })?; + let waker_fd = waker.get_fd(); + // SAFETY: `INIT_LOCK` is held and `STAGE` is `STAGE_EMPTY`, so nothing + // else accesses `LOOP`. `get_unchecked` because this runs on the // *spawning* thread, before the IO thread `claim()`s the cell. - let loop_ = unsafe { (*LOOP.get_unchecked()).assume_init_mut() }; - *loop_ = IoRequestLoop { - pending: RequestQueue::default(), - waker: Waker::init().unwrap_or_else(|_| panic!("failed to initialize waker")), - #[cfg(any(target_os = "linux", target_os = "android"))] - epoll_fd: Fd::INVALID, - #[cfg(target_os = "freebsd")] - kqueue_fd: Fd::INVALID, + let loop_ = unsafe { + (*LOOP.get_unchecked()).write(IoRequestLoop { + pending: RequestQueue::default(), + waker, + #[cfg(any(target_os = "linux", target_os = "android"))] + epoll_fd: Fd::INVALID, + #[cfg(target_os = "freebsd")] + kqueue_fd: Fd::INVALID, + }) }; #[cfg(any(target_os = "linux", target_os = "android"))] { let raw = safe_c::epoll_create1(libc::EPOLL_CLOEXEC); if raw < 0 { - panic!("Failed to create epoll file descriptor"); + let err = sys::Error::from_code(sys::get_errno(raw), sys::Tag::epoll_create1); + waker_fd.close(); + return Err(err); } loop_.epoll_fd = Fd::from_native(raw); @@ -835,14 +859,16 @@ impl IoRequestLoop { libc::epoll_ctl( loop_.epoll_fd.native(), linux::EPOLL_CTL_ADD, - loop_.waker.get_fd().native(), + waker_fd.native(), &raw mut epoll, ) }; match sys::get_errno(rc) { E::SUCCESS => {} err => { - bun_core::Output::panic(format_args!("Failed to wait on epoll {:?}", err)) + loop_.epoll_fd.close(); + waker_fd.close(); + return Err(sys::Error::from_code(err, sys::Tag::epoll_ctl)); } } } @@ -852,7 +878,9 @@ impl IoRequestLoop { { let kq = safe_c::kqueue(); if kq < 0 { - panic!("Failed to create kqueue"); + let err = sys::Error::from_code(sys::get_errno(kq), sys::Tag::kqueue); + waker_fd.close(); + return Err(err); } loop_.kqueue_fd = Fd::from_native(kq); // Register the eventfd waker. udata = 0 → Pollable.tag() == .empty, @@ -861,7 +889,7 @@ impl IoRequestLoop { // makes it edge-triggered so we never need to read() the eventfd. // SAFETY: all-zero is a valid kevent (POD). let mut change: KEvent = bun_core::ffi::zeroed(); - change.ident = usize::try_from(loop_.waker.get_fd().native()).expect("int cast"); + change.ident = usize::try_from(waker_fd.native()).expect("int cast"); change.filter = libc::EVFILT_READ; change.flags = libc::EV_ADD | libc::EV_CLEAR; // SAFETY: valid kqueue fd just created; passing 1 change, 0 events. @@ -877,31 +905,50 @@ impl IoRequestLoop { }; match sys::get_errno(rc as isize) { sys::Errno::SUCCESS => {} - err => bun_core::Output::panic(format_args!( - "Failed to register waker on kqueue: {}", - <&'static str>::from(err) - )), + err => { + loop_.kqueue_fd.close(); + waker_fd.close(); + return Err(sys::Error::from_code(err, sys::Tag::kevent)); + } } } - // smaller thread, since it's not doing much. - std::thread::Builder::new() - .stack_size(1024 * 1024 * 2) - .spawn(Self::on_spawn_io_thread) - .unwrap_or_else(|_| panic!("Failed to spawn IO watcher thread")); - // The JoinHandle detaches on drop. + #[cfg(target_os = "macos")] + let _ = (loop_, waker_fd); + Ok(()) } - fn ensure_init() { + /// Starts the IO thread on first use. If that fails, the next call retries. + pub fn ensure_init() -> sys::Result<()> { #[cfg(windows)] { panic!("Do not use this API on windows"); } #[cfg(not(windows))] { - ONCE.get_or_init(|| { - Self::load(); - }); + if STAGE.load(Ordering::Acquire) == STAGE_RUNNING { + return Ok(()); + } + let _guard = INIT_LOCK.lock_guard(); + if STAGE.load(Ordering::Relaxed) == STAGE_EMPTY { + Self::load()?; + STAGE.store(STAGE_LOADED, Ordering::Relaxed); + } + if STAGE.load(Ordering::Relaxed) == STAGE_LOADED { + // smaller thread, since it's not doing much. + // The JoinHandle detaches on drop. + std::thread::Builder::new() + .stack_size(1024 * 1024 * 2) + .spawn(Self::on_spawn_io_thread) + .map_err(|err| { + sys::Error::from_code_int( + err.raw_os_error().unwrap_or(libc::EAGAIN), + sys::Tag::pthread_create, + ) + })?; + STAGE.store(STAGE_RUNNING, Ordering::Release); + } + Ok(()) } } @@ -910,9 +957,9 @@ impl IoRequestLoop { // From here on, only this thread may borrow `IoRequestLoop`; // `ThreadCell` enforces that in debug builds. LOOP.claim(); - // SAFETY: `ONCE` guarantees `LOOP` is initialized before this thread - // is spawned (the spawn in `load()` is sequenced after the store, and - // `OnceLock` provides the cross-thread happens-before). We take a + // SAFETY: `LOOP` is initialized before this thread is spawned (the + // spawn in `ensure_init()` is sequenced after `load()`, and spawning + // a thread provides the cross-thread happens-before). We take a // *shared* `&IoRequestLoop` — never `&mut` — because `schedule()` on // other threads concurrently touches `pending`/`waker` through a // sibling raw pointer derived from `LOOP.get_unchecked()`. A `&mut` @@ -928,12 +975,15 @@ impl IoRequestLoop { /// thread: only touches the lock-free `pending` queue and the /// async-signal-safe `waker`. This is the *only* cross-thread entry /// point — every other `IoRequestLoop` method is IO-thread-only. + /// + /// Callers must call `ensure_init()` and handle its error before scheduling + /// an operation's first request. pub fn schedule(request: &mut Request) { - Self::ensure_init(); + Self::ensure_init().expect("IoRequestLoop::ensure_init() succeeded before schedule()"); debug_assert!(!request.scheduled); request.scheduled = true; let request = core::ptr::NonNull::from(request); - // SAFETY: `ONCE` above established happens-before for `load()`'s + // SAFETY: `STAGE` above established happens-before for `load()`'s // init of `pending`/`waker`. `get_unchecked` (no owner assert) and a // pointer cast of the `repr(transparent)` `MaybeUninit` (not // `as_mut_ptr(&mut self)`) keep this in raw-ptr land: the only @@ -2141,7 +2191,7 @@ pub mod waker { Self { loop_: None } } - pub fn init() -> crate::Result { + pub fn init() -> crate::error::Result { Ok(Self { loop_: Some(bun_ptr::BackRef::from( core::ptr::NonNull::new(bun_uws_sys::WindowsLoop::get()) diff --git a/src/io/openForWriting.rs b/src/io/openForWriting.rs index 8a29721c44d1..ef7904dc1d74 100644 --- a/src/io/openForWriting.rs +++ b/src/io/openForWriting.rs @@ -1,5 +1,4 @@ use bun_core::ZStr; -#[cfg(unix)] use bun_sys::FdExt; use bun_sys::{self, Fd, Mode}; @@ -102,6 +101,9 @@ where { let _ = (is_socket, out_nonblocking, ctx, on_tty, is_pollable); } + // `O_NONBLOCK` opens an asynchronous handle, and `uv_fs_write` fails on those with EINVAL. + #[cfg(windows)] + let input_flags = input_flags & !bun_sys::O::NONBLOCK; // TODO: this should be concurrent. #[cfg(unix)] let mut isatty = false; @@ -170,6 +172,8 @@ where *pollable = (bun_sys::windows::GetFileType(fd.native()) & bun_sys::windows::FILE_TYPE_PIPE) != 0 && !force_sync; - return Ok(fd); + // The libuv writer needs a CRT fd. `Fd::uv()` panics on a HANDLE. + return fd + .make_lib_uv_owned_for_syscall(bun_sys::Tag::open, bun_sys::ErrorCase::CloseOnFail); } } diff --git a/src/io/source.rs b/src/io/source.rs index 9eafd8ffd555..24a79ded814e 100644 --- a/src/io/source.rs +++ b/src/io/source.rs @@ -69,11 +69,8 @@ pub struct File { /// When true, file will close itself when the current operation completes. pub(crate) close_after_operation: bool, - /// A read still in flight when its reader let go of this file (`iov` - /// points into it): the reader's buffer, kept alive here until the - /// detached completion frees the Box, so the pending ReadFile never lands - /// in freed memory. - pub(crate) orphaned_read_buf: Vec, + /// Keeps the buffer of a detached in-flight read (`iov`) or write alive. Freed with the Box. + pub(crate) orphaned_buf: Vec, } #[repr(u8)] @@ -100,7 +97,7 @@ impl Default for File { file: 0, state: FileState::Deinitialized, close_after_operation: false, - orphaned_read_buf: Vec::new(), + orphaned_buf: Vec::new(), } } } @@ -169,9 +166,20 @@ impl File { /// If an operation is in progress, it will complete and then close the file. /// If idle, closes the file immediately. pub(crate) fn detach(&mut self) { + self.detach_impl(true); + } + + /// Like `detach`, but lets an in-flight operation finish instead of canceling it. + pub(crate) fn detach_after_inflight(&mut self) { + self.detach_impl(false); + } + + fn detach_impl(&mut self, cancel_inflight: bool) { self.fs.data = core::ptr::null_mut(); self.close_after_operation = true; - self.stop(); + if cancel_inflight { + self.stop(); + } if self.state == FileState::Deinitialized { self.close_after_operation = false; @@ -183,8 +191,19 @@ impl File { /// operation is in flight (its callback frees the Box); false when idle /// (caller drops the Box). pub(crate) fn detach_borrowed_fd(&mut self) -> bool { + self.detach_borrowed_fd_impl(true) + } + + /// Like `detach_borrowed_fd`, but lets an in-flight operation finish. + pub(crate) fn detach_borrowed_fd_after_inflight(&mut self) -> bool { + self.detach_borrowed_fd_impl(false) + } + + fn detach_borrowed_fd_impl(&mut self, cancel_inflight: bool) -> bool { self.fs.data = core::ptr::null_mut(); - self.stop(); + if cancel_inflight { + self.stop(); + } self.state != FileState::Deinitialized } diff --git a/src/js/builtins.d.ts b/src/js/builtins.d.ts index ed46f83e6eb0..36b78df986ff 100644 --- a/src/js/builtins.d.ts +++ b/src/js/builtins.d.ts @@ -289,7 +289,16 @@ declare function $removeAbortAlgorithmFromSignal(signal: AbortSignal, algorithmI declare function $redirect(): TODO; declare function $relative(): TODO; declare function $require(): TODO; +/** Loads `id` into `mod`. Returns -1 when the module is ESM and must be loaded from the ESM registry. */ +declare function $requireCommonJS( + id: string, + mod: JSCommonJSModule, + parentFilename: unknown, + userArgumentCount: number, + userOptions: unknown, +): JSCommonJSModule | -1; declare function $requireESM(path: string, requirer?: JSCommonJSModule): any; +declare function $requireNativeModule(id: string): any; declare const $requireMap: Map; declare const $internalModuleRegistry: InternalFieldObject; declare function $resolve(name: string, from: string): Promise; diff --git a/src/js/builtins/BunBuiltinNames.h b/src/js/builtins/BunBuiltinNames.h index 192c5c71c4f7..6c110a51cf74 100644 --- a/src/js/builtins/BunBuiltinNames.h +++ b/src/js/builtins/BunBuiltinNames.h @@ -86,6 +86,8 @@ using namespace JSC; macro(filename) \ macro(flush) \ macro(format) \ + macro(globScan) \ + macro(globScanSync) \ macro(handleEvent) \ macro(headers) \ macro(highWaterMark) \ @@ -156,6 +158,7 @@ using namespace JSC; macro(relative) \ macro(removeAbortAlgorithmFromSignal) \ macro(require) \ + macro(requireCommonJS) \ macro(requireESM) \ macro(requireMap) \ macro(requireNativeModule) \ diff --git a/src/js/builtins/BundlerPlugin.ts b/src/js/builtins/BundlerPlugin.ts index 862a7975eb55..d72a544cc3fe 100644 --- a/src/js/builtins/BundlerPlugin.ts +++ b/src/js/builtins/BundlerPlugin.ts @@ -431,6 +431,8 @@ export function runOnResolvePlugins(this: BundlerPlugin, specifier, inputNamespa } for (let [filter, callback] of results) { + // test() on a /g or /y filter starts at lastIndex, which the previous path's match may have advanced. + if (filter.global || filter.sticky) filter.lastIndex = 0; if (filter.test(inputPath)) { var result: ReturnType = callback({ path: inputPath, @@ -545,6 +547,7 @@ export function runOnLoadPlugins( } for (let [filter, callback] of results) { + if (filter.global || filter.sticky) filter.lastIndex = 0; if (filter.test(path)) { var result = callback({ path, diff --git a/src/js/builtins/CommonJS.ts b/src/js/builtins/CommonJS.ts index 2f17303f74e3..bcf8cc07cc01 100644 --- a/src/js/builtins/CommonJS.ts +++ b/src/js/builtins/CommonJS.ts @@ -18,13 +18,15 @@ export function require(this: JSCommonJSModule, _id: string) { } // overridableRequire can be overridden by setting `Module.prototype.require` +// Like Node, `this` is only used as the parent module and can be any value, even null. $overriddenName = "require"; $visibility = "Private"; export function overridableRequire(this: JSCommonJSModule, originalId: string, options?: { paths?: string[] }) { - const id = $resolveSync(originalId, this.filename, false, false, options ? options.paths : undefined, this, options); + // `filename` can be a user getter, so read it once, before the module is added to the require map. + const parentFilename = this?.filename; + const id = $resolveSync(originalId, parentFilename, false, false, options ? options.paths : undefined, this, options); // The global require cache, or the one of the Bun.ModuleGraph this module belongs to. - // (`this` need not be a module: Module.prototype.require.call({ filename }, id).) - const requireMap: RequireMap = this.$requireMap || $requireMap; + const requireMap: RequireMap = this?.$requireMap || $requireMap; if (id.startsWith("node:")) { if (id !== originalId) { // A terrible special case where Node.js allows non-prefixed built-ins to @@ -39,7 +41,7 @@ export function overridableRequire(this: JSCommonJSModule, originalId: string, o } } - return this.$requireNativeModule(id); + return $requireNativeModule(id); } else { const existing = requireMap.$get(id); if (existing) { @@ -76,7 +78,7 @@ export function overridableRequire(this: JSCommonJSModule, originalId: string, o } if (id === "bun:test") { - return Bun.jest(this.filename); + return Bun.jest(parentFilename); } // To handle import/export cycles, we need to create a module object and put @@ -95,9 +97,10 @@ export function overridableRequire(this: JSCommonJSModule, originalId: string, o if (IS_BUN_DEVELOPMENT) { $assert(mod.id === id); try { - out = this.$require( + out = $requireCommonJS( id, mod, + parentFilename, // did they pass a { type } object? $argumentCount(), // the object containing a "type" attribute, if they passed one @@ -109,7 +112,7 @@ export function overridableRequire(this: JSCommonJSModule, originalId: string, o throw E; } } else { - out = this.$require(id, mod, $argumentCount(), $argument(1)); + out = $requireCommonJS(id, mod, parentFilename, $argumentCount(), $argument(1)); } // -1 means we need to lookup the module from the ESM registry. @@ -244,7 +247,6 @@ $visibility = "Private"; // `requireMap`: the global require cache, or a Bun.ModuleGraph's together with one of // the graph's modules (`owner`, which names the loader whose ES modules it also lists). export function createRequireCache(requireMap: RequireMap, owner?: JSCommonJSModule) { - var moduleMap = new Map(); var inner = { [Symbol.for("nodejs.util.inspect.custom")]() { return { ...proxy }; @@ -285,7 +287,6 @@ export function createRequireCache(requireMap: RequireMap, owner?: JSCommonJSMod }, deleteProperty(_target, key: string) { - moduleMap.$delete(key); requireMap.$delete(key); $esmRegistryDelete(key, owner); $evictIsolationSourceProviderCache(key); diff --git a/src/js/builtins/Glob.ts b/src/js/builtins/Glob.ts index 6f35b613268f..d0e4e2ec5b23 100644 --- a/src/js/builtins/Glob.ts +++ b/src/js/builtins/Glob.ts @@ -1,10 +1,10 @@ interface Glob { - $pull(opts); - $resolveSync(opts); + $globScan(opts); + $globScanSync(opts); } export function scan(this: Glob, opts) { - const valuesPromise = this.$pull(opts); + const valuesPromise = this.$globScan(opts); async function* iter() { const values = (await valuesPromise) || []; yield* values; @@ -13,7 +13,7 @@ export function scan(this: Glob, opts) { } export function scanSync(this: Glob, opts) { - const arr = this.$resolveSync(opts) || []; + const arr = this.$globScanSync(opts) || []; function* iter() { yield* arr; } diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index f1234d65d09d..eeffb2c0bdbc 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -90,8 +90,8 @@ export function getStdioWriteStream( stream._isStdio = true; stream.fd = fd; + // This is undefined if a FileSink couldn't be opened for the fd. The stream then writes through node:fs. const underlyingSink = stream[require("internal/fs/streams").kWriteStreamFastPath]; - $assert(underlyingSink); return [stream, underlyingSink]; } diff --git a/src/js/internal-for-testing.ts b/src/js/internal-for-testing.ts index 1b68e0f63f12..d4b1db1bb820 100644 --- a/src/js/internal-for-testing.ts +++ b/src/js/internal-for-testing.ts @@ -151,6 +151,7 @@ export const crash_handler = $rust("crash_handler.rs", "js_bindings.generate") a panic: () => void; rootError: () => void; outOfMemory: () => void; + operatorNew: (nothrow?: boolean) => boolean | undefined; abort: () => void; fastfail: () => void; trap: () => void; @@ -634,6 +635,10 @@ export const arrayBufferViewHasBuffer = $newCppFunction( export const timerInternals = { timerClockMs: $newRustFunction("runtime/timer/Timer.rs", "internal_bindings.timerClockMs", 0), monotonicNowMs: $bindgen("Timer.bind.ts", "monotonicNowMs"), + // Sets the id of this thread's next timer. The counter can only move forward. + setNextTimerId: $newRustFunction("runtime/timer/Timer.rs", "internal_bindings.setNextTimerId", 1) as ( + id: number, + ) => void, }; // Raw datagram descriptor helpers for tests that need an unbound fd (which diff --git a/src/js/internal/cluster/RoundRobinHandle.ts b/src/js/internal/cluster/RoundRobinHandle.ts index 43e192a6e7d0..4fa74dcc6426 100644 --- a/src/js/internal/cluster/RoundRobinHandle.ts +++ b/src/js/internal/cluster/RoundRobinHandle.ts @@ -110,6 +110,12 @@ export default class RoundRobinHandle { return this.all.has(worker.id); } + // Returns the listener's file descriptor, or -1 if it isn't listening yet or remove() already closed it. + get fd() { + const fd = this.server?._handle?.fd; + return typeof fd === "number" ? fd : -1; + } + // With the channel still up the unacked newconn is settled by its ack; once it is gone, a crashed worker's goes to another worker and a disconnected worker's (already settled by it) is dropped. remove(worker, channelGone = false) { if (channelGone) { diff --git a/src/js/internal/cluster/SharedHandle.ts b/src/js/internal/cluster/SharedHandle.ts index cfef69546ab0..aa737ef5b2c9 100644 --- a/src/js/internal/cluster/SharedHandle.ts +++ b/src/js/internal/cluster/SharedHandle.ts @@ -19,7 +19,7 @@ export default class SharedHandle { this.sharedOnly = sharedOnly === true; if (typeof fd === "number" && fd >= 0) { - const err = validateFd(fd); + const err = validateFd(fd, addressType === "udp4" || addressType === "udp6"); if (err !== 0) { this.errno = err; } else { @@ -47,6 +47,12 @@ export default class SharedHandle { return this.workers.has(worker.id); } + // Returns the file descriptor that remove() closes, or -1. + get fd() { + const handle = this.handle; + return handle ? handle.fd : -1; + } + remove(worker) { const workers = this.workers; if (!workers.has(worker.id)) return false; diff --git a/src/js/internal/cluster/primary.ts b/src/js/internal/cluster/primary.ts index cd1adb25dda2..e0a2514ecca5 100644 --- a/src/js/internal/cluster/primary.ts +++ b/src/js/internal/cluster/primary.ts @@ -7,7 +7,9 @@ const { kHandle } = require("internal/shared"); const sendHelper = $newRustFunction("node_cluster_binding.rs", "sendHelperPrimary", 4); const onInternalMessage = $bindgen("node_cluster_binding.bind.ts", "onInternalMessagePrimary"); -const { UV_EINVAL, UV_ENOBUFS } = process.binding("uv"); +const validateFd = $bindgen("node_cluster_binding.bind.ts", "clusterValidateFd"); +const isDgramSocketFd = $bindgen("udp_socket.bind.ts", "dgramIsFdAdopted"); +const { UV_EEXIST, UV_EINVAL, UV_ENOBUFS } = process.binding("uv"); let child_process; let RoundRobinHandle; @@ -240,6 +242,19 @@ function exitedAfterDisconnect(worker, message) { send(worker, { ack: message.seq }); } +// A handle closes its fd when its last worker is removed, so a second handle for the same fd +// would close it while the first handle still uses it. +function isFdInUse(fd: number, isUdp: boolean) { + // SharedHandle rejects a number that isn't a valid fd. + if ((fd | 0) !== fd) return false; + if (isUdp && isDgramSocketFd(fd)) return true; + let inUse = false; + handles.forEach(handle => { + if (handle.fd === fd) inUse = true; + }); + return inUse; +} + function queryServer(worker, message) { // Stop processing if worker already disconnecting if (worker.exitedAfterDisconnect) return; @@ -294,6 +309,16 @@ function queryServer(worker, message) { worker.emit("error", error); return; } + const fd = message.fd; + if (typeof fd === "number" && fd >= 0) { + const isUdp = message.addressType === "udp4" || message.addressType === "udp6"; + if (isFdInUse(fd, isUdp)) { + // Like node, report a wrong fd type before reporting that the fd is in use. + const errno = validateFd(fd, isUdp) || UV_EEXIST; + send(worker, { errno, key, ack: message.seq, data: cachedHandle ? cachedHandle.data : message.data }, null); + return; + } + } if ( schedulingPolicy !== SCHED_RR || message.sharedOnly === true || diff --git a/src/js/internal/perf_hooks/monitorEventLoopDelay.ts b/src/js/internal/perf_hooks/monitorEventLoopDelay.ts index 7466edb75e1d..cdd83a97eac3 100644 --- a/src/js/internal/perf_hooks/monitorEventLoopDelay.ts +++ b/src/js/internal/perf_hooks/monitorEventLoopDelay.ts @@ -5,8 +5,8 @@ const { validateObject, validateInteger } = require("internal/validators"); const cppMonitorEventLoopDelay = $newCppFunction( "JSNodePerformanceHooksHistogramPrototype.cpp", "jsFunction_monitorEventLoopDelay", - 1, -) as (resolution: number) => import("node:perf_hooks").RecordableHistogram; + 0, +) as () => import("node:perf_hooks").RecordableHistogram; const cppEnableEventLoopDelay = $newCppFunction( "JSNodePerformanceHooksHistogramPrototype.cpp", @@ -59,7 +59,7 @@ function monitorEventLoopDelay(options?: { resolution?: number }) { } if (!eventLoopDelayHistogram) { - eventLoopDelayHistogram = cppMonitorEventLoopDelay(resolution); + eventLoopDelayHistogram = cppMonitorEventLoopDelay(); $putByValDirect(eventLoopDelayHistogram, "enable", enable); $putByValDirect(eventLoopDelayHistogram, "disable", disable); $putByValDirect(eventLoopDelayHistogram, Symbol.dispose, disable); diff --git a/src/js/internal/quic/quic.ts b/src/js/internal/quic/quic.ts index dc474e25e4f3..b349306d081c 100644 --- a/src/js/internal/quic/quic.ts +++ b/src/js/internal/quic/quic.ts @@ -2441,7 +2441,7 @@ class QuicStream { */ get priority() { assertIsQuicStream(this); - if (this.destroyed || getQuicSessionState(this.#inner.session).headersSupported === 2) return null; + if (this.destroyed || !getQuicSessionState(this.#inner.session).isPrioritySupported) return null; const packed = this.#handle.getPriority(); const urgency = packed >> 1; const incremental = !!(packed & 1); @@ -3272,7 +3272,7 @@ class QuicSession { throw ERR_QUIC_OPEN_STREAM_FAILED(); } - if (inner.state.headersSupported !== 2) { + if (inner.state.isPrioritySupported) { const urgency = priority === "high" ? 0 : priority === "low" ? 7 : 3; handle.setPriority((urgency << 1) | (incremental ? 1 : 0)); } diff --git a/src/js/internal/streams/native-readable.ts b/src/js/internal/streams/native-readable.ts index 3d62e724416f..a3678564aef0 100644 --- a/src/js/internal/streams/native-readable.ts +++ b/src/js/internal/streams/native-readable.ts @@ -68,7 +68,8 @@ let debugId = 0; function constructNativeReadable(readableStream: ReadableStream, options): NativeReadable { $assert(typeof readableStream === "object" && readableStream instanceof ReadableStream, "Invalid readable stream"); const bunNativePtr = (readableStream as any).$bunNativePtr; - $assert(typeof bunNativePtr === "object", "Invalid native ptr"); + // A closed stream with nothing left to read has no native handle, so read() ends it immediately. + $assert(bunNativePtr === undefined || typeof bunNativePtr === "object", "Invalid native ptr"); const stream = new Readable(options) as NativeReadable; stream._read = read; diff --git a/src/js/internal/webstreams_adapters.ts b/src/js/internal/webstreams_adapters.ts index 4a145ea85655..e10af2c79121 100644 --- a/src/js/internal/webstreams_adapters.ts +++ b/src/js/internal/webstreams_adapters.ts @@ -258,7 +258,8 @@ function newWritableStreamFromStreamWritable(streamWritable) { closed.reject(error); closed = undefined; } - controller.error(error); + // close() clears the controller when the stream has already ended. + controller?.error(error); controller = undefined; return; } @@ -268,7 +269,7 @@ function newWritableStreamFromStreamWritable(streamWritable) { closed = undefined; return; } - controller.error($makeAbortError()); + controller?.error($makeAbortError()); controller = undefined; }); @@ -558,6 +559,8 @@ function newReadableStreamFromStreamReadable(streamReadable, options = kEmptyObj streamReadable.pause(); streamReadable.on("data", function onData(chunk) { + // Buffered data can still be emitted after cancel() destroys the stream. + if (wasCanceled) return; // Copy the Buffer to detach it from the pool. if (Buffer.isBuffer(chunk) && !objectMode) chunk = new Uint8Array(chunk); controller.enqueue(chunk); diff --git a/src/js/node/fs.ts b/src/js/node/fs.ts index deb7b0d87d61..198c6702c3dc 100644 --- a/src/js/node/fs.ts +++ b/src/js/node/fs.ts @@ -283,7 +283,7 @@ var access = function access(path, mode, callback?) { } callback = wrapFsCallback(callback); fs.readCb( - (err, bytesRead) => (err ? callback(err) : callback(null, bytesRead, buffer)), + (err, bytesRead) => (err ? callback(err, 0, buffer) : callback(null, bytesRead, buffer)), fd, buffer, offset, @@ -293,7 +293,7 @@ var access = function access(path, mode, callback?) { }, write = function write(fd, buffer, offsetOrOptions, length?, position?, callback?) { function onWritten(err, bytesWritten) { - if (err) callback(err); + if (err) callback(err, 0, buffer); else callback(null, bytesWritten, buffer); } @@ -589,7 +589,7 @@ var access = function access(path, mode, callback?) { callback = ensureCallback(callback); fs.writevCb( - (err, bytesWritten) => (err ? callback(err) : callback(null, bytesWritten, buffers)), + (err, bytesWritten) => (err ? callback(err, 0, buffers) : callback(null, bytesWritten, buffers)), fd, buffers, position, @@ -604,7 +604,12 @@ var access = function access(path, mode, callback?) { callback = ensureCallback(callback); - fs.readvCb((err, bytesRead) => (err ? callback(err) : callback(null, bytesRead, buffers)), fd, buffers, position); + fs.readvCb( + (err, bytesRead) => (err ? callback(err, 0, buffers) : callback(null, bytesRead, buffers)), + fd, + buffers, + position, + ); }, readvSync = fs.readvSync.bind(fs), Dirent = fs.Dirent, diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index f41ce68605fc..55412679ad97 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -2247,7 +2247,10 @@ function streamOnResume(this: Http2Stream) { // A close() on a stream that has not been submitted yet (no id): the RST_STREAM has to follow the // HEADERS frame, which is sent when the queued request becomes ready (node's finishCloseStream). function sendRstOnReady(this: Http2Stream, session: Http2Session, code: number) { - setImmediate(rstNextTick.bind(session, this.id, code)); + const id = this.id; + // A request rejected by request() emits 'ready' without an id. The peer never saw it, so skip the RST_STREAM. + if (typeof id !== "number") return; + setImmediate(rstNextTick.bind(session, id, code)); } function uncorkNT(stream: Http2Stream) { stream.uncork(); @@ -3056,7 +3059,7 @@ function doSendFileFD(options, fd, headers, err, stat) { if (statOptions.offset <= 0) { statOptions.offset = 0; } - if (statOptions.length <= 0) { + if (statOptions.length < 0) { if (stat.isFile()) { statOptions.length = stat.size; } else { @@ -3081,6 +3084,18 @@ function doSendFileFD(options, fd, headers, err, stat) { statOptions.length < 0 ? stat.size - +statOptions.offset : Math.min(stat.size - +statOptions.offset, statOptions.length); + // Validate here because nothing can catch a throw from fs.createReadStream inside this fstat callback. + let rangeError; + if (!Number.isInteger(statOptions.offset) || statOptions.offset > stat.size) { + rangeError = $ERR_OUT_OF_RANGE("options.offset", `>= 0 && <= ${stat.size}`, statOptions.offset); + } else if (!Number.isInteger(statOptions.length)) { + rangeError = $ERR_OUT_OF_RANGE("options.length", "an integer", statOptions.length); + } + if (rangeError) { + if (ownsFd) tryClose(fd); + this.destroy(rangeError); + return; + } // remove content-length header for (let i in headers) { if (i?.toLowerCase() === HTTP2_HEADER_CONTENT_LENGTH) { @@ -3107,6 +3122,13 @@ function doSendFileFD(options, fd, headers, err, stat) { // _final (END_STREAM / wantTrailers) logic returned here. const finishNativeStream = closeWritableForFileResponse(this); + if (statOptions.length === 0) { + // The file is empty or the offset is at the end. + if (ownsFd) tryClose(fd); + finishNativeStream(() => {}); + return; + } + const stream = this; const fileStream = fs.createReadStream(null, { fd: fd, @@ -3744,9 +3766,14 @@ function scheduleDestroyIfNotDestroyed(target) { setImmediate(destroyIfNotDestroyedNT, target); } } -function rejectNoPayloadContentLengthNT(req) { +// Like node's onStreamClose, a stream that user code already closed keeps its code and doesn't emit 'error'. +function rejectRequestNT(session, req) { + if (req.closed) { + req.destroy(); + return; + } req.rstCode = constants.NGHTTP2_PROTOCOL_ERROR; - req.destroy(streamErrorFromCode(constants.NGHTTP2_PROTOCOL_ERROR)); + emitStreamErrorNT(session, req, constants.NGHTTP2_PROTOCOL_ERROR, true, false); } function emitStreamErrorNT(self, stream, error, destroy, destroy_self) { @@ -6200,8 +6227,7 @@ class ClientHttp2Session extends Http2Session { const req = new ClientHttp2Stream(undefined, this, headers); req.authority = authority; req[kHeadRequest] = method === HTTP2_METHOD_HEAD; - req.rstCode = constants.NGHTTP2_PROTOCOL_ERROR; - process.nextTick(emitStreamErrorNT, this, req, constants.NGHTTP2_PROTOCOL_ERROR, true, false); + process.nextTick(rejectRequestNT, this, req); process.nextTick(emitEventNT, req, "ready"); return req; } @@ -6216,7 +6242,7 @@ class ClientHttp2Session extends Http2Session { const req = new ClientHttp2Stream(undefined, this, headers); req.authority = authority; req[kHeadRequest] = method === HTTP2_METHOD_HEAD; - process.nextTick(rejectNoPayloadContentLengthNT, req); + process.nextTick(rejectRequestNT, this, req); process.nextTick(emitEventNT, req, "ready"); return req; } diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 18f3908bc3cc..fb9552548114 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -4513,18 +4513,24 @@ function closeSocketHandle(self, handle, isException, isCleanupPending = false) // The owner of a wrapped socket destroyed it: the TLS socket goes first, so the close of their fd is nothing to report. handle[kAdoptedTLSRaw]?.destroy(); handle.close(onSocketHandleClosed); - setImmediate(() => { - $debug("emit close", isCleanupPending); - self.emit("close", isException); - if (isCleanupPending) { - // self._handle may have been detached or replaced since. - handle.onread = noop; - if (self._handle === handle) { - self._handle = null; - self._sockname = null; - } - } - }); + // Arguments instead of a closure, so no scope keeps the closed handle alive. + if (isCleanupPending) setImmediate(emitCloseAndDetachHandle, self, isException, handle); + else setImmediate(emitClose, self, isException); +} + +function emitClose(self, isException) { + $debug("emit close"); + self.emit("close", isException); +} + +function emitCloseAndDetachHandle(self, isException, handle) { + emitClose(self, isException); + // self._handle may have been detached or replaced since. + handle.onread = noop; + if (self._handle === handle) { + self._handle = null; + self._sockname = null; + } } // Reformat a native listen error to Node's "listen : " diff --git a/src/js/node/vm.ts b/src/js/node/vm.ts index b17c0e8a4a65..29512e6cd192 100644 --- a/src/js/node/vm.ts +++ b/src/js/node/vm.ts @@ -113,13 +113,33 @@ function runInNewContext(code, context, options) { } if (typeof options === "string") { options = { filename: options }; - } else { - options = { ...options }; } - context = createContext(context, options); + context = createContext(context, getContextOptions(options)); + options = { ...options }; return createScript(code, options).runInNewContext(context, options); } +// Reads and validates options in the same order as getContextOptions in Node's lib/vm.js. +function getContextOptions(options) { + if (!options) return {}; + const name = options.contextName; + const origin = options.contextOrigin; + const microtaskMode = options.microtaskMode; + if (name !== undefined) validateString(name, "options.contextName"); + if (origin !== undefined) validateString(origin, "options.contextOrigin"); + let codeGeneration; + const contextCodeGeneration = options.contextCodeGeneration; + if (contextCodeGeneration !== undefined) { + validateObject(contextCodeGeneration, "options.contextCodeGeneration"); + const { strings, wasm } = contextCodeGeneration; + if (strings !== undefined) validateBoolean(strings, "options.contextCodeGeneration.strings"); + if (wasm !== undefined) validateBoolean(wasm, "options.contextCodeGeneration.wasm"); + codeGeneration = { strings, wasm }; + } + if (microtaskMode !== undefined) validateString(microtaskMode, "options.microtaskMode"); + return { name, origin, codeGeneration, microtaskMode, importModuleDynamically: options.importModuleDynamically }; +} + function createScript(code, options) { return new Script(code, options); } diff --git a/src/js/node/zlib.ts b/src/js/node/zlib.ts index b8758e67adba..46e3cfe5deac 100644 --- a/src/js/node/zlib.ts +++ b/src/js/node/zlib.ts @@ -232,6 +232,7 @@ function ZlibBase(opts, mode, handle, { flush, finishFlush, fullFlush }) { this._defaultFlushFlag = flush; this._finishFlushFlag = finishFlush; this._defaultFullFlushFlag = fullFlush; + this._flushBoundIdx = flushBoundIdx; this._info = opts && opts.info; this._maxOutputLength = maxOutputLength; } @@ -305,6 +306,8 @@ ZlibBase.prototype.flush = function (kind, callback) { callback = kind; kind = this._defaultFullFlushFlag; } + const flushBound = FLUSH_BOUND[this._flushBoundIdx]; + kind = checkRangesOrGetDefault(kind, "kind", flushBound[0], flushBound[1], this._defaultFullFlushFlag); if (this.writableFinished) { if (callback) process.nextTick(callback); } else if (this.writableEnded) { diff --git a/src/js/private.d.ts b/src/js/private.d.ts index 361eb88fd973..55f4fdd073bc 100644 --- a/src/js/private.d.ts +++ b/src/js/private.d.ts @@ -226,8 +226,6 @@ type RequireMap = Map; interface JSCommonJSModule { /** The require cache the module reads and writes: the global one, or its Bun.ModuleGraph's. */ readonly $requireMap: RequireMap; - $require(id: string, mod: any, args_count: number, args: Array): any; - $requireNativeModule(id: string): any; children: JSCommonJSModule[]; exports: any; id: string; diff --git a/src/js/thirdparty/ws.ts b/src/js/thirdparty/ws.ts index 361141b5a3f6..59de96ea03c2 100644 --- a/src/js/thirdparty/ws.ts +++ b/src/js/thirdparty/ws.ts @@ -1681,6 +1681,8 @@ const receiverErrors = { ], WS_ERR_UNSUPPORTED_MESSAGE_LENGTH: [RangeError, "Max payload size exceeded", false, 1009], Z_DATA_ERROR: [Error, "invalid compressed data", false, 1007], + // Not an upstream ws code. Allocating a buffer for the message failed. + ERR_MEMORY_ALLOCATION_FAILED: [RangeError, "Out of memory", false, 1011], } as const; type ReceiverErrorCode = Exclude; @@ -1698,6 +1700,7 @@ const serverFailures: (ReceiverErrorCode | undefined)[] = [ "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH", "WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH", "Z_DATA_ERROR", + "ERR_MEMORY_ALLOCATION_FAILED", ]; function createReceiverError(code: ReceiverErrorCode, detail: string | undefined): ReceiverError { diff --git a/src/js_parser/function_identities.rs b/src/js_parser/function_identities.rs index 174a37eb3ad8..94d65f0499b0 100644 --- a/src/js_parser/function_identities.rs +++ b/src/js_parser/function_identities.rs @@ -11,7 +11,7 @@ use bun_ast::{self as ast, Expr, G, Ref, Stmt, StmtOrExpr, b::B}; use bun_collections::HashMap; use bun_wyhash::Wyhash; -use crate::parse::parse_entry::ParsedOnly; +use crate::parse::parse_entry::{ParsedOnly, parse_only_text}; /// Which of the functions that may start at one place: JavaScriptCore's `OrderFunctionKind`. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] @@ -90,21 +90,12 @@ impl FunctionIdentities { { return None; } - 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"chunk.js"[..], text); - let mut options = crate::ParserOptions::init(Default::default(), bun_ast::Loader::Js); - options.features.no_macros = true; - options.features.top_level_await = kind == Text::Module; - options.jsc_builtin_syntax = kind == Text::Builtin; - options.suppress_warnings_about_weird_code = true; - let define = crate::Define::default(); - let mut log = bun_ast::Log::init(); - let parser = crate::Parser::init(options, &mut log, &source, &define, &arena).ok()?; - let mut identities = parser - .parse_only(|parsed| Walker::run(parsed, kind, chunk_paths)) - .ok()?; + let mut identities = parse_only_text( + text, + kind == Text::Module, + kind == Text::Builtin, + |parsed| Walker::run(parsed, kind, chunk_paths), + )?; if let Some(first_non_ascii) = first_non_ascii { starts_in_code_units(&mut identities.functions, text, first_non_ascii as usize); } diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index c285fbfb3a8e..fc6365ca4e40 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -5,7 +5,7 @@ use bun_alloc::ArenaVecExt as _; use bun_collections::{HashMap, VecExt}; use crate::lexer as js_lexer; -use crate::p::P; +use crate::p::{BinaryExpressionSimplifyVisitor, P}; use crate::parser::{ARGUMENTS_STR as arguments_str, Ref, TempRef}; use bun_ast::g::{DeclList, Property, PropertyKind}; use bun_ast::{self as js_ast, B, E, Expr, ExprNodeList, Flags, G, S, Stmt}; @@ -272,6 +272,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } fn rewrite_private_accesses_in_expr(&mut self, expr: &mut Expr, map: &PrivateLoweredMap) { + if !self.stack_check.is_safe_to_recurse() || self.reported_stack_overflow.get() { + self.report_stack_overflow(expr.loc); + return; + } let expr_loc = expr.loc; match &mut expr.data { js_ast::ExprData::EIndex(e) => { @@ -315,12 +319,23 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } } - let mut l = e.left; - self.rewrite_private_accesses_in_expr(&mut l, map); - e.left = l; - let mut r = e.right; - self.rewrite_private_accesses_in_expr(&mut r, map); - e.right = r; + // Walk a left-nested chain of binary expressions with a loop instead of recursion. + // Every node the loop skips has a binary `left`, so it can't match the cases above. + let stack_bottom = self.binary_expression_simplify_stack.len(); + let mut binary = *e; + while let js_ast::ExprData::EBinary(left) = binary.left.data + && matches!(left.left.data, js_ast::ExprData::EBinary(_)) + { + self.binary_expression_simplify_stack + .push(BinaryExpressionSimplifyVisitor { bin: binary }); + binary = left; + } + self.rewrite_private_accesses_in_expr(&mut binary.left, map); + self.rewrite_private_accesses_in_expr(&mut binary.right, map); + while self.binary_expression_simplify_stack.len() > stack_bottom { + let mut parent = self.binary_expression_simplify_stack.pop().unwrap().bin; + self.rewrite_private_accesses_in_expr(&mut parent.right, map); + } } js_ast::ExprData::ECall(e) => { if let js_ast::ExprData::EIndex(tgt_idx) = &mut e.target.data { @@ -493,6 +508,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O binding: js_ast::Binding, map: &PrivateLoweredMap, ) { + if !self.stack_check.is_safe_to_recurse() || self.reported_stack_overflow.get() { + self.report_stack_overflow(binding.loc); + return; + } match binding.data { js_ast::b::B::BArray(mut array) => { for item in array.items_mut() { @@ -575,6 +594,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } fn rewrite_private_accesses_in_stmts(&mut self, stmts: &mut [Stmt], map: &PrivateLoweredMap) { + if !self.stack_check.is_safe_to_recurse() || self.reported_stack_overflow.get() { + self.report_stack_overflow(stmts.first().map_or(bun_ast::Loc::EMPTY, |stmt| stmt.loc)); + return; + } for stmt_item in stmts.iter_mut() { match &mut stmt_item.data { js_ast::StmtData::SExpr(data) => { diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index fa4358ab4718..bd0f56678462 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -572,6 +572,7 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { pub(crate) is_inside_single_stmt_body: bool, /// We must be careful to avoid revisiting nodes that have scopes. + /// A revisited node can be part of a `define` value, which is shared with other parses. pub(crate) is_revisit_for_substitution: bool, pub(crate) method_call_must_be_replaced_with_undefined: bool, @@ -1001,84 +1002,58 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // `&mut P` — avoids the aliased-`&mut` that arises when a transposer // *field* holds `&mut self` while a `&mut P` is materialised inside the // visitor (PORTING.md §Forbidden). - pub(crate) fn maybe_transpose_if_import(&mut self, arg: Expr, state: &TransposeState) -> Expr { - match arg.data { - js_ast::ExprData::EIf(ex) => Expr::init( - E::If { - yes: self.maybe_transpose_if_import(ex.yes, state), - no: self.maybe_transpose_if_import(ex.no, state), - test: ex.test, - }, - arg.loc, - ), - _ => self.transpose_import(arg, state), + /// Applies `leaf` to every branch of a nested ternary. Loops over the `no` branches and only + /// recurses into `yes`. Returns `None` after logging a stack overflow. + fn transpose_each_branch( + &mut self, + arg: Expr, + leaf: &mut impl FnMut(&mut Self, Expr) -> Expr, + ) -> Option { + let js_ast::ExprData::EIf(first) = arg.data else { + return Some(leaf(self, arg)); + }; + if !self.stack_check.is_safe_to_recurse() || self.reported_stack_overflow.get() { + self.report_stack_overflow(arg.loc); + return None; } - } - - pub(crate) fn maybe_transpose_if_require(&mut self, arg: Expr, state: &TransposeState) -> Expr { - match arg.data { - js_ast::ExprData::EIf(ex) => Expr::init( - E::If { - yes: self.maybe_transpose_if_require(ex.yes, state), - no: self.maybe_transpose_if_require(ex.no, state), - test: ex.test, - }, - arg.loc, - ), - _ => self.transpose_require(arg, state), + let root = Expr::init(E::If { ..*first }, arg.loc); + let mut link = root.data.e_if().expect("infallible: variant checked"); + loop { + link.yes = self.transpose_each_branch(link.yes, leaf)?; + let no = link.no; + let js_ast::ExprData::EIf(next) = no.data else { + link.no = leaf(self, no); + return Some(root); + }; + link.no = Expr::init(E::If { ..*next }, no.loc); + link = link.no.data.e_if().expect("infallible: variant checked"); } } - pub(crate) fn transpose_known_to_be_if_require( + pub(crate) fn maybe_transpose_if_import( &mut self, arg: Expr, state: &TransposeState, - ) -> Expr { - // Caller guarantees `arg.data` is `EIf`. - let js_ast::ExprData::EIf(ex) = arg.data else { - unreachable!() - }; - Expr::init( - E::If { - yes: self.maybe_transpose_if_require(ex.yes, state), - no: self.maybe_transpose_if_require(ex.no, state), - test: ex.test, - }, - arg.loc, - ) + ) -> Option { + self.transpose_each_branch(arg, &mut |p, branch| p.transpose_import(branch, state)) } - pub(crate) fn maybe_transpose_if_require_resolve(&mut self, arg: Expr, state: Expr) -> Expr { - match arg.data { - js_ast::ExprData::EIf(ex) => Expr::init( - E::If { - yes: self.maybe_transpose_if_require_resolve(ex.yes, state), - no: self.maybe_transpose_if_require_resolve(ex.no, state), - test: ex.test, - }, - arg.loc, - ), - _ => self.transpose_require_resolve(arg, state), - } + pub(crate) fn maybe_transpose_if_require( + &mut self, + arg: Expr, + state: &TransposeState, + ) -> Option { + self.transpose_each_branch(arg, &mut |p, branch| p.transpose_require(branch, state)) } - pub(crate) fn transpose_known_to_be_if_require_resolve( + pub(crate) fn maybe_transpose_if_require_resolve( &mut self, arg: Expr, state: Expr, - ) -> Expr { - // Caller guarantees `arg.data` is `EIf`. - let js_ast::ExprData::EIf(ex) = arg.data else { - unreachable!() - }; - Expr::init( - E::If { - yes: self.maybe_transpose_if_require_resolve(ex.yes, state), - no: self.maybe_transpose_if_require_resolve(ex.no, state), - test: ex.test, - }, - arg.loc, - ) + ) -> Option { + self.transpose_each_branch(arg, &mut |p, branch| { + p.transpose_require_resolve(branch, state) + }) } /// Record the destructured property names of an `await import()` / @@ -1300,14 +1275,18 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O /// something else. pub(crate) fn conditional_namespace_records( &mut self, - expr: Expr, + mut expr: Expr, out: &mut Vec, ) -> Option<()> { - match expr.data { - js_ast::ExprData::EIf(e) => { - self.conditional_namespace_records(e.yes, out)?; - self.conditional_namespace_records(e.no, out) + while let js_ast::ExprData::EIf(e) = expr.data { + if !self.stack_check.is_safe_to_recurse() || self.reported_stack_overflow.get() { + self.report_stack_overflow(expr.loc); + return None; } + self.conditional_namespace_records(e.yes, out)?; + expr = e.no; + } + match expr.data { js_ast::ExprData::ERequireString(req) if self.options.bundle && req.unwrapped_id.get().is_none() => { @@ -2137,15 +2116,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O /// returns. A block `var n` hoisted onto a parameter `n` is a linked ref /// of the same variable. pub(crate) fn record_assignment(&mut self, ref_: Ref) { - let mut ref_ = ref_; - loop { - let symbol = &mut self.symbols[ref_.inner_index() as usize]; - if !symbol.has_link() { - symbol.set_has_been_assigned_to(true); - return; - } - ref_ = symbol.link.get(); - } + let root = js_ast::symbol::follow_symbols(&self.symbols, ref_); + self.symbols[root.inner_index() as usize].set_has_been_assigned_to(true); } /// See `Symbol::import_used_as_value`. @@ -5514,11 +5486,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } + /// Returns `false` if visiting removed every statement and no part was pushed. pub(crate) fn append_part( &mut self, parts: &mut ListManaged<'a, js_ast::Part>, stmts: &'a mut [Stmt], - ) -> Result<(), crate::Error> { + ) -> Result { // Uses recorded outside a part's visit (the parse pass resolving // decorator-metadata types) belong to no part. self.part_uses.clear(); @@ -5543,12 +5516,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O for i in 0..self.relocated_top_level_vars.len() { // Follow links because "var" declarations may be merged due to hoisting let mut local = self.relocated_top_level_vars[i]; - while !local.ref_.is_empty() { - let symbol = &self.symbols[local.ref_.inner_index() as usize]; - if !symbol.has_link() { - break; - } - local.ref_ = symbol.link.get(); + if !local.ref_.is_empty() { + local.ref_ = js_ast::symbol::follow_symbols(&self.symbols, local.ref_); } self.relocated_top_level_vars[i] = local; let Some(ref_) = local.ref_.to_nullable() else { @@ -5580,7 +5549,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.relocated_top_level_vars.clear(); } - if !part_stmts.is_empty() { + let appended = !part_stmts.is_empty(); + if appended { // SAFETY: `into_bump_slice_mut` leaks the BumpVec into the arena and // returns the unique `&'a mut [T]` for that allocation. We compute // `can_be_removed_if_unused` while the `&mut` is live (reborrowed as @@ -5639,7 +5609,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.declared_symbols.clear_retaining_capacity(); self.import_records_for_current_part.clear(); } - Ok(()) + Ok(appended) } /// A pattern runs getters or the iterator on its value, so only a literal value is side-effect free. @@ -6880,16 +6850,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O stmts_inside_closure: &'a mut [Stmt], all_values_are_pure: bool, ) -> Result<(), crate::Error> { - let mut name_ref = original_name_ref; - // Follow the link chain in case symbols were merged - let mut symbol = &self.symbols[name_ref.inner_index() as usize]; - while symbol.has_link() { - let link = symbol.link.get(); - name_ref = link; - symbol = &self.symbols[name_ref.inner_index() as usize]; - } - let symbol_kind = symbol.kind; + let name_ref = js_ast::symbol::follow_symbols(&self.symbols, original_name_ref); + let symbol_kind = self.symbols[name_ref.inner_index() as usize].kind; let arena = self.arena; // Make sure to only emit a variable once for a given namespace, since there @@ -7143,6 +7106,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O js_ast::ExprData::EString(str_) => { return self.new_expr(&*str_, loc); } + // The define table is shared by every parse and later passes mutate AST nodes in place, + // so clone the value. + js_ast::ExprData::EObject(_) | js_ast::ExprData::EArray(_) => { + let shared = Expr { data: value, loc }; + if let Ok(copy) = shared.deep_clone_no_detach(self.arena, self.stack_check) { + return copy; + } + } _ => {} } Expr { data: value, loc } @@ -7151,40 +7122,40 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // `parts` is `&[Box<[u8]>]` to match the active `DotDefine.parts: // Vec>` shape (auto-derefs at call sites). The full draft uses // `StoreSlice`; both index to a `[u8]` so the body is unchanged. - pub(crate) fn is_dot_define_match(&mut self, expr: Expr, parts: &[Box<[u8]>]) -> bool { - match expr.data { - js_ast::ExprData::EDot(ex) => { - if parts.len() > 1 { - if ex.optional_chain.is_some() { - return false; - } - // Intermediates must be dot expressions - let last = parts.len() - 1; - let is_tail_match = strings::eql(&parts[last], &ex.name); - return is_tail_match && self.is_dot_define_match(ex.target, &parts[..last]); + pub(crate) fn is_dot_define_match(&mut self, mut expr: Expr, mut parts: &[Box<[u8]>]) -> bool { + while parts.len() > 1 { + let last = parts.len() - 1; + // Intermediates must be dot expressions + if let js_ast::ExprData::EDot(ex) = expr.data { + if ex.optional_chain.is_some() || !strings::eql(&parts[last], &ex.name) { + return false; } - } - js_ast::ExprData::EImportMeta(_) => { - return parts.len() == 2 && &*parts[0] == b"import" && &*parts[1] == b"meta"; + expr = ex.target; + parts = &parts[..last]; + continue; } // Note: this behavior differs from esbuild // esbuild does not try to match index accessors // we do, but only if it's a UTF8 string // the intent is to handle people using this form instead of E.Dot. So we really only want to do this if the accessor can also be an identifier - js_ast::ExprData::EIndex(index) => { - if parts.len() > 1 { - if let js_ast::ExprData::EString(mut s) = index.index.data { - if s.is_utf8() { - if index.optional_chain.is_some() { - return false; - } - let last = parts.len() - 1; - let is_tail_match = strings::eql(&parts[last], s.slice(self.arena)); - return is_tail_match - && self.is_dot_define_match(index.target, &parts[..last]); - } - } + if let js_ast::ExprData::EIndex(index) = expr.data + && let js_ast::ExprData::EString(mut s) = index.index.data + && s.is_utf8() + { + if index.optional_chain.is_some() + || !strings::eql(&parts[last], s.slice(self.arena)) + { + return false; } + expr = index.target; + parts = &parts[..last]; + continue; + } + break; + } + match expr.data { + js_ast::ExprData::EImportMeta(_) => { + return parts.len() == 2 && &*parts[0] == b"import" && &*parts[1] == b"meta"; } js_ast::ExprData::EIdentifier(ex) => { // The last expression must be an identifier @@ -7327,6 +7298,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } } + // The visit pass skipped this method after a stack overflow. + js_ast::ExprData::EMissing(_) + if self.reported_stack_overflow.get() => {} _ => unreachable!(), } } @@ -7690,6 +7664,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ) { use js_ast::g::PropertyKind; + // The visit pass skipped this method after a stack overflow. + if self.reported_stack_overflow.get() && prop.value.is_some_and(|value| value.is_missing()) + { + return; + } + // Local helper: bump-alloc an arg pair and call __legacyMetadataTS. // pulled out of the per-arm code to cut a ~3x repetition. macro_rules! push_metadata { @@ -9252,12 +9232,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // If this symbol was merged, use the symbol at the end of the // linked list in the map. This is the case for multiple "var" // declarations with the same name, for example. - let mut r#ref = input; - let mut symbol_ref = &ctx.symbols[r#ref.inner_index() as usize]; - while symbol_ref.has_link() { - r#ref = symbol_ref.link.get(); - symbol_ref = &ctx.symbols[r#ref.inner_index() as usize]; - } + let r#ref = js_ast::symbol::follow_symbols(ctx.symbols, input); ctx.top_level .entry(r#ref) @@ -9274,15 +9249,22 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O .push(js_ast::NAMESPACE_EXPORT_PART_INDEX); } + let force_cjs_to_esm = self.unwrap_all_requires + || exports_kind == js_ast::ExportsKind::EsmWithDynamicFallbackFromCjs; + let wrapper_ref: Ref = 'brk: { if self.options.features.hot_module_reloading { break 'brk self.hmr_api_ref; } // When code splitting is enabled, always create wrapper_ref to match esbuild behavior. + // The linker wraps every CommonJS file and may wrap a FORCE_CJS_TO_ESM file, so those need one too. // Otherwise, use needsWrapperRef() to optimize away unnecessary wrappers. if self.options.bundle - && (self.options.code_splitting || self.needs_wrapper_ref(parts.as_slice())) + && (self.options.code_splitting + || exports_kind == js_ast::ExportsKind::Cjs + || force_cjs_to_esm + || self.needs_wrapper_ref(parts.as_slice())) { use core::fmt::Write as _; let mut buf = bun_alloc::ArenaString::new_in(arena); @@ -9363,9 +9345,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let parts_list = core::mem::replace(parts, BumpVec::new_in(arena)); let import_records = self.import_records.move_to_baby_list(arena); - let force_cjs_to_esm = self.unwrap_all_requires - || exports_kind == js_ast::ExportsKind::EsmWithDynamicFallbackFromCjs; - // PERF: box at the construction site so the ~1 KB `Ast` is written // straight into the heap allocation and only the thin `Box` pointer is // returned up the `_parse → parse → cache → transpiler` chain (see diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index 62ce6aa7b3ab..b9ca9e1745df 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -91,6 +91,42 @@ impl<'a> ParsedOnly<'_, 'a> { .get(import_record_index as usize) .map_or(&[], |record| record.path.text) } + pub fn module_syntax(&self) -> ModuleSyntax { + ModuleSyntax { + import_meta: self.p.has_import_meta, + top_level_await: self.p.top_level_await_keyword.len > 0, + } + } +} + +/// Module record flags found while parsing. +#[derive(Clone, Copy, Default)] +pub struct ModuleSyntax { + pub import_meta: bool, + pub top_level_await: bool, +} + +/// Runs `Parser::parse_only` on JavaScript text that isn't one of the build's files. Returns `None` +/// on a parse error. +pub fn parse_only_text( + text: &[u8], + top_level_await: bool, + jsc_builtin_syntax: bool, + f: impl FnOnce(&ParsedOnly<'_, '_>) -> R, +) -> Option { + 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"chunk.js"[..], text); + let mut options = Options::init(Default::default(), bun_ast::Loader::Js); + options.features.no_macros = true; + options.features.top_level_await = top_level_await; + options.jsc_builtin_syntax = jsc_builtin_syntax; + options.suppress_warnings_about_weird_code = true; + let define = Define::default(); + let mut log = bun_ast::Log::init(); + let parser = Parser::init(options, &mut log, &source, &define, &arena).ok()?; + parser.parse_only(f).ok() } pub struct Parser<'a> { @@ -1163,11 +1199,11 @@ impl<'a> Parser<'a> { let should_move = !p.options.bundle && class.class.can_be_moved(); let sliced = arena.alloc_slice_copy(&[*stmt]); - p.append_part(&mut parts, sliced)?; + let appended = p.append_part(&mut parts, sliced)?; - if should_move { + if should_move && appended { // `Part` isn't `Copy`; pop+push instead of last+truncate. - before.push(parts.pop().expect("unreachable")); + before.push(parts.pop().expect("infallible: part was appended")); } } js_ast::StmtData::SExportDefault(value) => { @@ -1176,10 +1212,10 @@ impl<'a> Parser<'a> { // https://github.com/oven-sh/bun/issues/1961 let should_move = !p.options.bundle && value.can_be_moved(); let sliced = arena.alloc_slice_copy(&[*stmt]); - p.append_part(&mut parts, sliced)?; + let appended = p.append_part(&mut parts, sliced)?; - if should_move { - before.push(parts.pop().expect("unreachable")); + if should_move && appended { + before.push(parts.pop().expect("infallible: part was appended")); } } js_ast::StmtData::SEnum(_) => { diff --git a/src/js_parser/parse/parse_property.rs b/src/js_parser/parse/parse_property.rs index a0a597a0bab2..49a59202aa79 100644 --- a/src/js_parser/parse/parse_property.rs +++ b/src/js_parser/parse/parse_property.rs @@ -494,6 +494,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O && p.lexer.token == T::TOpenBrace && name == b"static" { + if let Some(decorator) = opts.ts_decorators.slice().first() { + p.log().add_error( + Some(p.source), + decorator.loc, + b"Decorators are not valid here", + ); + return Err(crate::Error::SyntaxError); + } let loc = p.lexer.loc(); p.lexer.next()?; diff --git a/src/js_parser/parse/parse_stmt.rs b/src/js_parser/parse/parse_stmt.rs index e3608921ec89..a3c552c74ac6 100644 --- a/src/js_parser/parse/parse_stmt.rs +++ b/src/js_parser/parse/parse_stmt.rs @@ -1849,6 +1849,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O && p.lexer.token == T::TOpenBrace { p.lexer.next()?; + opts.ts_decorators = None; let _ = p.parse_stmts_up_to(T::TCloseBrace, opts)?; p.lexer.next()?; return Ok(Some(p.s(S::TypeScript {}, loc))); @@ -1881,6 +1882,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.lexer.next()?; p.lexer.expect(T::TOpenBrace)?; let scope_index = p.scopes_in_order.len(); + // The decorators belong to this statement, not to the statements in its body. + opts.ts_decorators = None; let _ = p.parse_stmts_up_to(T::TCloseBrace, opts)?; p.lexer.next()?; // The statements inside are dropped, so discard any scopes they diff --git a/src/js_parser/parser.rs b/src/js_parser/parser.rs index 455c70b23813..845a1db56b5f 100644 --- a/src/js_parser/parser.rs +++ b/src/js_parser/parser.rs @@ -123,7 +123,6 @@ pub mod options { /// §Forbidden re: `&'static [T]` for arena-freed data. #[derive(Clone, Default)] pub struct Framework { - pub is_built_in_react: bool, pub server_components: Option, pub react_fast_refresh: Option, } diff --git a/src/js_parser/scan/scan_imports.rs b/src/js_parser/scan/scan_imports.rs index e81ae9a621a8..64221b000261 100644 --- a/src/js_parser/scan/scan_imports.rs +++ b/src/js_parser/scan/scan_imports.rs @@ -348,18 +348,11 @@ impl<'a> ImportScanner<'a> { // member, while generated symbols (e.g. JSX runtime imports) // link the other way. let symbol = &p.symbols[name_ref.inner_index() as usize]; - let mut link = symbol.link.get(); - if !link.is_valid() { + if !symbol.has_link() { continue; } // Follow the chain of replacements to the live symbol. - loop { - let next = p.symbols[link.inner_index() as usize].link.get(); - if !next.is_valid() { - break; - } - link = next; - } + let link = js_ast::symbol::follow_symbols(&p.symbols, name_ref); // SAFETY: arena-owned slice valid for 'p. let name = symbol.original_name.slice(); let member = p diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index 27340fd1cef1..2a86c7bf754f 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -280,6 +280,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O is_export: bool, ) -> usize { let was_const = kind == LocalKind::KConst; + // A namespace member isn't a module export. + let can_replace_exports = + IS_POSSIBLY_DECL_TO_REMOVE && self.enclosing_namespace_arg_ref.is_none(); let mut j: usize = 0; // Iterate by index so kept entries can be written back through `decls[j]` // while scanning ahead. @@ -307,7 +310,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut replacement: Option< bun_ptr::BackRef, > = None; - if IS_POSSIBLY_DECL_TO_REMOVE { + if can_replace_exports { if let BData::BIdentifier(id) = decl.binding.data { let id_ref = id.r#ref; let name = self.load_name_from_ref(id_ref); @@ -574,7 +577,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - if IS_POSSIBLY_DECL_TO_REMOVE { + if can_replace_exports { self.is_control_flow_dead = orig_dead; self.in_deleted_export = orig_in_deleted; if let BData::BIdentifier(_) = decl.binding.data { @@ -602,7 +605,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O }, }, ); - } else if IS_POSSIBLY_DECL_TO_REMOVE { + } else if can_replace_exports { if let BData::BIdentifier(id) = decl.binding.data { let id_ref = id.r#ref; let name = self.load_name_from_ref(id_ref); @@ -617,18 +620,17 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // which is not mutated during the visit pass. let replacer = _ptr.get(); if !self.replace_decl_and_possibly_remove(decl, replacer) { - let is_after = self.vis_scope().is_after_const_local_prefix; - self.visit_decl( - decl, - VisitDeclOpts { - was_anonymous_named_expr: false, - could_be_const_value: was_const && !is_after, - could_be_macro: false, - }, - ); - } else { continue 'outer; } + let is_after = self.vis_scope().is_after_const_local_prefix; + self.visit_decl( + decl, + VisitDeclOpts { + was_anonymous_named_expr: false, + could_be_const_value: was_const && !is_after, + could_be_macro: false, + }, + ); } } } diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 5bd49840e95d..6adaa3666631 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -42,6 +42,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O pub(crate) fn visit_expr_in_out(&mut self, e: &mut Expr, in_: ExprIn) { if !self.stack_check.is_safe_to_recurse() || self.reported_stack_overflow.get() { + // The caller assumes `e` was visited, so replace it. Don't mutate it on a revisit + // because it can be part of a shared `define` value. + if !self.is_revisit_for_substitution { + e.data = Data::EMissing(E::Missing {}); + } self.report_stack_overflow(e.loc); return; } @@ -470,12 +475,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O None }; - // `props` may be reassigned to point inside a spread object's - // properties. Track it as a `StoreRef` (safe `Deref`/`DerefMut`) so - // the spread-collapse walk and the `push`/`take` calls below stay - // in safe code. - let mut props_handle = js_ast::StoreRef::from_bump(&mut e_.properties); - // arguments needs to be like // { // ...props, @@ -508,34 +507,39 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // -> //
// jsx("div", {...foo}) + let mut spread_object: Option> = None; loop { - // `StoreRef: Deref` — safe arena-backed read. - if !(props_handle.len_u32() == 1 - && props_handle.slice()[0].kind == G::PropertyKind::Spread - && matches!( - props_handle.slice()[0].value.unwrap().data, - Data::EObject(..) - )) - { + let properties = match &spread_object { + Some(object) => &object.properties, + None => &e_.properties, + }; + let [ + G::Property { + kind: G::PropertyKind::Spread, + value: + Some(Expr { + data: Data::EObject(inner), + .. + }), + .. + }, + ] = properties.slice() + else { break; - } - // Compute the next handle in a block so the `DerefMut` - // borrow of `props_handle` ends before reassignment. - let next = { - let inner = props_handle.slice_mut()[0] - .value - .as_mut() - .unwrap() - .data - .e_object_mut() - .unwrap(); - js_ast::StoreRef::from_bump(&mut inner.properties) }; - props_handle = next; + spread_object = Some(*inner); } - // `StoreRef: DerefMut` — safe arena-backed handle; no aliasing - // `&mut` outstanding for the remainder of this arm. - let props = &mut *props_handle; + let mut props: G::PropertyList = match spread_object { + // Another expression can reference the same spread object (e.g. a macro result), so copy its list. + Some(object) => { + let mut props = G::PropertyList::init_capacity( + object.properties.len() + usize::from(!e_.children.is_empty()), + ); + props.extend(object.properties.iter().map(G::Property::shallow_clone)); + props + } + None => bun_alloc::AstAlloc::take(&mut e_.properties), + }; // Typescript defines static jsx as children.len > 1 or single spread // https://github.com/microsoft/TypeScript/blob/d4fbc9b57d9aa7d02faac9b1e9bb7b37c687f6e9/src/compiler/transformers/jsx.ts#L340 @@ -582,7 +586,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O &mut args, p.new_expr( E::Object { - properties: bun_alloc::AstAlloc::take(props), + properties: props, ..Default::default() }, expr.loc, @@ -689,7 +693,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if let Some(ref_) = ref_ && !p.options.features.is_macro_runtime { - if let Some(macro_ref_data) = p.macro_.refs.get(&ref_).copied() { + // After a stack overflow, part of the template wasn't visited, so don't run the macro. + if let Some(macro_ref_data) = p.macro_.refs.get(&ref_).copied() + && !p.reported_stack_overflow.get() + { p.ignore_usage(ref_); if p.is_control_flow_dead { *e = p.new_expr(E::Undefined {}, e_.tag.unwrap().loc); @@ -1863,7 +1870,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.should_fold_typescript_constant_expressions = prev_should_fold_typescript_constant_expressions; - *e = p.maybe_transpose_if_import(e_.expr, &state); + if let Some(transposed) = p.maybe_transpose_if_import(e_.expr, &state) { + *e = transposed; + } return; } p.should_fold_typescript_constant_expressions = @@ -2183,7 +2192,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O Data::EIf(..) => { // require(FOO ? '123' : '456') => FOO ? require('123') : require('456') // This makes static analysis later easier - *e = p.transpose_known_to_be_if_require(first, &state); + if let Some(transposed) = p.maybe_transpose_if_require(first, &state) { + *e = transposed; + } return; } _ => {} @@ -2241,7 +2252,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // => // FOO ? require.resolve('123') : require.resolve('456') // This makes static analysis later easier - *e = p.transpose_known_to_be_if_require_resolve(first, e_.target); + if let Some(transposed) = + p.maybe_transpose_if_require_resolve(first, e_.target) + { + *e = transposed; + } return; } _ => {} @@ -2274,7 +2289,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } if Self::ALLOW_MACROS { - if is_macro_ref && !p.options.features.is_macro_runtime { + // After a stack overflow, some arguments weren't visited, so don't run the macro. + if is_macro_ref + && !p.options.features.is_macro_runtime + && !p.reported_stack_overflow.get() + { let ref_ = match &e_.target.data { Data::EImportIdentifier(ident) => ident.ref_, Data::EDot(dot) => { @@ -2793,6 +2812,24 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O return; } + // Only `lower_class` removes decorated `declare` and `abstract` fields, and it only runs for + // class statements. Remove them here because the printer can't print them. + if TYPESCRIPT && e_.has_decorators { + let properties = e_.properties.slice_mut(); + let mut end: usize = 0; + for i in 0..properties.len() { + if matches!( + properties[i].kind, + G::PropertyKind::Declare | G::PropertyKind::Abstract + ) { + continue; + } + properties.swap(end, i); + end += 1; + } + e_.properties.truncate(end); + } + // Remove unused class names when minifying (only when bundling is enabled) // unless --keep-names is specified if p.options.features.minify_syntax diff --git a/src/js_parser/visit/visit_stmt.rs b/src/js_parser/visit/visit_stmt.rs index c2f2be10c195..e95ef3470ae7 100644 --- a/src/js_parser/visit/visit_stmt.rs +++ b/src/js_parser/visit/visit_stmt.rs @@ -2,6 +2,7 @@ use crate::Error; use crate::lexer as js_lexer; use crate::p::{P, ReactRefreshExportKind}; +use crate::parser::Runtime::ReplaceableExport; use crate::parser::{ PrependTempRefsOpts, ReactRefresh, Ref, RelocateVarsMode, SideEffects, StmtsKind, statement_cares_about_scope, @@ -265,6 +266,65 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O Ok(()) } + /// Applies a `replace_exports` entry to `alias`, a re-exported name with no local binding in this module. + #[cold] + #[inline(never)] + fn replace_reexport( + p: &mut Self, + stmts: &mut StmtList<'a>, + alias: &'a [u8], + entry: &ReplaceableExport, + ) { + let exported_name: &[u8] = match entry { + ReplaceableExport::Inject { name, .. } => name, + ReplaceableExport::Replace(_) | ReplaceableExport::Delete => alias, + }; + match entry { + ReplaceableExport::Replace(value) | ReplaceableExport::Inject { value, .. } + if exported_name == js_ast::ClauseItem::DEFAULT_ALIAS => + { + let mut export_default = p.s( + S::ExportDefault { + default_name: js_ast::LocRef::default(), + value: js_ast::StmtOrExpr::Expr(*value), + }, + bun_ast::Loc::EMPTY, + ); + // Hide `replace_exports` from the visitor so it doesn't replace the export generated here. + let replace_exports = core::mem::take(&mut p.options.features.replace_exports); + p.visit_and_append_stmt(stmts, &mut export_default) + .expect("unreachable"); + p.options.features.replace_exports = replace_exports; + } + ReplaceableExport::Replace(_) => { + // In TypeScript, `declare_symbol` lets a declaration replace an import with the same name. + // Report a conflict here instead. + if TYPESCRIPT + && let Some(import) = p + .current_scope() + .get_member_with_hash(alias, js_ast::Scope::get_member_hash(alias)) + && p.symbols[import.ref_.inner_index() as usize].kind + == js_ast::symbol::Kind::Import + { + p.log().add_symbol_already_declared_error( + p.source, + alias, + bun_ast::Loc::EMPTY, + import.loc, + ); + return; + } + let declared = p + .declare_symbol(js_ast::symbol::Kind::Other, bun_ast::Loc::EMPTY, alias) + .expect("unreachable"); + let _ = p.inject_replacement_export(stmts, declared, bun_ast::Loc::EMPTY, entry); + } + ReplaceableExport::Delete | ReplaceableExport::Inject { .. } => { + let _ = p.inject_replacement_export(stmts, Ref::NONE, bun_ast::Loc::EMPTY, entry); + } + } + } + fn s_export_from( p: &mut Self, stmts: &mut StmtList<'a>, @@ -289,8 +349,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // alias is arena-owned (`ArenaStr`), valid for 'a. let alias = items[i].alias.slice(); if let Some(entry) = p.options.features.replace_exports.get_ptr(alias).cloned() { - let _ = - p.inject_replacement_export(stmts, old_ref, bun_ast::Loc::EMPTY, &entry); + Self::replace_reexport(p, stmts, alias, &entry); continue; } @@ -345,15 +404,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O .get_ptr(alias_name) .cloned() { - let declared = p - .declare_symbol( - js_ast::symbol::Kind::Other, - bun_ast::Loc::EMPTY, - alias_name, - ) - .expect("unreachable"); - let _ = - p.inject_replacement_export(stmts, declared, bun_ast::Loc::EMPTY, &entry); + Self::replace_reexport(p, stmts, alias_name, &entry); return Ok(()); } } @@ -401,6 +452,29 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O }; } + // The value removed by a `replace_exports` entry is dead code, but the export the entry injects isn't. + macro_rules! inject_for_dead_value { + () => { + if mark_for_replace && !orig_dead { + if let Some(entry) = p + .options + .features + .replace_exports + .get_ptr(b"default") + .cloned() + && !entry.is_replace() + { + let _ = p.inject_replacement_export( + stmts, + Ref::NONE, + bun_ast::Loc::EMPTY, + &entry, + ); + } + } + }; + } + match &mut data.value { js_ast::StmtOrExpr::Expr(expr) => { let was_anonymous_named_expr = expr.is_anonymous_named(); @@ -430,6 +504,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if p.is_control_flow_dead { restore_dead!(); + inject_for_dead_value!(); record_on_exit!(); return Ok(()); } @@ -453,6 +528,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // this symbol, drop the statement. data.default_name.ref_ = Ref::NONE; restore_dead!(); + inject_for_dead_value!(); record_on_exit!(); return Ok(()); } @@ -614,6 +690,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if p.is_control_flow_dead { p.react_refresh.hook_ctx_storage = prev; restore_dead!(); + inject_for_dead_value!(); record_on_exit!(); return Ok(()); } @@ -783,6 +860,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if p.is_control_flow_dead { restore_dead!(); + inject_for_dead_value!(); record_on_exit!(); return Ok(()); } @@ -893,6 +971,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mark_as_dead = p.options.features.dead_code_elimination && data.func.flags.contains(flags::Function::IsExport) && p.options.features.replace_exports.count() > 0 + && p.enclosing_namespace_arg_ref.is_none() && p.is_export_to_eliminate(data.func.name.expect("infallible: name checked").ref_); let original_is_dead = p.is_control_flow_dead; let original_in_deleted = p.in_deleted_export; @@ -1054,6 +1133,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mark_as_dead = p.options.features.dead_code_elimination && data.is_export && p.options.features.replace_exports.count() > 0 + && p.enclosing_namespace_arg_ref.is_none() && p.is_export_to_eliminate( data.class .class_name diff --git a/src/js_parser_jsc/Macro.rs b/src/js_parser_jsc/Macro.rs index a48a00f5f9e0..d058ff82c89e 100644 --- a/src/js_parser_jsc/Macro.rs +++ b/src/js_parser_jsc/Macro.rs @@ -28,7 +28,7 @@ use bun_jsc::virtual_machine::{ }; use bun_jsc::{ self as jsc, ConsoleObject, JSArrayIterator, JSGlobalObject, JSPropertyIterator, JSValue, - JsError, ModuleLoader, WebCore, + JsError, MarkedArgumentBuffer, ModuleLoader, WebCore, }; use bun_jsc::{BuildMessage, ResolveMessage}; @@ -442,10 +442,19 @@ impl Macro { // JSC needs to be initialized if building from CLI jsc::initialize(jsc::InitializeOptions::default()); + // `env` is shared by every parse thread, and this VM's transpiler loads `.env` files + // into its loader, so give the VM its own copy. + // SAFETY: `env` is the build's loader. It outlives the build and nothing writes to + // it while parse tasks run. + let owned_env_loader = match unsafe { env.as_ref() } { + Some(env) => Some(Box::new(env.clone_loaded()?)), + None => None, + }; + let _vm = VirtualMachine::init(VirtualMachineInitOptions { transform_options, log: Some(NonNull::from(&mut *log)), - env_loader: NonNull::new(env), + owned_env_loader, is_main_thread: false, ..Default::default() })?; @@ -498,7 +507,47 @@ impl Macro { struct Runner; -type VisitMap = HashMap; +mod visit_map { + use super::*; + use bun_collections::hash_map::GetOrPutResult; + + /// Keyed by cell address. `roots` keeps every key alive so the GC can't reuse an address. + pub(crate) struct VisitMap<'a> { + exprs: HashMap, + roots: &'a mut MarkedArgumentBuffer, + } + + impl<'a> VisitMap<'a> { + pub(crate) fn new(roots: &'a mut MarkedArgumentBuffer) -> Self { + Self { + exprs: HashMap::default(), + roots, + } + } + + pub(crate) fn get(&self, value: JSValue) -> Option<&Expr> { + self.exprs.get(&value) + } + + pub(crate) fn get_or_put( + &mut self, + value: JSValue, + ) -> Result, bun_alloc::AllocError> { + let entry = self.exprs.get_or_put(value)?; + if !entry.found_existing { + self.roots.append(value); + } + Ok(entry) + } + + pub(crate) fn insert(&mut self, value: JSValue, expr: Expr) { + if self.exprs.insert(value, expr).is_none() { + self.roots.append(value); + } + } + } +} +use visit_map::VisitMap; thread_local! { static EXCEPTION_HOLDER: Cell = const { Cell::new(false) }; @@ -552,7 +601,7 @@ pub(crate) struct Run<'a> { pub(crate) bump: &'a bun_alloc::Arena, pub(crate) log: &'a mut Log, pub(crate) source: &'a Source, - pub(crate) visited: VisitMap, + pub(crate) visited: VisitMap<'a>, pub(crate) is_top_level: bool, } @@ -579,20 +628,20 @@ impl<'a> Run<'a> { .unwrap_or_else(|_| global.try_take_exception().unwrap_or_default()) }); - let mut runner = Run { - caller, - macro_, - global: VirtualMachine::get().global(), - bump, - log, - source, - visited: VisitMap::default(), - is_top_level: false, - }; - - // `runner.visited` dropped at scope exit (was `defer runner.visited.deinit(allocator)`) + MarkedArgumentBuffer::new(|roots| { + let mut runner = Run { + caller, + macro_, + global: VirtualMachine::get().global(), + bump, + log, + source, + visited: VisitMap::new(roots), + is_top_level: false, + }; - runner.run(result) + runner.run(result) + }) } pub(crate) fn run(&mut self, value: JSValue) -> Result { @@ -654,9 +703,6 @@ impl<'a> Run<'a> { T::Null => return Ok(Expr::init(E::Null {}, self.caller.loc)), T::Private => { self.is_top_level = false; - if let Some(cached) = self.visited.get(&value) { - return Ok(*cached); - } let mut blob_: Option<*const WebCore::Blob> = None; @@ -843,7 +889,7 @@ impl<'a> Run<'a> { )); } T::Promise => { - if let Some(cached) = self.visited.get(&value) { + if let Some(cached) = self.visited.get(value) { return Ok(*cached); } diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 649ae106d2fe..3b8925c4b08d 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -4727,6 +4727,17 @@ pub(crate) mod __gated_printer { && (self.options.minify_syntax || !self.options.has_run_symbol_renamer)) } + /// Returns true if `key` must be printed as `[key]`. YAML and JSON5 keys can be any value. + /// A bare array or object key is a syntax error, and `minify_syntax` prints a boolean as `!0`. + pub(crate) fn property_key_must_be_computed(&self, key: &Expr) -> bool { + match &key.data { + ExprData::ENumber(e) => self.number_property_key_must_be_computed(e.value()), + ExprData::EBoolean(_) | ExprData::EBranchBoolean(_) => self.options.minify_syntax, + ExprData::EArray(_) | ExprData::EObject(_) => true, + _ => false, + } + } + /// `E::ObjectJSON` (JSON-only): always printed in JSON shape. pub(crate) fn print_object_json(&mut self, e: &E::ObjectJSON) { if !self.stack_check.is_safe_to_recurse() { @@ -4942,13 +4953,14 @@ pub(crate) mod __gated_printer { let key = item.key.expect("infallible: prop has key"); - // Automatically print numbers that would cause a syntax error as computed properties + // Automatically print keys that would cause a syntax error as computed properties if !IS_JSON && !item.flags.contains(js_ast::flags::Property::IsComputed) - && matches!(&key.data, ExprData::ENumber(e) if self.number_property_key_must_be_computed(e.value())) + && self.property_key_must_be_computed(&key) { // "{ -1: 0 }" must be printed as "{ [-1]: 0 }" // "{ 1/0: 0 }" must be printed as "{ [1/0]: 0 }" + // "{ {x: 1}: 0 }" must be printed as "{ [{x: 1}]: 0 }" set_flag(&mut item.flags, js_ast::flags::Property::IsComputed, true); } diff --git a/src/jsc/AsyncModule.rs b/src/jsc/AsyncModule.rs index c7c7222ecf3a..3943e1dc8703 100644 --- a/src/jsc/AsyncModule.rs +++ b/src/jsc/AsyncModule.rs @@ -1148,24 +1148,6 @@ impl AsyncModule { // the enqueue, and the fd the parse opened may have been closed (and // the number recycled) by the transpile frame's fd guard. - // SAFETY: per-thread VM. - if unsafe { (*jsc_vm).is_watcher_enabled() } { - // SAFETY: per-thread VM. - let mut resolved_source = unsafe { - (*jsc_vm).ref_counted_resolved_source( - printer.ctx.get_written(), - &BunString::from_bytes(specifier), - path.text, - None, - ) - }; - - resolved_source.is_commonjs_module = is_commonjs_module; - resolved_source.is_static = self.is_static; - - return Ok(resolved_source); - } - Ok(ResolvedSource { source_code: BunString::clone_latin1(printer.ctx.get_written()), source_url: BunString::from_bytes(path.text), diff --git a/src/jsc/BunCPUProfiler.rs b/src/jsc/BunCPUProfiler.rs index e19965f5986e..02d0f6baea99 100644 --- a/src/jsc/BunCPUProfiler.rs +++ b/src/jsc/BunCPUProfiler.rs @@ -39,6 +39,8 @@ unsafe extern "C" { ); /// Plain by-value `c_int`; sets a global sampler interval, no pointer invariants. safe fn Bun__setSamplingInterval(interval_microseconds: c_int); + /// Writes a +1 ref into `out`. Doesn't write to `out` if the VM has no sampling profiler. + safe fn Bun__SamplingProfiler__report(vm: &mut VM, out: &mut BunString); } pub fn set_sampling_interval(interval: u32) { @@ -52,6 +54,35 @@ pub fn start_cpu_profiler(vm: &mut VM) { Bun__startCPUProfiler(vm); } +/// Writes `vm`'s sampling profiler report into `directory` (absolute UTF-8), creating it if missing. +pub(crate) fn write_sampling_profiler_report( + vm: &mut VM, + directory: &[u8], +) -> Result<(), ProfilerError> { + let mut report = BunString::EMPTY; + Bun__SamplingProfiler__report(vm, &mut report); + if report.is_empty() { + return Ok(()); + } + let report = report.to_utf8(); + + let mut filename_buf = bun_paths::path_buffer_pool::get(); + let filename = { + let mut cursor = std::io::Cursor::new(&mut filename_buf[..]); + write_diagnostic_filename(&mut cursor, "SamplingProfile", ".txt") + .map_err(|_| ProfilerError::FilenameTooLong)?; + let len = usize::try_from(cursor.position()).expect("int cast"); + &filename_buf[..len] + }; + + let mut path_buf = AutoAbsPathChecked::init_top_level_dir(); + path_buf + .join(&[directory, filename]) + .map_err(|_| ProfilerError::FilenameTooLong)?; + + write_file_creating_dir(&mut path_buf, directory, report.slice()) +} + pub(crate) fn stop_and_write_profile( vm: &mut VM, config: &CPUProfilerConfig, @@ -91,30 +122,35 @@ fn write_profile_to_file( build_output_path(&mut path_buf, config, is_md_format)?; + write_file_creating_dir(&mut path_buf, config.dir, profile_slice.slice()) +} + +/// Writes `contents` to `path`. On ENOENT, EPERM or EACCES it creates `dir` and retries once. +fn write_file_creating_dir( + path: &mut AutoAbsPathChecked, + dir: &[u8], + contents: &[u8], +) -> Result<(), ProfilerError> { // Convert to OS-specific path (UTF-16 on Windows, UTF-8 elsewhere) #[cfg(windows)] let mut path_buf_os = bun_paths::os_path_buffer_pool::get(); #[cfg(windows)] let output_path_os = - bun_core::strings::convert_utf8_to_utf16_in_buffer_z(&mut path_buf_os, path_buf.slice_z()); + bun_core::strings::convert_utf8_to_utf16_in_buffer_z(&mut path_buf_os, path.slice_z()); #[cfg(not(windows))] - let output_path_os = path_buf.slice_z(); + let output_path_os = path.slice_z(); // Write the profile to disk using bun.sys.File.writeFile - let result = - bun_sys::File::write_file_os_path(Fd::cwd(), output_path_os, profile_slice.slice()); + let result = bun_sys::File::write_file_os_path(Fd::cwd(), output_path_os, contents); if let Err(err) = result { // If we got ENOENT, PERM, or ACCES, try creating the directory and retry 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); + if !dir.is_empty() { + let _ = Fd::cwd().make_path(dir); // Retry write - let retry_result = bun_sys::File::write_file_os_path( - Fd::cwd(), - output_path_os, - profile_slice.slice(), - ); + let retry_result = + bun_sys::File::write_file_os_path(Fd::cwd(), output_path_os, contents); if retry_result.is_err() { return Err(ProfilerError::WriteFailed); } diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 54c1e9bea13f..93a7efd4332d 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -517,9 +517,12 @@ fn message_with_type_and_level_( let opts = vals_slice[1]; if opts.is_object() { if let Some(depth_prop) = opts.get(global, b"depth")? { - if depth_prop.is_int32() || depth_prop.is_number() || depth_prop.is_big_int() { + if depth_prop.is_number() { // Clamp negatives to 0, then truncate (not saturate) to u16. print_options.max_depth = depth_prop.to_int32().max(0) as u32 as u16; + } else if depth_prop.is_big_int() { + print_options.max_depth = + depth_prop.to_int64().clamp(0, i64::from(u16::MAX)) as u16; } else if depth_prop.is_null() { print_options.max_depth = u16::MAX; } @@ -667,7 +670,6 @@ impl<'a> TablePrinter<'a> { f.single_line = true; f.max_depth = 5; f.can_throw_stack_overflow = true; - f.stack_check = StackCheck::init(); f }, values_col_width: None, @@ -1343,7 +1345,6 @@ pub fn format2( fmt.max_depth = options.max_depth; fmt.single_line = options.single_line; fmt.indent = u32::from(options.default_indent); - fmt.stack_check = StackCheck::init(); fmt.can_throw_stack_overflow = true; fmt.error_display_level = options.error_display_level; let tag = formatter::Tag::get(vals[0], global)?; @@ -1406,7 +1407,6 @@ pub fn format2( fmt.max_depth = options.max_depth; fmt.single_line = options.single_line; fmt.indent = u32::from(options.default_indent); - fmt.stack_check = StackCheck::init(); fmt.can_throw_stack_overflow = true; fmt.error_display_level = options.error_display_level; let mut tag: formatter::TagResult; @@ -1639,10 +1639,7 @@ pub mod formatter { ordered_properties: false, custom_formatted_object: CustomFormattedObject::default(), disable_inspect_custom: false, - // `StackCheck::default()` has `cached_stack_end = 0` ⇒ the - // check always passes; callers that want a real bound - // overwrite with `StackCheck::init()` explicitly. - stack_check: StackCheck::default(), + stack_check: StackCheck::init(), can_throw_stack_overflow: false, error_display_level: ErrorDisplayLevel::Full, format_buffer_as_text: false, @@ -1825,13 +1822,6 @@ pub mod formatter { bun_collections::object_pool!(pub Pool: Map, threadsafe, 16); pub type PoolNode = bun_collections::pool::Node; - /// Safe `&mut Map` accessor for a pooled node. `Map::INIT` is `Some`, - /// so every node returned by [`Pool::get_node`] carries an initialized - /// `data` payload, and the caller exclusively owns the node until - /// [`Pool::release`]. Centralises the `NonNull::as_mut()` + - /// `assume_init_mut()` pair so the four call sites in this file (and - /// the cause-chain guard in `VirtualMachine::print_error_instance`) - /// don't each open-code two `unsafe` operations. #[inline] pub(crate) fn node_data_mut(node: &mut core::ptr::NonNull) -> &mut Map { // SAFETY: `Map::INIT` is `Some`, so `data` is initialized for @@ -1914,8 +1904,13 @@ pub mod formatter { | Tag::Error | Tag::Class | Tag::Event + | Tag::JSX ) } + + pub(crate) fn printer_marks_visited(self) -> bool { + matches!(self, Tag::Error) + } } /// Only `CustomFormattedObject` carries a payload. @@ -3210,6 +3205,33 @@ pub mod formatter { // ─────────────────────────────────────────────────────────────────────── impl<'a> Formatter<'a> { + #[inline(never)] + pub(crate) fn visited_insert(&mut self, value: JSValue) -> bool { + if self.map_node.is_none() { + let mut node = core::ptr::NonNull::new(visited::Pool::get_node()) + .expect("ObjectPool::get_node always returns a valid heap node"); + let data = visited::node_data_mut(&mut node); + data.clear(); + self.map = core::mem::take(data); + self.map_node = Some(node); + } + !self + .map + .get_or_put(value) + .expect("unreachable") + .found_existing + } + + #[inline] + pub(crate) fn visited_remove(&mut self, value: JSValue) { + let _ = self.map.remove(&value); + } + + #[inline] + pub(crate) fn visited_contains(&self, value: JSValue) -> bool { + self.map_node.is_some() && self.map.contains(&value) + } + /// Circular-reference / stack-overflow / visited-map prelude for /// `print_as`. Outlined so its locals (the pool node, the /// `get_or_put` result, the `[Circular]` write path) live in a leaf @@ -3224,7 +3246,7 @@ pub mod formatter { &mut self, writer_: &mut dyn bun_io::Write, value: JSValue, - can_circ: bool, + format: Tag, remove_before_recurse: &mut bool, ) -> JsResult { if self.failed { @@ -3233,10 +3255,6 @@ pub mod formatter { if self.global_this.has_exception() { return Err(jsc::JsError::Thrown); } - if !can_circ { - return Ok(true); - } - if !self.stack_check.is_safe_to_recurse() { self.failed = true; if self.can_throw_stack_overflow { @@ -3245,17 +3263,11 @@ pub mod formatter { return Ok(false); } - if self.map_node.is_none() { - let mut node = core::ptr::NonNull::new(visited::Pool::get_node()) - .expect("ObjectPool::get_node always returns a valid heap node"); - let data = visited::node_data_mut(&mut node); - data.clear(); - self.map = core::mem::take(data); - self.map_node = Some(node); + if !format.can_have_circular_references() || format.printer_marks_visited() { + return Ok(true); } - let entry = self.map.get_or_put(value).expect("unreachable"); - if entry.found_existing { + if !self.visited_insert(value) { if writer_ .write_all(pfmt!("[Circular]", C).as_bytes()) .is_err() @@ -3283,7 +3295,7 @@ pub mod formatter { if !self.print_as_prelude::( writer_, value, - format.can_have_circular_references(), + format, &mut remove_before_recurse, )? { return Ok(()); @@ -3498,18 +3510,19 @@ pub mod formatter { writer_: &mut dyn bun_io::Write, value: JSValue, ) -> JsResult<()> { - let target = value.get_proxy_internal_field(jsc::ProxyField::Target); + let mut target = value.get_proxy_internal_field(jsc::ProxyField::Target); // Proxy does not allow non-objects here. debug_assert!(target.is_cell()); // TODO: if (options.showProxy), print like // `Proxy { target: ..., handlers: ... }` — this is default off so // it is not used. - self.format::( - Tag::get(target, self.global_this)?, - writer_, - target, - self.global_this, - ) + loop { + let tag = Tag::get(target, self.global_this)?; + if !matches!(tag.tag.tag(), Tag::Proxy) { + return self.format::(tag, writer_, target, self.global_this); + } + target = target.get_proxy_internal_field(jsc::ProxyField::Target); + } } #[inline(never)] @@ -3855,25 +3868,6 @@ pub mod formatter { writer_: &mut dyn bun_io::Write, value: JSValue, ) -> JsResult<()> { - // Temporarily remove from the visited map to allow - // printErrorlikeObject to process it. The circular reference - // check is already done in print_as, so we know it's safe. - let was_in_map = if self.map_node.is_some() { - self.map.remove(&value).is_some() - } else { - false - }; - let map_restore_ptr: *mut visited::Map = &raw mut self.map; - scopeguard::defer! { - // SAFETY: `self.map` outlives this guard; no other borrow is - // live at the drop point. - unsafe { - if was_in_map { - let _ = (*map_restore_ptr).insert(value, ()); - } - } - } - let mut adapter = DynWriteAdapter::new(&mut *writer_); // SAFETY: per-thread VM. let vm = VirtualMachine::get().as_mut(); @@ -4554,7 +4548,7 @@ pub mod formatter { } else if js_type != jsc::JSType::DOMWrapper { if *remove_before_recurse { *remove_before_recurse = false; - let _ = self.map.remove(&value); + self.visited_remove(value); } if value.is_callable() { @@ -4567,7 +4561,7 @@ pub mod formatter { } if *remove_before_recurse { *remove_before_recurse = false; - let _ = self.map.remove(&value); + self.visited_remove(value); } *remove_before_recurse = true; @@ -4837,7 +4831,7 @@ pub mod formatter { evt @ (EventType::MessageEvent | EventType::ErrorEvent) => evt, _ => { if *remove_before_recurse { - let _ = self.map.remove(&value); + self.visited_remove(value); } // We must potentially remove it again. *remove_before_recurse = true; @@ -5671,7 +5665,6 @@ pub mod formatter { value: JSValue, writer: &mut dyn bun_io::Write, ) -> JsResult<()> { - self.stack_check.update(); let one = [value]; self.remaining_values = bun_ptr::RawSlice::new(&one); let global = self.global_this; @@ -5740,14 +5733,13 @@ pub mod formatter { pub(crate) extern "C" fn Bun__ConsoleObject__count( _console: *mut ConsoleObject, global_this: &JSGlobalObject, - ptr: *const u8, - len: usize, + label: &BunString, ) { // SAFETY: top-level JS-thread host call ⇒ exclusive access to the // set-once `VirtualMachine.console` box. let this = unsafe { vm_console_mut(global_this) }; - // SAFETY: caller passes a valid (ptr, len) pair. - let slice = unsafe { bun_core::ffi::slice(ptr, len) }; + let label = label.to_utf8(); + let slice: &[u8] = &label; let hash = bun_wyhash::hash(slice); // we don't want to store these strings, it will take too much memory let counter = this.counts.get_or_put(hash).expect("unreachable"); @@ -5781,15 +5773,12 @@ pub(crate) extern "C" fn Bun__ConsoleObject__count( pub(crate) extern "C" fn Bun__ConsoleObject__countReset( _console: *mut ConsoleObject, global_this: &JSGlobalObject, - ptr: *const u8, - len: usize, + label: &BunString, ) { // SAFETY: top-level JS-thread host call ⇒ exclusive access to the // set-once `VirtualMachine.console` box. let this = unsafe { vm_console_mut(global_this) }; - // SAFETY: caller passes a valid (ptr, len) pair. - let slice = unsafe { bun_core::ffi::slice(ptr, len) }; - let hash = bun_wyhash::hash(slice); + let hash = bun_wyhash::hash(&label.to_utf8()); // we don't delete it because deleting is implemented via tombstoning if let Some(v) = this.counts.get_mut(&hash) { *v = 0; @@ -5807,11 +5796,9 @@ thread_local! { pub(crate) extern "C" fn Bun__ConsoleObject__time( _console: *mut ConsoleObject, _global: &JSGlobalObject, - chars: *const u8, - len: usize, + label: &BunString, ) { - // SAFETY: caller passes a valid (ptr, len) pair. - let id = bun_wyhash::hash(unsafe { bun_core::ffi::slice(chars, len) }); + let id = bun_wyhash::hash(&label.to_utf8()); if !PENDING_TIME_LOGS_LOADED.with(|c| c.get()) { PENDING_TIME_LOGS.with_borrow_mut(|m| *m = PendingTimers::default()); PENDING_TIME_LOGS_LOADED.with(|c| c.set(true)); @@ -5830,15 +5817,14 @@ pub(crate) extern "C" fn Bun__ConsoleObject__time( pub(crate) extern "C" fn Bun__ConsoleObject__timeEnd( _console: *mut ConsoleObject, _global: &JSGlobalObject, - chars: *const u8, - len: usize, + label: &BunString, ) { if !PENDING_TIME_LOGS_LOADED.with(|c| c.get()) { return; } - // SAFETY: caller passes a valid (ptr, len) pair. - let slice = unsafe { bun_core::ffi::slice(chars, len) }; + let label = label.to_utf8(); + let slice: &[u8] = &label; let id = bun_wyhash::hash(slice); // Replace the slot with `None`, returning the previous value. let Some(prev) = PENDING_TIME_LOGS.with_borrow_mut(|m| m.get_mut(&id).map(|slot| slot.take())) @@ -5850,7 +5836,7 @@ pub(crate) extern "C" fn Bun__ConsoleObject__timeEnd( Output::print_elapsed( (value.read() / bun_core::time::NS_PER_US) as f64 / bun_core::time::US_PER_MS as f64, ); - match len { + match slice.len() { 0 => Output::print_errorln(format_args!("")), _ => Output::print_errorln(format_args!(" {}", bstr::BStr::new(slice))), } @@ -5863,8 +5849,7 @@ pub(crate) extern "C" fn Bun__ConsoleObject__timeEnd( pub(crate) extern "C" fn Bun__ConsoleObject__timeLog( _console: *mut ConsoleObject, global: &JSGlobalObject, - chars: *const u8, - len: usize, + label: &BunString, args: *const JSValue, args_len: usize, ) { @@ -5872,8 +5857,8 @@ pub(crate) extern "C" fn Bun__ConsoleObject__timeLog( return; } - // SAFETY: caller passes a valid (ptr, len) pair. - let slice = unsafe { bun_core::ffi::slice(chars, len) }; + let label = label.to_utf8(); + let slice: &[u8] = &label; let id = bun_wyhash::hash(slice); let Some(Some(value)) = PENDING_TIME_LOGS.with_borrow(|m| m.get(&id).copied()) else { return; @@ -5882,7 +5867,7 @@ pub(crate) extern "C" fn Bun__ConsoleObject__timeLog( Output::print_elapsed( (value.read() / bun_core::time::NS_PER_US) as f64 / bun_core::time::US_PER_MS as f64, ); - match len { + match slice.len() { 0 => {} _ => Output::print_error(format_args!(" {}", bstr::BStr::new(slice))), } @@ -5895,7 +5880,6 @@ pub(crate) extern "C" fn Bun__ConsoleObject__timeLog( fmt.max_depth = bun_options_types::context::try_get() .and_then(|ctx| ctx.runtime_options.console_depth) .unwrap_or(DEFAULT_CONSOLE_LOG_DEPTH); - fmt.stack_check = StackCheck::init(); fmt.can_throw_stack_overflow = true; let console = vm_console(global); // SAFETY: see [`vm_console`] — points at the live boxed `ConsoleObject` for @@ -5960,11 +5944,12 @@ console_noop_hooks!(str: Bun__ConsoleObject__profile, Bun__ConsoleObject__profil pub(crate) extern "C" fn Bun__ConsoleObject__takeHeapSnapshot( _console: *mut ConsoleObject, global_this: &JSGlobalObject, - _chars: *const u8, - _len: usize, ) { // TODO: this does an extra JSONStringify and we don't need it to! - let snapshot: [JSValue; 1] = [global_this.generate_heap_snapshot()]; + let Ok(result) = global_this.generate_heap_snapshot() else { + return; + }; + let snapshot: [JSValue; 1] = [result]; // SAFETY: re-entry into our own host shim with a stack-local args slice. unsafe { message_with_type_and_level( diff --git a/src/jsc/DOMFormData.rs b/src/jsc/DOMFormData.rs index f1fd45c9574b..75c793952b61 100644 --- a/src/jsc/DOMFormData.rs +++ b/src/jsc/DOMFormData.rs @@ -17,9 +17,10 @@ unsafe extern "C" { safe fn WebCore__DOMFormData__fromJS(js_value0: JSValue) -> *mut DOMFormData; safe fn WebCore__DOMFormData__append( arg0: &mut DOMFormData, - arg1: &EncodedSlice, + arg1: &JSGlobalObject, arg2: &EncodedSlice, - ); + arg3: &EncodedSlice, + ) -> bool; // safe: `DOMFormData`/`JSGlobalObject` are opaque `UnsafeCell`-backed ZST // handles; `&EncodedSlice` is ABI-identical to non-null `*const EncodedSlice` and // C++ only reads the named struct via `toStringCopy`. `arg3` is an opaque @@ -31,7 +32,7 @@ unsafe extern "C" { arg2: &EncodedSlice, arg3: *mut c_void, arg4: &EncodedSlice, - ); + ) -> bool; safe fn WebCore__DOMFormData__count(arg0: &mut DOMFormData) -> usize; } @@ -64,18 +65,29 @@ impl DOMFormData { (!p.is_null()).then(|| DOMFormData::opaque_mut(p)) } - pub fn append(&mut self, name_: &EncodedSlice, value_: &EncodedSlice) { - WebCore__DOMFormData__append(self, name_, value_) + #[track_caller] + pub fn append( + &mut self, + global: &JSGlobalObject, + name_: &EncodedSlice, + value_: &EncodedSlice, + ) -> JsResult<()> { + crate::call_false_is_throw(global, || { + WebCore__DOMFormData__append(self, global, name_, value_) + }) } + #[track_caller] pub fn append_blob( &mut self, global: &JSGlobalObject, name_: &EncodedSlice, blob: *mut c_void, filename_: &EncodedSlice, - ) { - WebCore__DOMFormData__appendBlob(self, global, name_, blob, filename_); + ) -> JsResult<()> { + crate::call_false_is_throw(global, || { + WebCore__DOMFormData__appendBlob(self, global, name_, blob, filename_) + }) } pub fn count(&mut self) -> usize { diff --git a/src/jsc/FetchHeaders.rs b/src/jsc/FetchHeaders.rs index 495fad1bcdfc..2543c7cbbccb 100644 --- a/src/jsc/FetchHeaders.rs +++ b/src/jsc/FetchHeaders.rs @@ -27,12 +27,18 @@ unsafe extern "C" { arg1: &JSGlobalObject, ) -> *mut FetchHeaders; fn WebCore__FetchHeaders__copyTo( - arg0: *mut FetchHeaders, + arg0: &FetchHeaders, arg1: *mut StringPointer, arg2: *mut StringPointer, - arg3: *mut u8, + arg3: u32, + arg4: *mut u8, + arg5: u32, ); - safe fn WebCore__FetchHeaders__count(arg0: &FetchHeaders, arg1: &mut u32, arg2: &mut u32); + safe fn WebCore__FetchHeaders__count( + arg0: &FetchHeaders, + arg1: &mut u32, + arg2: &mut u32, + ) -> bool; safe fn WebCore__FetchHeaders__createEmpty() -> *mut FetchHeaders; // safe: `arg0`/`arg1` are opaque handles to C++-owned request structs // (PicoHeaders / uWS HttpRequest); never dereferenced as Rust data — same @@ -213,7 +219,7 @@ impl FetchHeaders { Some(str) } - pub fn fast_has_(&mut self, name_: u8) -> bool { + pub fn fast_has_(&self, name_: u8) -> bool { WebCore__FetchHeaders__fastHas_(self, name_) } @@ -244,8 +250,11 @@ impl FetchHeaders { WebCore__FetchHeaders__toJS(self, global_this) } - pub fn count(&mut self, names: &mut u32, buf_len: &mut u32) { - WebCore__FetchHeaders__count(self, names, buf_len) + /// Returns the header count and the total size of all names and values in bytes, or `None` if + /// the size exceeds `u32::MAX`. + pub fn count(&self) -> Option<(u32, u32)> { + let (mut names, mut buf_len) = (0, 0); + WebCore__FetchHeaders__count(self, &mut names, &mut buf_len).then_some((names, buf_len)) } pub fn clone_this( @@ -261,9 +270,27 @@ impl FetchHeaders { WebCore__FetchHeaders__deref(self) } - pub fn copy_to(&mut self, names: *mut StringPointer, values: *mut StringPointer, buf: *mut u8) { - // SAFETY: caller guarantees names/values/buf are sized per a prior `count()` call - unsafe { WebCore__FetchHeaders__copyTo(self, names, values, buf) } + /// Aborts if `names`, `values` or `buf` is smaller than what `count()` reported. + pub fn copy_to( + &self, + names: &mut [StringPointer], + values: &mut [StringPointer], + buf: &mut [u8], + ) { + // Clamping a longer slice is fine because C++ writes at most `u32::MAX` entries or bytes. + let columns = u32::try_from(names.len().min(values.len())).unwrap_or(u32::MAX); + let buf_len = u32::try_from(buf.len()).unwrap_or(u32::MAX); + // SAFETY: C++ asserts that it writes at most `columns` entries and `buf_len` bytes. + unsafe { + WebCore__FetchHeaders__copyTo( + self, + names.as_mut_ptr(), + values.as_mut_ptr(), + columns, + buf.as_mut_ptr(), + buf_len, + ) + } } } diff --git a/src/jsc/JSGlobalObject.rs b/src/jsc/JSGlobalObject.rs index b23d948192d1..613b6d5b8a7b 100644 --- a/src/jsc/JSGlobalObject.rs +++ b/src/jsc/JSGlobalObject.rs @@ -321,10 +321,6 @@ impl JSGlobalObject { pub fn throw_todo(&self, msg: &[u8]) -> JsError { let err = EncodedSlice::utf8(msg).to_error_instance(self); - if err.is_empty() { - debug_assert!(self.has_exception()); - return JsError::Thrown; - } let name_value = match BunString::static_("TODOError").to_js(self) { Ok(v) => v, Err(_) => return JsError::Thrown, @@ -833,10 +829,6 @@ impl JSGlobalObject { pub fn throw_sys_error(&self, opts: &SysErrOptions, message: Arguments<'_>) -> JsError { let err = self.create_error_instance(message); - if err.is_empty() { - debug_assert!(self.has_exception()); - return JsError::Thrown; - } err.put( self, b"code", @@ -856,12 +848,7 @@ impl JSGlobalObject { /// Note: If you are throwing an error within somewhere in the Bun API, /// chances are you should be using `.err(...).throw()` instead. pub fn throw(&self, args: Arguments<'_>) -> JsError { - let instance = self.create_error_instance(args); - if instance.is_empty() { - debug_assert!(self.has_exception()); - return JsError::Thrown; - } - self.throw_value(instance) + self.throw_value(self.create_error_instance(args)) } pub fn queue_microtask(&self, function: JSValue, args: &[JSValue]) { @@ -952,8 +939,8 @@ impl JSGlobalObject { }) } - pub(crate) fn generate_heap_snapshot(&self) -> JSValue { - JSC__JSGlobalObject__generateHeapSnapshot(self) + pub(crate) fn generate_heap_snapshot(&self) -> JsResult { + crate::from_js_host_call(self, || JSC__JSGlobalObject__generateHeapSnapshot(self)) } /// DEPRECATED — use [`TopExceptionScope`](crate::TopExceptionScope) to check for exceptions diff --git a/src/jsc/JSString.rs b/src/jsc/JSString.rs index 68f9c74f07ce..fd8934cd9dd1 100644 --- a/src/jsc/JSString.rs +++ b/src/jsc/JSString.rs @@ -13,6 +13,7 @@ unsafe extern "C" { pub(crate) safe fn JSC__JSString__view<'a>( this: &'a JSString, global: &JSGlobalObject, + owner: &mut *const JSString, ) -> StringView<'a>; fn JSC__JSString__iterator(this: &JSString, global_object: &JSGlobalObject, iter: *mut c_void); safe fn JSC__JSString__length(this: &JSString) -> usize; @@ -33,8 +34,14 @@ impl JSString { /// Throws when resolving a rope runs out of memory. #[track_caller] pub fn view<'a>(&'a self, global: &JSGlobalObject) -> JsResult> { - let view = crate::call_check_slow(global, || JSC__JSString__view(self, global))?; - Ok(JSStringView { cell: self, view }) + let mut owner: *const JSString = self; + let view = + crate::call_check_slow(global, || JSC__JSString__view(self, global, &mut owner))?; + Ok(JSStringView { + cell: self, + owner: JSString::opaque_ref(owner), + view, + }) } pub fn iterator(&self, global_object: &JSGlobalObject, iter: &mut Iterator) { @@ -59,6 +66,8 @@ impl JSString { /// collected while its characters are in use. pub struct JSStringView<'a> { pub(crate) cell: &'a JSString, + /// The cell that owns the characters. It's the base string when `cell` is a substring rope. + pub(crate) owner: &'a JSString, pub(crate) view: StringView<'a>, } @@ -89,6 +98,7 @@ impl Drop for JSStringView<'_> { #[inline] fn drop(&mut self) { self.cell.ensure_still_alive(); + self.owner.ensure_still_alive(); } } diff --git a/src/jsc/JSValue.rs b/src/jsc/JSValue.rs index bc2f04dcb1f2..e374f7dbaed9 100644 --- a/src/jsc/JSValue.rs +++ b/src/jsc/JSValue.rs @@ -807,15 +807,17 @@ impl JSValue { .map(|p| JSString::opaque_ref(p.as_ptr())) } /// [`to_js_string`] plus a borrowed view of its characters, in one FFI - /// call. The returned guard keeps the cell alive while in scope. + /// call. The returned guard keeps the characters' owner cell alive while in scope. #[track_caller] pub fn to_js_string_view<'a>(self, global: &'a JSGlobalObject) -> JsResult> { let mut view = bun_core::StringView::EMPTY; + let mut owner: *const JSString = core::ptr::null(); crate::call_null_is_throw(global, || { - JSC__JSValue__toJSStringView(self, global, &mut view) + JSC__JSValue__toJSStringView(self, global, &mut view, &mut owner) }) .map(|p| JSStringView { cell: JSString::opaque_ref(p.as_ptr()), + owner: JSString::opaque_ref(owner), view, }) } @@ -859,7 +861,8 @@ impl JSValue { pub fn js_type_string<'a>(self, global: &'a JSGlobalObject) -> bun_core::StringView<'a> { let cell = JSString::opaque_ref::<'a>(JSC__jsTypeStringForValue(global, self)); crate::validation_scope!(scope, global); - let view = crate::js_string::JSC__JSString__view(cell, global); + let mut owner: *const JSString = cell; + let view = crate::js_string::JSC__JSString__view(cell, global, &mut owner); scope.assert_no_exception(); view } @@ -2071,6 +2074,7 @@ unsafe extern "C" { this: JSValue, global: &'a JSGlobalObject, view: &mut bun_core::StringView<'a>, + owner: &mut *const JSString, ) -> *mut JSString; safe fn JSC__JSValue__asString(this: JSValue) -> *mut JSString; safe fn JSC__jsTypeStringForValue(global: &JSGlobalObject, value: JSValue) -> *mut JSString; diff --git a/src/jsc/NodeCompileCache.rs b/src/jsc/NodeCompileCache.rs index a2546e4c6b9d..bb86b68ee857 100644 --- a/src/jsc/NodeCompileCache.rs +++ b/src/jsc/NodeCompileCache.rs @@ -400,6 +400,11 @@ fn platform_tmp_dir() -> &'static [u8] { fn enable_with_dir(dir: &[u8], portable: bool) -> EnableResult { let tag = version_tag(); + let path_too_long = || EnableResult { + status: STATUS_FAILED, + directory: None, + message: Some("Cannot create cache directory: path too long".to_string()), + }; // Resolve `dir` to an absolute path against the process cwd. let mut abs_buf = bun_paths::path_buffer_pool::get(); @@ -420,19 +425,16 @@ fn enable_with_dir(dir: &[u8], portable: bool) -> EnableResult { }; } }; - bun_paths::resolve_path::join_abs_string_buf_z::( - &cwd_buf[..cwd_len], - &mut abs_buf[..], - &[dir], - ) - .as_bytes() + match bun_paths::resolve_path::join_abs_string_buf_checked::< + bun_paths::resolve_path::platform::Auto, + >(&cwd_buf[..cwd_len], &mut abs_buf[..], &[dir]) + { + Some(abs) => abs, + None => return path_too_long(), + } }; if abs.len() + 1 + tag.len() + 2 > MAX_PATH_BYTES { - return EnableResult { - status: STATUS_FAILED, - directory: None, - message: Some("Cannot create cache directory: path too long".to_string()), - }; + return path_too_long(); } let mut tagged: Vec = Vec::with_capacity(abs.len() + 1 + tag.len()); diff --git a/src/jsc/NodeModuleModule.rs b/src/jsc/NodeModuleModule.rs index e172f76a747d..1b9f00763c1f 100644 --- a/src/jsc/NodeModuleModule.rs +++ b/src/jsc/NodeModuleModule.rs @@ -101,6 +101,8 @@ fn find_path_inner( } pub(crate) fn stat(path: &[u8]) -> i32 { + // Node passes the path to `uv_fs_stat` as a C string, which ends at the first NUL. + let path = bun_core::slice_to_nul(path); // PERF: `exists_at_type` // takes a `&ZStr`, so we copy into a NUL-terminated heap buffer here. let zpath = bun_core::ZBox::from_bytes(path); diff --git a/src/jsc/RefString.rs b/src/jsc/RefString.rs index e5f7e90d9d1f..3f63e3019f05 100644 --- a/src/jsc/RefString.rs +++ b/src/jsc/RefString.rs @@ -15,7 +15,11 @@ pub(crate) type Hash = u32; // `bun.IdentityContext` is an identity hasher (key is already a hash). The `80` // max-load-percentage has no direct knob on the Rust side. pub type Map = - bun_collections::HashMap>; + bun_collections::HashMap, bun_collections::IdentityContext>; + +/// Error for bytes that aren't interned yet and are longer than [`bun_core::String::max_length`]. +#[derive(Debug)] +pub struct StringTooLong; pub type Callback = unsafe fn(ctx: *mut c_void, str: *mut RefString); @@ -67,7 +71,7 @@ impl RefString { /// /// Frees the byte /// buffer and then the `RefString` itself (self-destroying). Because - /// `RefString` is heap-allocated and held as `*mut RefString` (see `Map`), + /// `RefString` is heap-allocated and held as `NonNull` (see `Map`), /// this stays an explicit raw-pointer destroy rather than `impl Drop`. /// /// SAFETY: `this` must be the unique live reference to a `RefString` diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 8b9460b7d3bc..c71a871474fc 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -67,7 +67,8 @@ bun_core::declare_scope!(cache, visible); /// Version 35: Long hexadecimal, octal and binary literals are rounded to the nearest double. /// Version 36: `export`, `require()`, `import()`, a JSX element, an exported async function and /// an abstract class are mapped elsewhere. -const EXPECTED_VERSION: u32 = 36; +/// Version 37: A JSX spread no longer empties an object `--define`. Older entries can print it as `{}`. +const EXPECTED_VERSION: u32 = 37; /// Source files smaller than this are not written to / read from the on-disk /// transpiler cache. Originally 50 KiB, which excluded almost every file in a @@ -79,6 +80,9 @@ const EXPECTED_VERSION: u32 = 36; /// `is_stale`), so shrinking this does not weaken staleness detection. const MINIMUM_CACHE_SIZE: usize = 4 * 1024; +/// Length of the hex `input_hash` plus the longest suffix `write_cache_filename` appends. +const CACHE_FILE_NAME_MAX_LEN: usize = size_of::() * 2 + ".debug.pile".len(); + // When making parser changes, it gets extremely confusing. #[cfg(bun_debug)] static BUN_DEBUG_RESTORE_FROM_CACHE: AtomicBool = AtomicBool::new(false); @@ -519,6 +523,9 @@ impl Entry { } self.sourcemap = sourcemap; + } else { + // Without a sourcemap, a cache hit would produce unmapped stack traces for this module. + return Err(crate::CrateError::InvalidSourceMap); } if self.metadata.esm_record_byte_length > 0 { @@ -644,16 +651,16 @@ impl RuntimeTranspilerCache { // `abs_buf` (no NUL-terminating `_z` variant), so go straight to the // underlying joiner with the same `top_level_dir` + `Loose` platform // that `absBufZ` used. - let top = FileSystem::instance().top_level_dir; + let top = FileSystem::instance().top_level_dir(); if let Some(dir) = env_var::XDG_CACHE_HOME.get() { let parts: &[&[u8]] = &[dir, b"bun", b"@t@"]; - return path_handler::join_abs_string_buf_z::( + return path_handler::join_abs_string_buf_z_checked::( top, &mut buf[..], parts, ) - .len(); + .map_or(0, |dir| dir.len()); } #[cfg(target_os = "macos")] @@ -662,23 +669,23 @@ impl RuntimeTranspilerCache { // This is different than ~/.bun/install/cache, and not configurable by the user. if let Some(home) = env_var::HOME.get() { let parts: &[&[u8]] = &[home, b"Library/", b"Caches/", b"bun", b"@t@"]; - return path_handler::join_abs_string_buf_z::( + return path_handler::join_abs_string_buf_z_checked::( top, &mut buf[..], parts, ) - .len(); + .map_or(0, |dir| dir.len()); } } if let Some(dir) = env_var::HOME.get() { let parts: &[&[u8]] = &[dir, b".bun", b"install", b"cache", b"@t@"]; - return path_handler::join_abs_string_buf_z::( + return path_handler::join_abs_string_buf_z_checked::( top, &mut buf[..], parts, ) - .len(); + .map_or(0, |dir| dir.len()); } 0 @@ -706,7 +713,8 @@ impl RuntimeTranspilerCache { None => { let len = Self::CACHE_DIR_BUF .with_borrow_mut(|tl_buf| Self::really_get_cache_dir(tl_buf)); - if len == 0 { + // `get_cache_file_path` appends a separator, the file name and a NUL. + if len == 0 || len + 1 + CACHE_FILE_NAME_MAX_LEN + 1 > MAX_PATH_BYTES { IS_DISABLED.store(true, Ordering::Relaxed); return Err(crate::CrateError::CacheDisabled); } @@ -980,6 +988,10 @@ bun_ast::link_impl_TranspilerCacheImpl! { if this.input_hash.is_none() || IS_DISABLED.load(Ordering::Relaxed) { return; } + // `Entry::load` rejects an entry with no sourcemap. + if sourcemap.is_empty() { + return; + } debug_assert!(this.entry.is_none()); // Borrowed Latin-1 view: `to_file` only reads `byte_slice()` + the encoding diff --git a/src/jsc/SavedSourceMap.rs b/src/jsc/SavedSourceMap.rs index 7edf2e3ad7d0..46f46c554f21 100644 --- a/src/jsc/SavedSourceMap.rs +++ b/src/jsc/SavedSourceMap.rs @@ -183,25 +183,26 @@ impl SavedSourceMap { source: &bun_ast::Source, mut mappings: MutableString, ) -> bun_js_printer::Result<()> { + // Readers trust a stored blob's header, so validate it here. + if !InternalSourceMap::is_valid_blob(mappings.list.as_slice()) { + return Ok(()); + } + // --hot can re-read a file mid-rewrite (truncate + write) and transpile // a comment-only prefix into a 0-mapping map. Overwriting a real map // with that would make any still-unreported error from the previous // transpile remap against nothing and leak transpiled coords. A map // with no mappings can never answer a lookup, so dropping it is never // worse than installing it. - if mappings.list.len() >= SourceMap::internal_source_map::HEADER_SIZE { - let incoming = InternalSourceMap { - data: mappings.list.as_ptr(), - }; - if incoming.mapping_count() == 0 { - self.lock(); - let contains = self.map.contains_key(&hash(source.path.text)); - self.unlock(); - if contains { - return Ok(()); - } - // Note: reshaped for borrowck — the lock is - // released before returning since no further table access follows. + let incoming = InternalSourceMap { + data: mappings.list.as_ptr(), + }; + if incoming.mapping_count() == 0 { + self.lock(); + let contains = self.map.contains_key(&hash(source.path.text)); + self.unlock(); + if contains { + return Ok(()); } } diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 7f98053db4e7..4fbe9c6944be 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -93,6 +93,8 @@ pub struct InitOptions { /// `RuntimeHooks::init_runtime_state` so the high-tier `Transpiler::init` /// reuses the caller's env loader. pub env_loader: Option>, + /// Overrides `env_loader`. The VM owns it and frees it with its runtime state. + pub owned_env_loader: Option>, pub graph: Option<&'static dyn bun_resolver::StandaloneModuleGraph>, /// `--install=...`; the resolver needs it before `configure_linker()` caches package.json. pub global_cache: bun_options_types::global_cache::GlobalCache, @@ -119,6 +121,7 @@ impl Default for InitOptions { debugger: Default::default(), log: None, env_loader: None, + owned_env_loader: None, graph: None, global_cache: bun_options_types::global_cache::GlobalCache::disable, smol: false, @@ -239,6 +242,8 @@ pub struct VirtualMachine { pub dns_result_order: u8, pub cpu_profiler_config: Option, pub heap_profiler_config: Option, + /// Set by `BUN_JSC_samplingProfilerPath` or `bun:jsc`'s `startSamplingProfiler()`. + pub sampling_profiler_directory: Option>, pub counters: Counters, /// `--hot` / `--watch` mode this VM runs under. @@ -297,6 +302,8 @@ pub struct VirtualMachine { /// Used by bun:test to set global hooks for beforeAll, beforeEach, etc. pub is_in_preload: bool, + /// True if `load_preloads` kept the preload list for a reload that was deferred while it ran. + pub preloads_kept_for_reload: bool, pub has_patched_run_main: bool, pub transpiler_store: crate::runtime_transpiler_store::RuntimeTranspilerStore, @@ -330,7 +337,7 @@ pub struct VirtualMachine { pub(crate) resolved_path_dups: Vec>, pending_internal_promise: crate::strong::Optional, pub pending_internal_promise_reported_at: u32, - pub(crate) hot_reload_deferred: bool, + pub hot_reload_deferred: bool, pub entry_point_result: EntryPointResult, pub on_unhandled_rejection: OnUnhandledRejection, @@ -724,8 +731,8 @@ impl VMHolder { } /// Node parity: `process.kill(self, sig)` with no JS handler for `sig` - /// flushes the CPU and heap profiles before sending the (likely fatal) - /// signal, mirroring node's `Kill` binding. Idempotent via `Option::take` + /// flushes the profiles before sending the (likely fatal) signal, + /// mirroring node's `Kill` binding. Idempotent via `Option::take` /// (the compile cache is written again at a real exit; a bytecode order /// recording is written once and ends there). The recording is the main /// thread's to write: a Worker that sends the signal leaves none. @@ -734,20 +741,7 @@ impl VMHolder { let Some(vm_ptr) = VM.get() else { return }; // SAFETY: called on the JS thread that owns this VM (process._kill). let vm = unsafe { &mut *vm_ptr }; - if let Some(config) = vm.cpu_profiler_config.take() { - if let Err(e) = - crate::bun_cpu_profiler::stop_and_write_profile(vm.jsc_vm_mut(), &config) - { - bun_core::Output::err(<&'static str>::from(e), "Failed to write CPU profile", ()); - } - } - if let Some(config) = vm.heap_profiler_config.take() { - if let Err(e) = - crate::bun_heap_profiler::generate_and_write_profile(vm.jsc_vm_mut(), &config) - { - bun_core::Output::err(e, "Failed to write heap profile", ()); - } - } + vm.write_profiles(); // Node runs RunAtExit (incl. compile cache) on self-directed fatal signals. Non-latching: // the signal may prove non-fatal, and latching here would no-op the real exit's persist. // https://github.com/nodejs/node/blob/main/src/env.cc (AtExit(FlushCompileCache)) @@ -1025,9 +1019,10 @@ impl VirtualMachine { VM.get() } - /// The signal handler path (unix only) reaches the main VM through this. + /// Returns the main thread's VM. Callable from any thread. The VM is never freed, but other + /// threads may only touch its thread-safe state. #[cfg(unix)] - pub(crate) fn get_main_thread_vm() -> Option<*mut VirtualMachine> { + pub fn get_main_thread_vm() -> Option<*mut VirtualMachine> { let p = MAIN_THREAD_VM.load(core::sync::atomic::Ordering::Acquire); if p.is_null() { None } else { Some(p) } } @@ -1548,6 +1543,29 @@ impl VirtualMachine { self.bun_watcher } + /// Shuts down the file watcher. Its thread reads this VM while handling a change, so this waits + /// for the current change to finish and prevents new ones. + fn stop_import_watcher(&mut self) { + use crate::hot_reloader::ImportWatcher; + if self.bun_watcher.is_null() { + return; + } + // SAFETY: `install_bun_watcher` leaked this box, and the other threads that read it have finished. + // Keep it installed until `shutdown` returns because the in-progress change still reads it. + if let ImportWatcher::Hot(w) | ImportWatcher::Watch(w) = unsafe { &mut *self.bun_watcher } { + // SAFETY: the box is the one `Watcher::init` returned. + unsafe { bun_watcher::Watcher::shutdown(&raw mut **w, false) }; + } + self.transpiler.resolver.watcher = None; + let watcher = core::mem::replace(&mut self.bun_watcher, core::ptr::null_mut()); + // SAFETY: as above. + let watcher = unsafe { bun_core::heap::take(watcher) }; + if let ImportWatcher::Hot(w) | ImportWatcher::Watch(w) = *watcher { + // `shutdown` transferred ownership to the watcher thread. + let _ = Box::into_raw(w); + } + } + /// `event_loop().enter()` now, `.exit()` on drop. Safe wrapper over /// [`EventLoop::enter_scope`] for the common `vm.event_loop()` case. #[inline] @@ -1603,7 +1621,7 @@ impl VirtualMachine { /// allocated once during VM init and never freed. #[inline] pub fn top_level_dir(&self) -> &'static [u8] { - self.fs().top_level_dir + self.fs().top_level_dir() } /// Safe `&mut Debugger` accessor — the [`JsCell`] escape hatch applied to @@ -2252,6 +2270,37 @@ impl VirtualMachine { } } + /// Writes each configured profile once. The sampling report is written first because + /// `stopCPUProfiler` clears its traces. + pub fn write_profiles(&mut self) { + if let Some(directory) = self.sampling_profiler_directory.take() { + if let Err(e) = crate::bun_cpu_profiler::write_sampling_profiler_report( + self.jsc_vm_mut(), + &directory, + ) { + bun_core::Output::err( + <&'static str>::from(e), + "Failed to write sampling profiler report", + (), + ); + } + } + if let Some(config) = self.cpu_profiler_config.take() { + if let Err(e) = + crate::bun_cpu_profiler::stop_and_write_profile(self.jsc_vm_mut(), &config) + { + bun_core::Output::err(<&'static str>::from(e), "Failed to write CPU profile", ()); + } + } + if let Some(config) = self.heap_profiler_config.take() { + if let Err(e) = + crate::bun_heap_profiler::generate_and_write_profile(self.jsc_vm_mut(), &config) + { + bun_core::Output::err(e, "Failed to write heap profile", ()); + } + } + } + pub fn on_exit(&mut self) { // Decide once whether the exit sequence may run script. It can be // entered with an exception pending: `process.exit()` from inside a @@ -2274,25 +2323,8 @@ impl VirtualMachine { } } - // Write CPU profile if profiling was enabled - do this FIRST before any - // shutdown begins. Grab the config and null it out to make this - // idempotent. - if let Some(config) = self.cpu_profiler_config.take() { - if let Err(e) = - crate::bun_cpu_profiler::stop_and_write_profile(self.jsc_vm_mut(), &config) - { - bun_core::Output::err(<&'static str>::from(e), "Failed to write CPU profile", ()); - } - } - // Write heap profile if profiling was enabled - do this after CPU - // profile but before shutdown. - if let Some(config) = self.heap_profiler_config.take() { - if let Err(e) = - crate::bun_heap_profiler::generate_and_write_profile(self.jsc_vm_mut(), &config) - { - bun_core::Output::err(e, "Failed to write heap profile", ()); - } - } + // Write profiles before any shutdown begins. + self.write_profiles(); ExitHandler::dispatch_on_exit(self); @@ -2540,6 +2572,8 @@ impl VirtualMachine { (*this).release_queued_work(); let _ = Self::stop_phase_sweep(this, kind); }); + // SAFETY: fn contract (statement-scoped exclusive access). + unsafe { (*this).stop_import_watcher() }; // The exiting main thread now parks the process-wide HTTP thread — // after the children it also served are joined and this VM's own // requests are back — so it cannot touch what process exit frees. If @@ -3274,6 +3308,7 @@ impl VirtualMachine { // explicitly. addr_of_mut!((*vm).cpu_profiler_config).write(None); addr_of_mut!((*vm).heap_profiler_config).write(None); + addr_of_mut!((*vm).sampling_profiler_directory).write(None); // `Option` uses the bool's invalid range (2) as the niche, so // all-zero bytes decode as `Some(false)` — for TLS that would // silently disable certificate verification. Write `None` explicitly. @@ -4190,7 +4225,7 @@ unsafe extern "C" { extern "C" fn free_ref_string(str_: *mut crate::ref_string::RefString, _: *mut c_void, _: usize) { // SAFETY: `str_` is the `ctx` we passed to `String::create_external` in - // `ref_counted_string_with_was_new`; it points at a heap `RefString`. + // `ref_counted_string`; it points at a heap `RefString`. unsafe { crate::ref_string::RefString::destroy(str_) }; } @@ -4315,7 +4350,7 @@ impl VirtualMachine { // `&'static mut Loader` is independent of `&self`, so `map` may be held // across the `&mut self` writes below. let env = self.transpiler.env_mut(); - let map = &mut env.map; + let map = &env.map; ensure_source_code_printer(); // The runtime VM owns the printer from here on — even if a macro had @@ -4332,23 +4367,14 @@ impl VirtualMachine { self.transpiler_store.enabled = false; } - if let Some(idx) = map.map.get_index(b"NODE_CHANNEL_FD") { - let (_, kv) = map.map.swap_remove_at(idx); - let fd_s = kv.value; - let advanced = map - .map - .get_index(b"NODE_CHANNEL_SERIALIZATION_MODE") - .map(|i| map.map.swap_remove_at(i).1) - .is_some_and(|v| &v.value[..] == b"advanced"); - // Accept only - // non-negative values that fit in i31 (i.e. `0..=i32::MAX`). - // Parsing as `u32` then `as i32` would silently wrap values in - // `2^31..2^32` to a negative fd instead of taking the warn branch. - // The channel belongs to the process (its main thread). A worker - // sees the same inherited variables but must not open a second - // endpoint over the same fd (Node: no process.send() in workers). - if self.is_main_thread() { - match bun_core::fmt::parse_int::(&fd_s, 10) + // `Loader::load_process` skips these, so read them from the process environment. + // A worker has no process.send(). + if self.is_main_thread() { + if let Some(fd_s) = bun_core::env_var::NODE_CHANNEL_FD.get() { + let advanced = bun_core::env_var::NODE_CHANNEL_SERIALIZATION_MODE + .get() + .is_some_and(|v| bun_core::strings::eql(v, b"advanced")); + match bun_core::fmt::parse_int::(fd_s, 10) .ok() .filter(|&n| n >= 0) { @@ -4360,7 +4386,7 @@ impl VirtualMachine { } None => bun_core::warn!( "Failed to parse IPC channel number '{}'", - bstr::BStr::new(&fd_s[..]) + bstr::BStr::new(fd_s) ), } } @@ -4548,39 +4574,31 @@ impl VirtualMachine { /// After a hot reload, surfaces the entry-point promise's rejection (if any) and re-arms the watcher. pub fn report_exception_in_hot_reloaded_module_if_needed(&mut self) { - let promise = match self.pending_internal_promise() { - Some(p) => p, - None => { - self.add_main_to_watcher_if_needed(); - return; - } - }; - // SAFETY: `promise` is a live JSC heap cell tracked by the VM. - match crate::JSPromise::status_ptr(promise) { - crate::js_promise::Status::Pending => { - self.add_main_to_watcher_if_needed(); - return; - } - crate::js_promise::Status::Rejected => { - if self.pending_internal_promise_reported_at != self.hot_reload_counter { - self.pending_internal_promise_reported_at = self.hot_reload_counter; - // `global()` is `&'static`, so it survives the `&mut self` - // call below. - let global_ref = self.global(); - // `JSPromise` is an `opaque_ffi!` ZST handle; `opaque_mut` - // is the centralised non-null deref proof (live JSC heap - // cell tracked by the VM's strong-ref slot). - let result = crate::JSPromise::opaque_mut(promise).result(global_ref.vm()); - let promise_js = JSValue::from_cell(promise); - self.unhandled_rejection(global_ref, result, promise_js); - crate::JSPromise::opaque_mut(promise).set_handled(); + while let Some(promise) = self.pending_internal_promise() { + // SAFETY: `promise` is a live JSC heap cell tracked by the VM. + match crate::JSPromise::status_ptr(promise) { + crate::js_promise::Status::Pending => break, + crate::js_promise::Status::Rejected => { + if self.pending_internal_promise_reported_at != self.hot_reload_counter { + self.pending_internal_promise_reported_at = self.hot_reload_counter; + // `global()` is `&'static`, so it survives the `&mut self` call below. + let global_ref = self.global(); + // `opaque_mut`: the VM's slot keeps this JSC heap cell alive. + let result = crate::JSPromise::opaque_mut(promise).result(global_ref.vm()); + let promise_js = JSValue::from_cell(promise); + self.unhandled_rejection(global_ref, result, promise_js); + crate::JSPromise::opaque_mut(promise).set_handled(); + } } + crate::js_promise::Status::Fulfilled => {} } - crate::js_promise::Status::Fulfilled => {} - } - if self.hot_reload_deferred { + if !self.hot_reload_deferred { + break; + } self.reload(None); + // This reload wasn't started by a task, so run its microtasks here before the loop sleeps. + self.tick(); } self.add_main_to_watcher_if_needed(); } @@ -4696,6 +4714,12 @@ impl VirtualMachine { unreachable!("reload_process returns only when may_return is true"); } + // Reloading while `load_preloads` ticks would clear the list it's iterating. + if self.is_in_preload { + self.hot_reload_deferred = true; + return; + } + if let Some(p) = self.pending_internal_promise() { // SAFETY: `p` is a live JSC heap cell tracked by the VM. match crate::JSPromise::status_ptr(p) { @@ -4734,15 +4758,40 @@ impl VirtualMachine { // the JSC module loader registry. self.global().reload().expect("Failed to reload"); self.hot_reload_counter += 1; - // reload_entry_point() stores into pending_internal_promise on every return path. + // reload_entry_point() stores into pending_internal_promise whenever it returns a promise. let main = self.main; // Note: reshaped for borrowck — copy the `RawSlice` first to avoid // overlapping `&self`/`&mut self` borrows. - if self - .reload_entry_point(main.as_ref().map(|main| main.slice())) - .is_err() - { - panic!("Failed to reload"); + if let Err(err) = self.reload_entry_point(main.as_ref().map(|main| main.slice())) { + self.reject_failed_reload(err); + } + } + + /// Sets the entry promise to a rejected promise when `reload_entry_point` fails without returning one. + #[cold] + fn reject_failed_reload(&mut self, err: crate::CrateError) { + use crate::LogJsc as _; + let global = self.global(); + let reason = if global.has_exception() { + Err(jsc::JsError::Thrown) + } else if let Some(log) = self.log_mut().filter(|log| log.has_errors()) { + let reason = log.to_js(global, format_args!("Failed to reload")); + log.reset(); + reason + } else { + Ok(global.create_error_instance(format_args!( + "Error occurred loading entry point: {}", + err.name(), + ))) + }; + let rejected = match reason { + Ok(reason) => Ok(crate::JSPromise::rejected_promise(global, reason)), + Err(thrown) => crate::JSPromise::rejected_promise_with_caught_exception(global, thrown), + }; + if let Ok(rejected) = rejected { + // The run loop reports the entry promise's rejection, so keep it out of the unhandled rejection tracker. + rejected.set_handled(); + self.set_pending_internal_promise(Some(core::ptr::from_mut(rejected))); } } @@ -4908,11 +4957,11 @@ impl VirtualMachine { } /// Stored as [`RefString::on_before_deinit`] (an unsafe-fn-ptr slot) in - /// [`ref_counted_string_with_was_new`]; only ever invoked from + /// [`Self::ref_counted_string`]; only ever invoked from /// `RefString::destroy` with the live `*mut RefString` being torn down. fn clear_ref_string(_: *mut c_void, ref_string: *mut crate::ref_string::RefString) { // SAFETY: only reachable via `RefString::destroy`, which passes the - // live heap `RefString` allocated in `ref_counted_string_with_was_new`; + // live heap `RefString` allocated in `ref_counted_string`; // safe-fn coerces to the unsafe-fn-ptr `Callback` slot type. let hash = unsafe { &*ref_string }.hash; // SAFETY: `get()` is the live per-thread VM. @@ -4932,60 +4981,52 @@ impl VirtualMachine { specifier: &bun_core::String, source_url: &[u8], hash_: Option, - ) -> ResolvedSource { + ) -> Result { // refCountedString will panic if the code is empty if code.is_empty() { - return ResolvedSource { + return Ok(ResolvedSource { source_url: specifier.create_if_different(source_url), ..Default::default() - }; + }); } - let source = self.ref_counted_string::(code, hash_); - // SAFETY: `ref_counted_string` returns a live `*mut RefString` held in + let source = self.ref_counted_string(code, hash_)?; + // SAFETY: `ref_counted_string` returns a live `RefString` held in // `self.ref_strings` until JSC calls the external-string finalizer. - let source_ref = unsafe { &*source }; + let source_ref = unsafe { source.as_ref() }; - ResolvedSource { + Ok(ResolvedSource { source_code: bun_core::String::retain_wtf_impl(source_ref.impl_), source_url: specifier.create_if_different(source_url), ..Default::default() - } + }) } - fn ref_counted_string_with_was_new( + /// Interns `input_` in the VM's ref-string map and returns the ref-counted entry. + pub fn ref_counted_string( &mut self, - new: &mut bool, input_: &[u8], hash_: Option, - ) -> *mut crate::ref_string::RefString { - use crate::ref_string::RefString; + ) -> Result, crate::ref_string::StringTooLong> { + use crate::ref_string::{RefString, StringTooLong}; use bun_collections::zig_hash_map::MapEntry as Entry; jsc::mark_binding(); debug_assert!(!input_.is_empty()); let hash = hash_.unwrap_or_else(|| RefString::compute_hash(input_)); - // RAII guard releases on every - // exit (including the early-return `Occupied` arm). let _unlock = self.ref_strings_mutex.lock_guard(); // Note: reshaped for borrowck — capture the back-pointer before // `ref_strings.entry()` takes its unique borrow on `self`. let self_ctx = NonNull::new(std::ptr::from_mut::(self).cast::()); match self.ref_strings.entry(hash) { - Entry::Occupied(o) => { - *new = false; - *o.get() - } + Entry::Occupied(o) => Ok(*o.get()), Entry::Vacant(v) => { - // Dupe the input bytes when `DUPE`, otherwise - // adopt the caller's allocation (caller transferred ownership). - let (ptr, len) = if DUPE { - let buf = Box::<[u8]>::from(input_); - let len = buf.len(); - (bun_core::heap::into_raw(buf).cast::().cast_const(), len) - } else { - (input_.as_ptr(), input_.len()) - }; - let ref_ = bun_core::heap::into_raw(Box::new(RefString { + if input_.len() > bun_core::String::max_length() { + return Err(StringTooLong); + } + let buf = Box::<[u8]>::from(input_); + let len = buf.len(); + let ptr = bun_core::heap::into_raw(buf).cast::().cast_const(); + let ref_ = bun_core::heap::into_raw_nn(Box::new(RefString { ptr, len, hash, @@ -4993,7 +5034,8 @@ impl VirtualMachine { // `*mut RefString` ctx pointer first. impl_: core::ptr::null_mut(), ctx: self_ctx, - on_before_deinit: Some(VirtualMachine::clear_ref_string), + // Set after `create_external` succeeds. If it fails, it runs `free_ref_string` while `v` still borrows the map. + on_before_deinit: None, })); // SAFETY: `ref_` is the unique live `*mut RefString` (just // boxed); `(ptr, len)` is its owned latin-1 buffer. The @@ -5003,29 +5045,25 @@ impl VirtualMachine { let s = bun_core::String::create_external::<*mut RefString>( unsafe { bun_core::ffi::slice(ptr, len) }, true, - ref_, + ref_.as_ptr(), free_ref_string, ); - // SAFETY: see above. - unsafe { (*ref_).impl_ = s.leak_wtf_impl() }; + if s.tag() == bun_core::Tag::Dead { + // `create_external` already freed `ref_`. + return Err(StringTooLong); + } + // SAFETY: `s` owns the impl, so `free_ref_string` has not run + // and `ref_` is still the unique live `RefString`. + unsafe { + (*ref_.as_ptr()).impl_ = s.leak_wtf_impl(); + (*ref_.as_ptr()).on_before_deinit = Some(VirtualMachine::clear_ref_string); + } v.insert(ref_); - *new = true; - ref_ + Ok(ref_) } } } - /// Interns `input_` in the VM's ref-string map and returns the ref-counted entry. - pub fn ref_counted_string( - &mut self, - input_: &[u8], - hash_: Option, - ) -> *mut crate::ref_string::RefString { - debug_assert!(!input_.is_empty()); - let mut was_new = false; - self.ref_counted_string_with_was_new::(&mut was_new, input_, hash_) - } - // Note: `flags` is a runtime arg — // `FetchFlags` would need `ConstParamTy` (unstable derive on the enum's // owning module) to be a const generic; the only branches are cheap @@ -5665,6 +5703,7 @@ impl VirtualMachine { drop(core::mem::take(&mut self.resolved_path_dups)); drop(core::mem::take(&mut self.main_resolved_path)); + drop(self.sampling_profiler_directory.take()); self.overridden_main.deinit(); self.bake_config_plugins.deinit(); @@ -5858,36 +5897,43 @@ impl VirtualMachine { } pub fn set_process_cwd(&mut self, to: &bun_core::ZStr) -> bun_sys::Result<()> { - let fs = self.transpiler.fs_mut(); + let fs = self.fs(); bun_sys::chdir(to)?; let mut buf = bun_paths::path_buffer_pool::get(); - let into_cwd_len = match bun_sys::getcwd(&mut buf[..]) { + let mut len = match bun_sys::getcwd(&mut buf[..]) { bun_sys::Result::Ok(r) => r, bun_sys::Result::Err(err) => { let mut rollback = bun_paths::path_buffer_pool::get(); - let _ = bun_sys::chdir(bun_paths::resolve_path::z(fs.top_level_dir, &mut rollback)); + let _ = bun_sys::chdir(bun_paths::resolve_path::z( + fs.top_level_dir(), + &mut rollback, + )); return bun_sys::Result::Err(err); } }; - fs.top_level_dir_buf[..into_cwd_len].copy_from_slice(&buf[..into_cwd_len]); - fs.top_level_dir_buf[into_cwd_len] = 0; - // SAFETY: `top_level_dir_buf` is a process-lifetime field of the - // FileSystem singleton, so the detached borrow never outlives its - // backing storage. - fs.top_level_dir = - unsafe { bun_ptr::detach_lifetime(&fs.top_level_dir_buf[..into_cwd_len]) }; - let len = fs.top_level_dir.len(); - if fs.top_level_dir_buf[len - 1] != bun_paths::SEP { - fs.top_level_dir_buf[len] = bun_paths::SEP; - fs.top_level_dir_buf[len + 1] = 0; - // SAFETY: see above. - fs.top_level_dir = - unsafe { bun_ptr::detach_lifetime(&fs.top_level_dir_buf[..len + 1]) }; + if len > 0 && buf[len - 1] != bun_paths::SEP && len < buf.len() { + buf[len] = bun_paths::SEP; + len += 1; } - bun_core::set_top_level_dir(fs.top_level_dir); + fs.set_top_level_dir(Self::intern_cwd(fs, &buf[..len])); Ok(()) } + /// Returns a `'static` copy of `cwd`. Other threads keep slices of the cwd, so a published one + /// is never overwritten or freed. + fn intern_cwd(fs: &bun_resolver::fs::FileSystem, cwd: &[u8]) -> &'static [u8] { + static CACHE: bun_core::Mutex>> = + bun_core::Mutex::new(None); + let mut cache = CACHE.lock(); + let cache = cache.get_or_insert_default(); + if let Some((&interned, ())) = cache.get_key_value(cwd) { + return interned; + } + let interned = bun_core::handle_oom(fs.dirname_store().append(cwd)); + cache.insert(interned, ()); + interned + } + /// Whether `group` outlives a test file under `--isolate`: the spawn-IPC pool (this process's /// own inbound IPC included) and the test-parallel channel. fn is_test_runner_socket_group(&self, group: *const uws::SocketGroup) -> bool { @@ -6160,7 +6206,20 @@ impl VirtualMachine { let writer = unsafe { &mut *ctx.writer }; ctx.printed_member = true; formatter.depth = formatter.depth.saturating_add(1); - if formatter.depth > formatter.error_chain_max_depth() + if next_value.is_cell() + && next_value.js_type() == crate::JSType::ErrorInstance + && formatter.visited_contains(next_value) + { + let _ = if ctx.allow_ansi_color { + writer.write_all( + bun_core::pretty_fmt!("[Circular]\n", true).as_bytes(), + ) + } else { + writer.write_all( + bun_core::pretty_fmt!("[Circular]\n", false).as_bytes(), + ) + }; + } else if formatter.depth > formatter.error_chain_max_depth() || !formatter.stack_check.is_safe_to_recurse() { let _ = if ctx.allow_ansi_color { @@ -6827,6 +6886,7 @@ impl VirtualMachine { ) -> crate::CrateResult<()> { let mut default_formatter = crate::console_object::Formatter::new(self.global()); let f = formatter.unwrap_or(&mut default_formatter); + let mut recorded = false; self.print_error_instance_body( zig_exception, JSValue::ZERO, @@ -6835,6 +6895,7 @@ impl VirtualMachine { writer, allow_ansi_color, allow_side_effects, + &mut recorded, ) // `defer default_formatter.deinit()` → Drop. } @@ -6862,8 +6923,7 @@ impl VirtualMachine { // re-check here with an extra `MAX_PATH_BYTES * 3` of slack on Windows // to cover the transpiler's nested path buffers — same parity-level // protection the Object path gets from C++ `forEachProperty`'s - // `vm.isSafeToRecurse()`. The formatter's `stack_check` was seated by - // the caller (`format2` / `Bun.inspect`). + // `vm.isSafeToRecurse()`. let extra_headroom: usize = if cfg!(windows) { // 3× PathBuffer ≈ 288 KB — empirically enough for the // `remap_zig_exception` → `transpile_source_code` chain on the @@ -6883,6 +6943,18 @@ impl VirtualMachine { return Ok(()); } + if error_instance.is_cell() + && error_instance.js_type() == crate::JSType::ErrorInstance + && formatter.visited_contains(error_instance) + { + writer.write_all(if allow_ansi_color { + bun_core::pretty_fmt!("[Circular]", true).as_bytes() + } else { + bun_core::pretty_fmt!("[Circular]", false).as_bytes() + })?; + return Ok(()); + } + // Note: `Holder` is ~4 KB (32 ZigStackFrames + 6 source lines + // ZigException). It sits next to the large runtime-dispatched body, so // box it to keep the per-level recursion frame small enough for the @@ -6906,6 +6978,7 @@ impl VirtualMachine { ); error_instance.ensure_still_alive(); + let mut recorded = false; let result = self.print_error_instance_body( // SAFETY: see above. unsafe { &mut *exception }, @@ -6916,7 +6989,11 @@ impl VirtualMachine { writer, allow_ansi_color, allow_side_effects, + &mut recorded, ); + if recorded { + formatter.visited_remove(error_instance); + } drop(source_code_slice); exception_holder.deinit(self); @@ -6938,10 +7015,11 @@ impl VirtualMachine { writer: &mut bun_core::io::Writer, allow_ansi_color: bool, allow_side_effects: bool, + recorded: &mut bool, ) -> crate::CrateResult<()> { use crate::JSType; use crate::console_object::formatter::TagOptions; - use crate::console_object::{self, Tag, TagPayload}; + use crate::console_object::{Tag, TagPayload}; let prev_had_errors = self.had_errors; self.had_errors = true; @@ -7281,7 +7359,9 @@ impl VirtualMachine { } let kind = value.js_type(); - if kind == JSType::ErrorInstance && !prev_had_errors { + let circular = kind == JSType::ErrorInstance + && (value == error_instance || formatter.visited_contains(value)); + if kind == JSType::ErrorInstance && !prev_had_errors && !circular { if field.eq_ascii(b"cause") { saw_cause = true; } @@ -7292,6 +7372,12 @@ impl VirtualMachine { || value.is_primitive() || kind.is_string_like() { + if circular && field.eq_ascii(b"cause") { + saw_cause = true; + } + if !*recorded && !value.is_primitive() { + *recorded = formatter.visited_insert(error_instance); + } let prev_disable_inspect_custom = formatter.disable_inspect_custom; let prev_quote_strings = formatter.quote_strings; let prev_max_depth = formatter.max_depth; @@ -7375,20 +7461,30 @@ impl VirtualMachine { )?; } - if !is_first_property { - writer.write_all(b"\n")?; - } - // "cause" is not enumerable, so the above loop won't see it. if !saw_cause { let key = bun_core::String::static_("cause"); if let Some(cause) = error_instance.get_own(global_ref, &key)? { if cause.is_cell() && cause.js_type() == JSType::ErrorInstance { - cause.protect(); - errors_to_append.push(cause); + if cause == error_instance || formatter.visited_contains(cause) { + let pad_left = longest_name.saturating_sub(b"cause".len()); + is_first_property = false; + splat_space(writer, pad_left as u64)?; + pretty_write!( + writer, + " cause: [Circular],\n" + )?; + } else { + cause.protect(); + errors_to_append.push(cause); + } } } } + + if !is_first_property { + writer.write_all(b"\n")?; + } } else if error_instance != JSValue::ZERO { // If you do `reportError([1,2,3])` we should still show something. let tag = Tag::get_advanced( @@ -7397,11 +7493,11 @@ impl VirtualMachine { TagOptions::DISABLE_INSPECT_CUSTOM | TagOptions::HIDE_GLOBAL, )?; if !matches!(tag.tag, TagPayload::NativeCode) { - let _ = if allow_ansi_color { - formatter.format::(tag, writer, error_instance, global_ref) + if allow_ansi_color { + formatter.format::(tag, writer, error_instance, global_ref)?; } else { - formatter.format::(tag, writer, error_instance, global_ref) - }; + formatter.format::(tag, writer, error_instance, global_ref)?; + } writer.write_all(b"\n")?; } } @@ -7416,25 +7512,12 @@ impl VirtualMachine { )?; } + if !*recorded && !errors_to_append.is_empty() { + *recorded = formatter.visited_insert(error_instance); + } + let mut exception_list = exception_list; for &err in &errors_to_append { - // Circular-ref guard for cause chains. - if formatter.map_node.is_none() { - let mut node = NonNull::new(console_object::formatter::visited::Pool::get_node()) - .expect("ObjectPool::get_node always returns a valid heap node"); - let data = console_object::formatter::visited::node_data_mut(&mut node); - data.clear(); - formatter.map = core::mem::take(data); - formatter.map_node = Some(node); - } - - let entry = formatter.map.get_or_put(err).expect("unreachable"); - if entry.found_existing { - writer.write_all(b"\n")?; - pretty_write!(writer, "[Circular]")?; - continue; - } - writer.write_all(b"\n")?; let prev_depth = formatter.depth; formatter.depth = formatter.depth.saturating_add(1); @@ -7452,7 +7535,6 @@ impl VirtualMachine { ) }; formatter.depth = prev_depth; - let _ = formatter.map.remove(&err); result?; } @@ -7569,7 +7651,7 @@ impl VirtualMachine { let frames = exception.stack.frames(); let top_frame = frames.first(); let dir = bun_core::env_var::GITHUB_WORKSPACE::get() - .unwrap_or_else(|| bun_bundler::bun_fs::FileSystem::instance().top_level_dir); + .unwrap_or_else(|| bun_bundler::bun_fs::FileSystem::instance().top_level_dir()); bun_core::Output::flush(); let writer = bun_core::Output::error_writer(); diff --git a/src/jsc/VmHandle.rs b/src/jsc/VmHandle.rs index ee3cf34d826d..9d635246c691 100644 --- a/src/jsc/VmHandle.rs +++ b/src/jsc/VmHandle.rs @@ -413,6 +413,12 @@ impl VmHandle { self.0.state() == State::Open } + /// Returns true once teardown has finished waiting. Nothing waits for a ticket issued after that. + #[inline] + pub fn closed(&self) -> bool { + self.0.state() == State::Closed + } + pub(crate) fn tickets_outstanding(&self) -> u32 { self.0.tickets.load(Ordering::SeqCst) } @@ -515,11 +521,17 @@ impl VirtualMachine { let h = self.handle_ref(); h.assert_js_thread(); debug_assert!( - h.0.state() != State::Closed, + !h.closed(), "off-thread work started after the VM finished draining" ); Ticket::issue(&h.0, self.current_loop_kind()) } + + /// See [`VmHandle::closed`]. + #[inline] + pub fn closed(&self) -> bool { + self.handle_ref().closed() + } } /// JS thread: [`VirtualMachine::current_loop_kind`] for C++ (`BunLoopKind`), diff --git a/src/jsc/array_buffer.rs b/src/jsc/array_buffer.rs index 149d4671e90c..ff78c05ab367 100644 --- a/src/jsc/array_buffer.rs +++ b/src/jsc/array_buffer.rs @@ -138,6 +138,11 @@ unsafe extern "C" { safe fn JSC__ArrayBuffer__deref(self_: &JSCArrayBuffer); // safe: by-value `JSValue`; no-op for non-buffer values. safe fn JSC__JSValue__unpinArrayBuffer(v: JSValue); + // safe: by-value `JSValue`; false for non-buffer values. + safe fn JSC__JSValue__isNonSharedWasmMemory(v: JSValue) -> bool; + safe fn JSC__JSValue__canBeFreedWhilePinned(v: JSValue) -> bool; + // safe: by-value `JSValue`; the callee always fills both out-params. + safe fn JSC__JSValue__arrayBufferExtent(v: JSValue, out_ptr: &mut *mut u8, out_len: &mut usize); } impl JSValue { @@ -159,6 +164,33 @@ impl ArrayBuffer { } } + /// Returns true if another thread can write to the bytes (`SharedArrayBuffer`) or JS that runs + /// during the call can unmap them with `resize()`. + pub fn can_change_under_borrow(&self) -> bool { + self.shared || self.resizable + } + + /// Returns a private copy of the bytes, or `None` if allocation fails. Never inlined, so the + /// compiler can't re-read the `SharedArrayBuffer` in place of the copy. + #[inline(never)] + pub fn try_copy_bytes(&self) -> Option> { + let bytes = self.byte_slice(); + let mut copy = Vec::new(); + copy.try_reserve_exact(bytes.len()).ok()?; + copy.extend_from_slice(bytes); + Some(copy) + } + + /// True if a pin doesn't keep these bytes mapped. `resize()` unmaps the pages it trims, and + /// `grow()` detaches a non-shared `WebAssembly.Memory`'s buffer regardless of the pin count, + /// which frees the old block immediately or when the memory is collected. + /// Copy these bytes if the borrow outlives the call. + /// + /// A call instead of a field, so that `as_array_buffer` callers that never ask don't pay for the answer. + pub fn can_be_freed_while_pinned(&self) -> bool { + JSC__JSValue__canBeFreedWhilePinned(self.value) + } + // require('buffer').kMaxLength. // keep in sync with Bun::Buffer::kMaxLength pub const MAX_SIZE: c_uint = c_uint::MAX; @@ -647,14 +679,38 @@ impl ArrayBuffer { /// A JS ArrayBuffer/view whose backing store is pinned (cannot be detached or /// moved) for as long as this value lives; [`root`](Self::root) additionally -/// GC-roots the cell. `Drop` releases what was taken. Constructed on the JS -/// thread; a `root()`ed value is dropped there too, while a `pin()`-only value -/// held by a `Blob` store drops wherever the store's last ref goes. +/// GC-roots the cell. `Drop` releases what was taken. Constructed and dropped on the JS +/// thread. pub struct PinnedArrayBuffer { buffer: ArrayBuffer, rooted: bool, - /// The bytes `buffer.ptr` points at when [`copy_if_resizable`](Self::copy_if_resizable) took a copy. - copy: Option>, + /// Set when [`copy_if_freeable_while_pinned`](Self::copy_if_freeable_while_pinned) or + /// [`write_to_scratch_if_wasm`](Self::write_to_scratch_if_wasm) replaced the JS storage. + /// Boxed because it is rare and this struct is moved by value on hot paths. + copy: Option>, +} + +/// Native bytes that `buffer.ptr` points to instead of the JS storage. +struct NativeCopy { + bytes: Vec, + /// Offset in the view where [`write_back`](PinnedArrayBuffer::write_back) copies `bytes`. `None` for a read-only snapshot. + write_back_at: Option, +} + +impl NativeCopy { + /// Copies the first `len` bytes to `write_back_at` in `view`'s current range. + fn write_back(&self, view: JSValue, len: usize) { + let Some(offset) = self.write_back_at else { + return; + }; + let mut live = ArrayBuffer::default(); + JSC__JSValue__arrayBufferExtent(view, &mut live.ptr, &mut live.byte_len); + let Some(target) = live.byte_slice_mut().get_mut(offset..) else { + return; + }; + let n = len.min(self.bytes.len()).min(target.len()); + target[..n].copy_from_slice(&self.bytes[..n]); + } } impl PinnedArrayBuffer { @@ -685,16 +741,20 @@ impl PinnedArrayBuffer { Some(this) } - /// [`root`](Self::root) for a job that reads the bytes itself: see [`copy_if_resizable`](Self::copy_if_resizable). + /// Like [`root`](Self::root), but for a job that reads the bytes itself. See [`copy_if_freeable_while_pinned`](Self::copy_if_freeable_while_pinned). pub fn root_read_only(global: &JSGlobalObject, value: JSValue) -> Option { + // Asking first makes the usual case a tail call, so the result isn't moved again. + if !JSC__JSValue__canBeFreedWhilePinned(value) { + return Self::root(global, value); + } let mut this = Self::root(global, value)?; - this.copy_if_resizable(global).then_some(this) + this.copy_if_freeable_while_pinned(global).then_some(this) } - /// A pin stops a detach but not a shrink, which unmaps pages: a resizable non-shared buffer is copied so a later read of the bytes in user space cannot fault (a syscall reader gets `EFAULT` and needs no copy). `false` if the copy cannot be allocated. - pub fn copy_if_resizable(&mut self, global: &JSGlobalObject) -> bool { - if !self.buffer.resizable - || self.buffer.shared + /// Copies the bytes if they [`can_be_freed_while_pinned`](ArrayBuffer::can_be_freed_while_pinned), + /// so a later read can't fault. Returns `false` if the copy can't be allocated. + pub fn copy_if_freeable_while_pinned(&mut self, global: &JSGlobalObject) -> bool { + if !self.buffer.can_be_freed_while_pinned() || self.buffer.byte_len == 0 || self.copy.is_some() { @@ -708,16 +768,89 @@ impl PinnedArrayBuffer { copy.extend_from_slice(bytes); global.vm().report_extra_memory(copy.len()); self.buffer.ptr = copy.as_mut_ptr(); - self.copy = Some(copy); + self.copy = Some(Box::new(NativeCopy { + bytes: copy, + write_back_at: None, + })); + true + } + + /// Swaps in `len` bytes of scratch space if `grow()` could free the block while a job writes to + /// it. If this returns true, the job writes from index 0 and [`write_back`](Self::write_back) + /// copies the bytes to `offset`. + #[inline] + pub fn write_to_scratch_if_wasm(&mut self, offset: usize, len: usize) -> bool { + if len == 0 || !JSC__JSValue__isNonSharedWasmMemory(self.buffer.value) { + return false; + } + let mut scratch = vec![0u8; len]; + self.buffer.ptr = scratch.as_mut_ptr(); + self.buffer.len = len; + self.buffer.byte_len = len; + self.copy = Some(Box::new(NativeCopy { + bytes: scratch, + write_back_at: Some(offset), + })); true } + /// Call on the JS thread after the job is done. Copies the `len` bytes it wrote into the view, + /// clamped to the view's current size. No-op unless + /// [`write_to_scratch_if_wasm`](Self::write_to_scratch_if_wasm) returned true. + #[inline] + pub fn write_back(&mut self, len: usize) { + if let Some(scratch) = self.copy.take_if(|copy| copy.write_back_at.is_some()) { + self.buffer.ptr = ptr::null_mut(); + self.buffer.len = 0; + self.buffer.byte_len = 0; + // Don't pass a pointer to `self` to the out-of-line call, so a job that owns `self` by value can keep it in registers. + scratch.write_back(self.buffer.value, len); + } + } + #[inline] pub fn slice_mut(&mut self) -> &mut [u8] { - debug_assert!(self.copy.is_none(), "a read-only root is not writable"); + debug_assert!( + self.copy + .as_ref() + .is_none_or(|copy| copy.write_back_at.is_some()), + "a read-only root is not writable" + ); self.buffer.byte_slice_mut() } + /// Returns the view's current range from JSC, or null and 0 if it's detached. The range can + /// change while pinned (see [`ArrayBuffer::can_be_freed_while_pinned`]). + fn live_range(&self) -> (*mut u8, usize) { + debug_assert!(self.copy.is_none()); + let mut ptr = ptr::null_mut(); + let mut byte_len = 0usize; + JSC__JSValue__arrayBufferExtent(self.buffer.value, &mut ptr, &mut byte_len); + (ptr, byte_len) + } + + /// Like [`slice_mut`](Self::slice_mut), but re-reads the range. For a writer that runs after user JS. + pub fn live_slice_mut(&mut self) -> &mut [u8] { + let (ptr, byte_len) = self.live_range(); + self.buffer.ptr = ptr; + self.buffer.byte_len = byte_len; + self.buffer.len = match self.buffer.bytes_per_element() { + Some(size) => byte_len / size as usize, + None => byte_len, + }; + self.slice_mut() + } + + /// Like [`slice`](ArrayBuffer::slice), but re-reads the range. For a reader that runs after user JS. + pub fn live_slice(&self) -> &[u8] { + let (ptr, byte_len) = self.live_range(); + if ptr.is_null() { + return &[]; + } + // SAFETY: JSC reports the view's own range, and no JS runs while the borrow of `self` lives. + unsafe { core::slice::from_raw_parts(ptr, byte_len) } + } + /// VM-shutdown finalizer only: the heap sweep already deleted what `Drop` /// would unpin and unprotect, so release nothing. pub fn defuse(&mut self) { @@ -744,8 +877,7 @@ impl Drop for PinnedArrayBuffer { } } -// SAFETY: a pin and GC protection on a heap cell; constructed on the JS thread, -// and a rooted value is dropped there (a pin-only one may drop with its `Blob` store). +// SAFETY: a pin and GC protection on a heap cell; constructed and dropped on the JS thread. unsafe impl crate::job::JsAffine for PinnedArrayBuffer {} // ────────────────────────────────────────────────────────────────────────── @@ -754,6 +886,7 @@ unsafe impl crate::job::JsAffine for PinnedArrayBuffer {} #[repr(u8)] #[derive(Clone, Copy, PartialEq, Eq, Hash, strum::IntoStaticStr)] +#[strum(serialize_all = "lowercase")] pub enum BinaryType { Buffer, ArrayBuffer, diff --git a/src/jsc/bindgen.rs b/src/jsc/bindgen.rs index b769c56469b3..238cb1555049 100644 --- a/src/jsc/bindgen.rs +++ b/src/jsc/bindgen.rs @@ -489,6 +489,36 @@ impl<'a> JsBytes<'a> { }, } } + + /// `shared: "copied"` for `string: "asIs"`. Like [`get`](Self::get), but copies the bytes of a shared buffer into `copy` and returns those. + #[inline] + pub fn get_or_copy_if_shared<'heap>( + self, + heap: &'heap JsHeap<'a>, + copy: &'heap mut Vec, + ) -> crate::JsResult<&'heap [u8]> { + if let Some(bytes) = self.stable { + return Ok(bytes); + } + let mut is_shared = false; + let bytes = extern_types::Bun__Bindgen__bytesOfBlobOrBufferAndIsShared( + self.value.0, + &mut is_shared, + ); + if !is_shared { + // SAFETY: as in `get`. + return Ok(unsafe { bytes.get(heap) }); + } + let global = heap.global_object(); + let buffer = self.value.0.as_array_buffer(global); + match buffer.and_then(|buffer| buffer.try_copy_bytes()) { + Some(bytes) => { + *copy = bytes; + Ok(copy) + } + None => Err(global.throw_out_of_memory()), + } + } } /// `b.bytes` with `string: "asIs"`. diff --git a/src/jsc/bindgen/extern_types.rs b/src/jsc/bindgen/extern_types.rs index 58d6be530df9..b3d912aa9d61 100644 --- a/src/jsc/bindgen/extern_types.rs +++ b/src/jsc/bindgen/extern_types.rs @@ -120,6 +120,11 @@ pub struct ExternWithValue { unsafe extern "C" { /// `value` is a `BufferSource` or a `Blob` whose bytes are in memory. pub(super) safe fn Bun__Bindgen__bytesOfBlobOrBuffer(value: JSValue) -> ExternBytes; + /// As above. Always sets `is_shared`. + pub(super) safe fn Bun__Bindgen__bytesOfBlobOrBufferAndIsShared( + value: JSValue, + is_shared: &mut bool, + ) -> ExternBytes; pub(super) fn Bun__Bindgen__constructArrayFromValues( global: *mut JSGlobalObject, values: *const JSValue, diff --git a/src/jsc/bindings/Bindgen/JSBytesView.cpp b/src/jsc/bindings/Bindgen/JSBytesView.cpp index 083b218b861d..d6beab378131 100644 --- a/src/jsc/bindings/Bindgen/JSBytesView.cpp +++ b/src/jsc/bindings/Bindgen/JSBytesView.cpp @@ -61,7 +61,7 @@ bool isSharedBuffer(JSValue value) if (auto* view = dynamicDowncast(value)) return view->isShared(); if (auto* buffer = dynamicDowncast(value)) - return buffer->isShared(); + return buffer->impl()->isShared(); return false; } @@ -87,3 +87,9 @@ extern "C" Bun::Bindgen::ExternBytes Bun__Bindgen__bytesOfBlobOrBuffer(JSC::Enco auto bytes = Bun::Bindgen::bytesOfBlobOrBuffer(JSC::JSValue::decode(value)); return { bytes.data(), bytes.size() }; } + +extern "C" Bun::Bindgen::ExternBytes Bun__Bindgen__bytesOfBlobOrBufferAndIsShared(JSC::EncodedJSValue value, bool* isShared) +{ + *isShared = Bun::Bindgen::isSharedBuffer(JSC::JSValue::decode(value)); + return Bun__Bindgen__bytesOfBlobOrBuffer(value); +} diff --git a/src/jsc/bindings/BunCPUProfiler.cpp b/src/jsc/bindings/BunCPUProfiler.cpp index 978ab5660494..e514c555428b 100644 --- a/src/jsc/bindings/BunCPUProfiler.cpp +++ b/src/jsc/bindings/BunCPUProfiler.cpp @@ -3,6 +3,7 @@ #include "ZigGlobalObject.h" #include "helpers.h" #include "BunString.h" +#include #include #include #include @@ -10,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -128,8 +130,11 @@ static WTF::String formatLocation(const WTF::String& url, int lineNumber) // Extract path from file:// URL using WTF::URL WTF::String path = url; WTF::URL parsedUrl { url }; - if (parsedUrl.isValid() && parsedUrl.protocolIsFile()) - path = parsedUrl.fileSystemPath(); + if (parsedUrl.isValid() && parsedUrl.protocolIsFile()) { + // fileSystemPath() returns null if the percent-decoded path isn't valid UTF-8. + if (auto decoded = parsedUrl.fileSystemPath(); !decoded.isNull()) + path = WTF::move(decoded); + } if (lineNumber >= 0) { WTF::StringBuilder sb; @@ -945,3 +950,21 @@ extern "C" void Bun__stopCPUProfiler(JSC::VM* vm, BunString* outJSON, BunString* if (outText) *outText = Bun::toStringRef(textResult); } + +// Returns the sampling profiler's text report: top functions, then top bytecodes. +extern "C" void Bun__SamplingProfiler__report(JSC::VM* vm, BunString* out) +{ + JSC::SamplingProfiler* profiler = vm->samplingProfiler(); + if (!profiler) + return; + + WTF::StringPrintStream report; + { + JSC::JSLockHolder locker(*vm); + // A terminated Worker has a pending termination exception, and the report's DeferTermination asserts there isn't one. + JSC::SuspendExceptionScope suspendExceptions(*vm); + profiler->reportTopFunctions(report); + profiler->reportTopBytecodes(report); + } + *out = Bun::toStringRef(report.toString()); +} diff --git a/src/jsc/bindings/BunClientData.cpp b/src/jsc/bindings/BunClientData.cpp index 65f06cf4c8cf..15fc6f521a0c 100644 --- a/src/jsc/bindings/BunClientData.cpp +++ b/src/jsc/bindings/BunClientData.cpp @@ -41,7 +41,7 @@ JSHeapData::JSHeapData(Heap& heap) : m_heapCellTypeForJSWorkerGlobalScope(JSC::IsoHeapCellType::Args()) , m_heapCellTypeForNodeVMGlobalObject(JSC::IsoHeapCellType::Args()) , m_heapCellTypeForBakeGlobalObject(JSC::IsoHeapCellType::Args()) - , m_heapCellTypeForNapiHandleScopeImpl(JSC::IsoHeapCellType::Args()) + , m_heapCellTypeForHandleScopeImpl(JSC::IsoHeapCellType::Args()) , m_heapCellTypeForNativePromiseContext(JSC::IsoHeapCellType::Args()) , m_heapCellTypeForJSModuleGraph(JSC::IsoHeapCellType::Args()) , m_domConstructorSpace ISO_SUBSPACE_INIT(heap, heap.cellHeapCellType, JSDOMConstructorBase) diff --git a/src/jsc/bindings/BunClientData.h b/src/jsc/bindings/BunClientData.h index 0d37b509638b..40f3a7db8aa7 100644 --- a/src/jsc/bindings/BunClientData.h +++ b/src/jsc/bindings/BunClientData.h @@ -179,7 +179,7 @@ class JSHeapData { JSC::IsoHeapCellType m_heapCellTypeForJSWorkerGlobalScope; JSC::IsoHeapCellType m_heapCellTypeForNodeVMGlobalObject; - JSC::IsoHeapCellType m_heapCellTypeForNapiHandleScopeImpl; + JSC::IsoHeapCellType m_heapCellTypeForHandleScopeImpl; JSC::IsoHeapCellType m_heapCellTypeForBakeGlobalObject; JSC::IsoHeapCellType m_heapCellTypeForNativePromiseContext; JSC::IsoHeapCellType m_heapCellTypeForJSModuleGraph; diff --git a/src/jsc/bindings/BunObject.cpp b/src/jsc/bindings/BunObject.cpp index 48811d6c06dc..802bb9ce1b9d 100644 --- a/src/jsc/bindings/BunObject.cpp +++ b/src/jsc/bindings/BunObject.cpp @@ -117,7 +117,11 @@ static JSValue constructWebViewObject(VM& vm, JSObject* bunObject); static JSValue constructEnvObject(VM& vm, JSObject* object) { - return uncheckedDowncast(object->globalObject())->processEnvObject(); + // reifyAllStaticProperties calls PropertyCallback builders without checking for exceptions, so check here. + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + JSValue env = uncheckedDowncast(object->globalObject())->processEnvObject(); + RETURN_IF_EXCEPTION(scope, {}); + return env; } JSC::EncodedJSValue flattenArrayOfBuffersIntoArrayBufferOrUint8Array(JSGlobalObject* lexicalGlobalObject, JSValue arrayValue, size_t maxLength, bool asUint8Array) @@ -324,19 +328,17 @@ static JSValue constructPluginObject(VM& vm, JSObject* bunObject) return pluginFunction; } -static JSValue defaultBunSQLObject(VM& vm, JSObject* bunObject) +static JSValue createBunSql(VM& vm, Zig::GlobalObject* globalObject) { auto scope = DECLARE_THROW_SCOPE(vm); - auto* globalObject = defaultGlobalObject(bunObject->globalObject()); JSValue sqlValue = globalObject->internalModuleRegistry()->requireId(globalObject, vm, InternalModuleRegistry::BunSql); RETURN_IF_EXCEPTION(scope, {}); RELEASE_AND_RETURN(scope, sqlValue.getObject()->get(globalObject, vm.propertyNames->defaultKeyword)); } -static JSValue constructBunSQLObject(VM& vm, JSObject* bunObject) +static JSValue createBunSQLConstructor(VM& vm, Zig::GlobalObject* globalObject) { auto scope = DECLARE_THROW_SCOPE(vm); - auto* globalObject = defaultGlobalObject(bunObject->globalObject()); JSValue sqlValue = globalObject->internalModuleRegistry()->requireId(globalObject, vm, InternalModuleRegistry::BunSql); RETURN_IF_EXCEPTION(scope, {}); auto clientData = WebCore::clientData(vm); @@ -354,11 +356,10 @@ JSValue constructBunFetchObject(VM& vm, JSObject* bunObject) return fetchFn; } -static JSValue constructBunShell(VM& vm, JSObject* bunObject) +static JSValue createBunShell(VM& vm, Zig::GlobalObject* globalObject) { - auto* globalObject = uncheckedDowncast(bunObject->globalObject()); - JSFunction* createParsedShellScript = JSFunction::create(vm, bunObject->globalObject(), 2, "createParsedShellScript"_s, BunObject_callback_createParsedShellScript, ImplementationVisibility::Private, NoIntrinsic); - JSFunction* createShellInterpreterFunction = JSFunction::create(vm, bunObject->globalObject(), 1, "createShellInterpreter"_s, BunObject_callback_createShellInterpreter, ImplementationVisibility::Private, NoIntrinsic); + JSFunction* createParsedShellScript = JSFunction::create(vm, globalObject, 2, "createParsedShellScript"_s, BunObject_callback_createParsedShellScript, ImplementationVisibility::Private, NoIntrinsic); + JSFunction* createShellInterpreterFunction = JSFunction::create(vm, globalObject, 1, "createShellInterpreter"_s, BunObject_callback_createShellInterpreter, ImplementationVisibility::Private, NoIntrinsic); JSC::JSFunction* createShellFn = JSC::JSFunction::create(vm, globalObject, shellCreateBunShellTemplateFunctionCodeGenerator(vm), globalObject); auto scope = DECLARE_THROW_SCOPE(vm); @@ -389,6 +390,35 @@ static JSValue constructBunShell(VM& vm, JSObject* bunObject) return bunShell; } +// $, sql, postgres and SQL run builtin JS, which can throw, so they're CustomValue getters and the caller's scope checks them. +// The first read caches the value as a plain data property. +static EncodedJSValue lazyBunObjectValue(JSGlobalObject* lexicalGlobalObject, EncodedJSValue thisValue, PropertyName propertyName, JSValue (*create)(VM&, Zig::GlobalObject*)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + // A CustomValue getter's thisValue is the property's owner, not the receiver. + JSObject* bunObject = JSValue::decode(thisValue).getObject(); + JSValue value = create(vm, defaultGlobalObject(bunObject->globalObject())); + RETURN_IF_EXCEPTION(scope, {}); + bunObject->putDirect(vm, propertyName, value, PropertyAttribute::DontDelete | 0); + return JSValue::encode(value); +} + +// Assignment replaces the getter with a plain data property. +static bool replaceLazyBunObjectValue(JSGlobalObject* lexicalGlobalObject, EncodedJSValue thisValue, EncodedJSValue value, PropertyName propertyName) +{ + if (JSObject* object = JSValue::decode(thisValue).getObject()) + object->putDirect(JSC::getVM(lexicalGlobalObject), propertyName, JSValue::decode(value), PropertyAttribute::DontDelete | 0); + return true; +} + +static JSC_DEFINE_CUSTOM_GETTER(bunObjectShell, (JSGlobalObject * globalObject, EncodedJSValue thisValue, PropertyName propertyName)) { return lazyBunObjectValue(globalObject, thisValue, propertyName, createBunShell); } +static JSC_DEFINE_CUSTOM_GETTER(bunObjectSql, (JSGlobalObject * globalObject, EncodedJSValue thisValue, PropertyName propertyName)) { return lazyBunObjectValue(globalObject, thisValue, propertyName, createBunSql); } +static JSC_DEFINE_CUSTOM_GETTER(bunObjectSQL, (JSGlobalObject * globalObject, EncodedJSValue thisValue, PropertyName propertyName)) { return lazyBunObjectValue(globalObject, thisValue, propertyName, createBunSQLConstructor); } +static JSC_DEFINE_CUSTOM_SETTER(setBunObjectShell, (JSGlobalObject * globalObject, EncodedJSValue thisValue, EncodedJSValue value, PropertyName propertyName)) { return replaceLazyBunObjectValue(globalObject, thisValue, value, propertyName); } +static JSC_DEFINE_CUSTOM_SETTER(setBunObjectSql, (JSGlobalObject * globalObject, EncodedJSValue thisValue, EncodedJSValue value, PropertyName propertyName)) { return replaceLazyBunObjectValue(globalObject, thisValue, value, propertyName); } +static JSC_DEFINE_CUSTOM_SETTER(setBunObjectSQL, (JSGlobalObject * globalObject, EncodedJSValue thisValue, EncodedJSValue value, PropertyName propertyName)) { return replaceLazyBunObjectValue(globalObject, thisValue, value, propertyName); } + static JSValue constructDNSObject(VM& vm, JSObject* bunObject) { JSGlobalObject* globalObject = bunObject->globalObject(); @@ -840,6 +870,11 @@ JSC_DEFINE_HOST_FUNCTION(functionGenerateHeapSnapshot, (JSC::JSGlobalObject * gl builder.buildSnapshot(); auto json = builder.json(); RETURN_IF_EXCEPTION(throwScope, {}); + // json() returns a null string if the snapshot exceeds the max string length. + if (json.isNull()) [[unlikely]] { + throwOutOfMemoryError(globalObject, throwScope); + return {}; + } // Returning an object was a bad idea but it's a breaking change // so we'll just keep it for now. JSC::JSValue jsonValue = JSONParseWithException(globalObject, json); @@ -921,7 +956,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj /* Source for BunObject.lut.h @begin bunObjectTable - $ constructBunShell DontDelete|PropertyCallback + $ bunObjectShell DontDelete|CustomValue Archive BunObject_lazyPropCb_wrap_Archive DontDelete|PropertyCallback ArrayBufferSink BunObject_lazyPropCb_wrap_ArrayBufferSink DontDelete|PropertyCallback Cookie constructCookieObject DontDelete|ReadOnly|PropertyCallback @@ -1008,9 +1043,9 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj resolveSync BunObject_callback_resolveSync DontDelete|Function 1 revision constructBunRevision ReadOnly|DontDelete|PropertyCallback semver Bun::Bindgen::Generated::SemverObject::createSemver ReadOnly|DontDelete|PropertyCallback - sql defaultBunSQLObject DontDelete|PropertyCallback - postgres defaultBunSQLObject DontDelete|PropertyCallback - SQL constructBunSQLObject DontDelete|PropertyCallback + sql bunObjectSql DontDelete|CustomValue + postgres bunObjectSql DontDelete|CustomValue + SQL bunObjectSQL DontDelete|CustomValue serve BunObject_callback_serve DontDelete|Function 1 sha Bun::Bindgen::Generated::CryptoHasher::jsSha DontDelete|Function 1 shrink Bun::Bindgen::Generated::BunObject::jsShrink DontDelete|Function 0 diff --git a/src/jsc/bindings/BunPlugin.cpp b/src/jsc/bindings/BunPlugin.cpp index 9a207d8c455e..0e7fc69cb2d3 100644 --- a/src/jsc/bindings/BunPlugin.cpp +++ b/src/jsc/bindings/BunPlugin.cpp @@ -22,7 +22,6 @@ #include #include #include -#include #include #include #include @@ -36,13 +35,14 @@ namespace Zig { +static bool isValidNamespaceCharacter(char16_t c) +{ + return isASCIIAlphanumeric(c) || c == '_' || c == '-' || c == '/' || c == '@'; +} + static bool isValidNamespaceString(String& namespaceString) { - static JSC::Yarr::RegularExpression* namespaceRegex = nullptr; - if (!namespaceRegex) { - namespaceRegex = new JSC::Yarr::RegularExpression("^([/@a-zA-Z0-9_\\-]+)$"_s); - } - return namespaceRegex->match(namespaceString) > -1; + return !namespaceString.isEmpty() && namespaceString.containsOnly(); } static JSC::EncodedJSValue jsFunctionAppendOnLoadPluginBody(JSC::JSGlobalObject* globalObject, JSC::CallFrame* callframe, BunPluginTarget target, BunPlugin::Base& plugin) @@ -93,7 +93,7 @@ static JSC::EncodedJSValue jsFunctionAppendOnLoadPluginBody(JSC::JSGlobalObject* plugin.append(vm, filter->regExp(), func.getObject(), namespaceString); - return JSValue::encode(callframe->thisValue()); + return JSValue::encode(callframe->thisValue().toThis(globalObject, JSC::ECMAMode::strict())); } static EncodedJSValue jsFunctionAppendVirtualModulePluginBody(JSC::JSGlobalObject* globalObject, JSC::CallFrame* callframe) @@ -157,7 +157,7 @@ static EncodedJSValue jsFunctionAppendVirtualModulePluginBody(JSC::JSGlobalObjec global->moduleLoader()->removeEntry(idIdent); // takes the loader's cellLock itself } - return JSValue::encode(callframe->thisValue()); + return JSValue::encode(callframe->thisValue().toThis(globalObject, JSC::ECMAMode::strict())); } static JSC::EncodedJSValue jsFunctionAppendOnResolvePluginBody(JSC::JSGlobalObject* globalObject, JSC::CallFrame* callframe, BunPluginTarget target, BunPlugin::Base& plugin) @@ -208,7 +208,7 @@ static JSC::EncodedJSValue jsFunctionAppendOnResolvePluginBody(JSC::JSGlobalObje plugin.append(vm, filter->regExp(), uncheckedDowncast(func), namespaceString); - return JSValue::encode(callframe->thisValue()); + return JSValue::encode(callframe->thisValue().toThis(globalObject, JSC::ECMAMode::strict())); } static JSC::EncodedJSValue jsFunctionAppendOnResolvePluginGlobal(JSC::JSGlobalObject* globalObject, JSC::CallFrame* callframe, BunPluginTarget target) @@ -382,6 +382,15 @@ JSC::JSObject* BunPlugin::Group::find(JSC::JSGlobalObject* globalObject, String& return nullptr; } +// fileSystemPath() returns null if a percent escape in the specifier doesn't decode to UTF-8. Retry with every "%" escaped. +static String relativeSpecifierToFileSystemPath(const URL& base, const String& specifier) +{ + String path = URL(base, specifier).fileSystemPath(); + if (path.isNull()) [[unlikely]] + path = URL(base, makeStringByReplacingAll(specifier, "%"_s, "%25"_s)).fileSystemPath(); + return path; +} + void BunPlugin::OnLoad::addModuleMock(JSC::VM& vm, const String& path, JSC::JSObject* mockObject) { Zig::GlobalObject* globalObject = defaultGlobalObject(mockObject->globalObject()); @@ -640,9 +649,10 @@ extern "C" JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(JSMock__jsModuleMock, __attr const URL& url = sourceOrigin.url(); if (specifier.startsWith("file:"_s)) { - URL fileURL = URL(url, specifier); - if (fileURL.isValid()) { - specifier = fileURL.fileSystemPath(); + // fileSystemPath() returns null if the percent-decoded path isn't valid UTF-8. + String path = URL(url, specifier).fileSystemPath(); + if (!path.isNull()) { + specifier = WTF::move(path); specifierString = jsString(vm, specifier); globalObject->onLoadPlugins.mustDoExpensiveRelativeLookup = true; return; @@ -674,7 +684,7 @@ extern "C" JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(JSMock__jsModuleMock, __attr globalObject->onLoadPlugins.mustDoExpensiveRelativeLookup = true; if (relativeURL.protocolIsFile()) - specifier = relativeURL.fileSystemPath(); + specifier = relativeSpecifierToFileSystemPath(url, specifier); else specifier = relativeURL.string(); @@ -841,9 +851,12 @@ EncodedJSValue BunPlugin::OnLoad::run(JSC::JSGlobalObject* globalObject, const B JSC::JSObject* paramsObject = JSC::constructEmptyObject(globalObject, globalObject->objectPrototype(), 1); const auto& builtinNames = WebCore::builtinNames(vm); + // `pathString` can borrow the caller's bytes, and `args.path` outlives this call, so give it a copy. + auto* pathJS = Bun::toJS(globalObject, *path); + RETURN_IF_EXCEPTION(scope, {}); paramsObject->putDirect( vm, builtinNames.pathPublicName(), - jsString(vm, pathString)); + pathJS); arguments.append(paramsObject); auto result = AsyncContextFrame::call(globalObject, function, JSC::jsUndefined(), arguments); @@ -874,13 +887,16 @@ std::optional BunPlugin::OnLoad::resolveVirtualModule(const String& path { ASSERT(virtualModules); + if (path.isNull()) [[unlikely]] + return std::nullopt; + if (this->mustDoExpensiveRelativeLookup) { String joinedPath = path; if (path.startsWith("./"_s) || path.startsWith(".."_s)) { auto url = WTF::URL::fileURLWithFileSystemPath(from); ASSERT(url.isValid()); - joinedPath = URL(url, path).fileSystemPath(); + joinedPath = relativeSpecifierToFileSystemPath(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 2f15cbbdd5f1..1d0155876744 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -2,6 +2,7 @@ #include "napi.h" #include "BunProcess.h" +#include "ObjectBindings.h" #include "DLHandleMap.h" #include "WebCoreJSBuiltins.h" #include "v8/node.h" @@ -72,6 +73,7 @@ #ifndef WIN32 #include #include +#include #include #include #include @@ -87,6 +89,7 @@ #include #include #include +#include "NodeExeImportsWindows.h" // Using the same typedef and define for `mode_t` and `umask` as node on windows. // https://github.com/nodejs/node/blob/ad5e2dab4c8306183685973387829c2f69e793da/src/node_process_methods.cc#L29 #define umask _umask @@ -435,14 +438,25 @@ static char* toFileURI(std::span span) extern "C" size_t Bun__process_dlopen_count; -extern "C" void CrashHandler__setDlOpenAction(const char* action); +extern "C" void* CrashHandler__beginDlOpenAction(const char* path); +extern "C" void CrashHandler__endDlOpenAction(void* guard); extern "C" bool Bun__VM__allowAddons(void* vm); extern "C" int32_t Bun__addonNeedsGlibcOnMusl(const char* path, size_t len, char* soname_out, size_t soname_cap); +// Drops only this load's reference. A library that registered modules on an earlier load is still referenced by that load. +static void closeDLHandle(Bun::DLHandleMap::DLHandle handle) +{ +#if OS(WINDOWS) + Bun::NodeExeImports::forget(handle); + FreeLibrary(handle); +#else + dlclose(handle); +#endif +} + JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Process_functionDlopen, __attribute__((minsize)), (JSC::JSGlobalObject * globalObject_, JSC::CallFrame* callFrame)) { Zig::GlobalObject* globalObject = static_cast(globalObject_); - auto callCountAtStart = globalObject->napiModuleRegisterCallCount; auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject)); auto& vm = JSC::getVM(globalObject); @@ -471,9 +485,6 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Process_functionDlopen, __attribute__(( return {}; } - globalObject->m_pendingNapiModuleAndExports[0].set(vm, globalObject, moduleObject); - globalObject->m_pendingNapiModuleAndExports[1].set(vm, globalObject, exports); - Strong strongExports; if (exports.isCell()) { @@ -533,6 +544,13 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Process_functionDlopen, __attribute__(( } } +#if !OS(WINDOWS) + // glibc's dlopen() alloca()s a copy of a name without a '/'. A path this long can't be opened anyway. + if (filename.length() >= PATH_MAX) [[unlikely]] { + return throwError(globalObject, scope, ErrorCode::ERR_DLOPEN_FAILED, "dlopen failed: File name too long"_s); + } +#endif + { auto utf8_filename = filename.tryGetUTF8(ConversionMode::LenientConversion); if (!utf8_filename) [[unlikely]] { @@ -542,10 +560,21 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Process_functionDlopen, __attribute__(( utf8 = *utf8_filename; } + // An init function can call process.dlopen() recursively, so save the outer call's pending registrations and restore them on return. + auto outerNapiModules = std::exchange(globalObject->m_pendingNapiModules, {}); + auto outerV8Modules = std::exchange(globalObject->m_pendingV8Modules, {}); + auto outerRegisterCallCount = std::exchange(globalObject->napiModuleRegisterCallCount, 0); + auto restorePendingRegistrations = WTF::makeScopeExit([&] { + globalObject->m_pendingNapiModules = WTF::move(outerNapiModules); + globalObject->m_pendingV8Modules = WTF::move(outerV8Modules); + globalObject->napiModuleRegisterCallCount = outerRegisterCallCount; + }); + Bun__process_dlopen_count++; #if OS(WINDOWS) BunString filename_str = Bun::toString(filename); + Bun::NodeExeImports::Scope nodeExeImports; HMODULE handle = Bun__LoadLibraryBunString(&filename_str); #else #if OS(LINUX) @@ -566,13 +595,11 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Process_functionDlopen, __attribute__(( } } #endif - CrashHandler__setDlOpenAction(utf8.legacyCStringPointer()); + // Keep the addon's name in crash reports until this call returns, because its init functions run after dlopen(). + auto endDlOpenAction = WTF::makeScopeExit([guard = CrashHandler__beginDlOpenAction(utf8.legacyCStringPointer())] { CrashHandler__endDlOpenAction(guard); }); void* handle = dlopen(utf8.legacyCStringPointer(), RTLD_LAZY); - CrashHandler__setDlOpenAction(nullptr); #endif - globalObject->m_pendingNapiModuleDlopenHandle = handle; - if (!handle) { #if OS(WINDOWS) DWORD errorId = GetLastError(); @@ -599,6 +626,8 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Process_functionDlopen, __attribute__(( errorBuilder.append("error code "_s); errorBuilder.append(WTF::String::number(errorId)); } + if (WTF::String unresolved = nodeExeImports.unresolvedImportError(nullptr); !unresolved.isNull()) + errorBuilder.append(" "_s, unresolved, "."_s); WTF::String msg = errorBuilder.toString(); if (messageBuffer) @@ -609,7 +638,14 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Process_functionDlopen, __attribute__(( return throwError(globalObject, scope, ErrorCode::ERR_DLOPEN_FAILED, msg); } - if (callCountAtStart != globalObject->napiModuleRegisterCallCount) { +#if OS(WINDOWS) + if (WTF::String unresolved = nodeExeImports.unresolvedImportError(handle); !unresolved.isNull()) { + closeDLHandle(handle); + return throwError(globalObject, scope, ErrorCode::ERR_DLOPEN_FAILED, makeString("Cannot load native addon "_s, filename, ": "_s, unresolved)); + } +#endif + + if (globalObject->napiModuleRegisterCallCount != 0) { // Module self-registered via static constructor(s). // Move pending registrations into locals before iterating: an // nm_register_func can itself call napi_module_register(), which @@ -618,14 +654,6 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Process_functionDlopen, __attribute__(( // iterator dangling. auto pendingNapiModules = std::exchange(globalObject->m_pendingNapiModules, {}); auto pendingV8Modules = std::exchange(globalObject->m_pendingV8Modules, {}); - // Whatever happens below, no registration state may leak into the next dlopen(). - auto resetPendingRegistrations = WTF::makeScopeExit([&] { - globalObject->m_pendingV8Modules.clear(); - globalObject->m_pendingNapiModules.clear(); - globalObject->napiModuleRegisterCallCount = 0; - globalObject->m_pendingNapiModuleAndExports[0].clear(); - globalObject->m_pendingNapiModuleAndExports[1].clear(); - }); if (handle) { // Save all NAPI module registrations @@ -636,25 +664,22 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Process_functionDlopen, __attribute__(( // Save all V8 C++ module registrations for (auto* mod : pendingV8Modules) { - Bun::DLHandleMap::singleton().add(handle, mod); + Bun::DLHandleMap::singleton().add(handle, mod, vm); } } - // A V8-style module's nm_register_func already ran inside dlopen() (node_module_register) - // and may have thrown. - RETURN_IF_EXCEPTION(scope, {}); + // Run V8 init functions here instead of in node_module_register(). See https://github.com/oven-sh/bun/issues/20454 + for (auto* mod : pendingV8Modules) { + node::executePendingV8Module(globalObject, mod, strongModule.get()); + RETURN_IF_EXCEPTION(scope, {}); + } // Execute all NAPI modules. If an nm_register_func registers more // modules re-entrantly, they accumulate back in m_pendingNapiModules; // drain those too once the current batch is done. for (;;) { for (auto& mod : pendingNapiModules) { - // Restore dlopen handle for this module before execution - // executePendingNapiModule clears it, so we must set it for each module - globalObject->m_pendingNapiModuleDlopenHandle = handle; - globalObject->m_pendingNapiModule = mod; - Napi::executePendingNapiModule(globalObject); - globalObject->m_pendingNapiModule = {}; + Napi::executePendingNapiModule(globalObject, mod, handle, strongModule.get()); RETURN_IF_EXCEPTION(scope, {}); } if (globalObject->m_pendingNapiModules.isEmpty()) @@ -662,33 +687,22 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Process_functionDlopen, __attribute__(( pendingNapiModules = std::exchange(globalObject->m_pendingNapiModules, {}); } - JSValue resultValue = globalObject->m_pendingNapiModuleAndExports[0].get(); - if (resultValue && resultValue != strongModule.get()) { - if (resultValue.isCell() && resultValue.getObject()->isErrorInstance()) { - JSC::throwException(globalObject, scope, resultValue); - return {}; - } - } - return JSValue::encode(jsUndefined()); } // Module didn't self-register on this load. Check if we have cached registrations. if (auto cachedModules = Bun::DLHandleMap::singleton().get(handle)) { - // (The V8 registrations are already in DLHandleMap; nothing here re-saves them.) - auto resetPendingRegistrations = WTF::makeScopeExit([&] { - globalObject->m_pendingV8Modules.clear(); - globalObject->m_pendingNapiModules.clear(); - globalObject->napiModuleRegisterCallCount = 0; - globalObject->m_pendingNapiModuleAndExports[0].clear(); - globalObject->m_pendingNapiModuleAndExports[1].clear(); - }); + if (!Bun::DLHandleMap::singleton().claimForVM(handle, vm)) { + closeDLHandle(handle); + return throwError(globalObject, scope, ErrorCode::ERR_DLOPEN_FAILED, + makeString("Module did not self-register: '"_s, filename, "'."_s)); + } // Replay all registrations from this handle. napi ones only queue into // m_pendingNapiModules; a V8 one runs its nm_register_func right here. for (auto& registration : *cachedModules) { - if (auto* const* nodeModule = std::get_if(®istration)) { - node::node_module_register(*nodeModule); + if (auto* v8Module = std::get_if(®istration)) { + node::executePendingV8Module(globalObject, v8Module->module, strongModule.get()); RETURN_IF_EXCEPTION(scope, {}); } else { napi_module_register(std::get(registration)); @@ -701,24 +715,11 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Process_functionDlopen, __attribute__(( while (!globalObject->m_pendingNapiModules.isEmpty()) { auto pendingNapiModules = std::exchange(globalObject->m_pendingNapiModules, {}); for (auto& mod : pendingNapiModules) { - // Restore dlopen handle for this module before execution - // executePendingNapiModule clears it, so we must set it for each module - globalObject->m_pendingNapiModuleDlopenHandle = handle; - globalObject->m_pendingNapiModule = mod; - Napi::executePendingNapiModule(globalObject); - globalObject->m_pendingNapiModule = {}; + Napi::executePendingNapiModule(globalObject, mod, handle, strongModule.get()); RETURN_IF_EXCEPTION(scope, {}); } } - JSValue resultValue = globalObject->m_pendingNapiModuleAndExports[0].get(); - if (resultValue && resultValue != strongModule.get()) { - if (resultValue.isCell() && resultValue.getObject()->isErrorInstance()) { - JSC::throwException(globalObject, scope, resultValue); - return {}; - } - } - return JSValue::encode(jsUndefined()); } @@ -737,11 +738,7 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Process_functionDlopen, __attribute__(( #endif if (!napi_register_module_v1) { -#if OS(WINDOWS) - FreeLibrary(handle); -#else - dlclose(handle); -#endif + closeDLHandle(handle); if (!scope.exception()) [[likely]] { JSC::throwTypeError(globalObject, scope, "symbol 'napi_register_module_v1' not found in native module. Is this a Node API (napi) module?"_s); @@ -792,7 +789,7 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Process_functionDlopen, __attribute__(( if (pointer_to_plugin_name) { // TODO: think about the finalizer here // currently we do not dealloc napi modules so we don't have to worry about it right now - auto* meta = new Bun::NapiModuleMeta(globalObject->m_pendingNapiModuleDlopenHandle); + auto* meta = new Bun::NapiModuleMeta(handle); Bun::NapiExternal* napi_external = Bun::NapiExternal::create(vm, globalObject->NapiExternalStructure(), meta, nullptr, nullptr, env.ptr()); bool success = resultObject->putDirect(vm, WebCore::builtinNames(vm).napiDlopenHandlePrivateName(), napi_external, JSC::PropertyAttribute::DontDelete | JSC::PropertyAttribute::ReadOnly); ASSERT(success); @@ -800,10 +797,6 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Process_functionDlopen, __attribute__(( } } - globalObject->m_pendingNapiModuleAndExports[0].clear(); - globalObject->m_pendingNapiModuleAndExports[1].clear(); - globalObject->m_pendingNapiModuleDlopenHandle = nullptr; - // https://github.com/nodejs/node/blob/2eff28fb7a93d3f672f80b582f664a7c701569fb/src/node_api.cc#L734-L742 // https://github.com/oven-sh/bun/issues/1288 if (!resultValue.isEmpty() && (!strongExports || resultValue != strongExports.get())) { @@ -1036,7 +1029,8 @@ JSC_DEFINE_HOST_FUNCTION(Process_functionChdir, (JSC::JSGlobalObject * globalObj RELEASE_AND_RETURN(scope, JSC::JSValue::encode(result)); } -static HashMap* signalNumberToNameMap = nullptr; +// One number can have two names, e.g. SIGABRT and SIGIOT. +static HashMap>* signalNumberToNamesMap = nullptr; static HashMap* signalNameToNumberMap = nullptr; // On windows, signals need to have a handle to the uv_signal_t. When sigaction is used, this is kept track globally for you. @@ -1188,88 +1182,91 @@ static void loadSignalNumberMap() }); } -static void loadSignalNumberToNameMap() +static void loadSignalNumberToNamesMap() { - static std::once_flag signalNumberToNameMapOnceFlag; - std::call_once(signalNumberToNameMapOnceFlag, [] { + static std::once_flag signalNumberToNamesMapOnceFlag; + std::call_once(signalNumberToNamesMapOnceFlag, [] { auto signalNames = getSignalNames(); - signalNumberToNameMap = new HashMap(); - signalNumberToNameMap->reserveInitialCapacity(31); - signalNumberToNameMap->add(SIGHUP, signalNames[0]); - signalNumberToNameMap->add(SIGINT, signalNames[1]); - signalNumberToNameMap->add(SIGQUIT, signalNames[2]); - signalNumberToNameMap->add(SIGILL, signalNames[3]); + signalNumberToNamesMap = new HashMap>(); + signalNumberToNamesMap->reserveInitialCapacity(31); + auto add = [](int signalNumber, const String& signalName) { + signalNumberToNamesMap->add(signalNumber, Vector()).iterator->value.append(signalName); + }; + add(SIGHUP, signalNames[0]); + add(SIGINT, signalNames[1]); + add(SIGQUIT, signalNames[2]); + add(SIGILL, signalNames[3]); #ifdef SIGTRAP - signalNumberToNameMap->add(SIGTRAP, signalNames[4]); + add(SIGTRAP, signalNames[4]); #endif - signalNumberToNameMap->add(SIGABRT, signalNames[5]); + add(SIGABRT, signalNames[5]); #ifdef SIGIOT - signalNumberToNameMap->add(SIGIOT, signalNames[6]); + add(SIGIOT, signalNames[6]); #endif #ifdef SIGBUS - signalNumberToNameMap->add(SIGBUS, signalNames[7]); + add(SIGBUS, signalNames[7]); #endif - signalNumberToNameMap->add(SIGFPE, signalNames[8]); - signalNumberToNameMap->add(SIGKILL, signalNames[9]); + add(SIGFPE, signalNames[8]); + add(SIGKILL, signalNames[9]); #ifdef SIGUSR1 - signalNumberToNameMap->add(SIGUSR1, signalNames[10]); + add(SIGUSR1, signalNames[10]); #endif - signalNumberToNameMap->add(SIGSEGV, signalNames[11]); + add(SIGSEGV, signalNames[11]); #ifdef SIGUSR2 - signalNumberToNameMap->add(SIGUSR2, signalNames[12]); + add(SIGUSR2, signalNames[12]); #endif #ifdef SIGPIPE - signalNumberToNameMap->add(SIGPIPE, signalNames[13]); + add(SIGPIPE, signalNames[13]); #endif #ifdef SIGALRM - signalNumberToNameMap->add(SIGALRM, signalNames[14]); + add(SIGALRM, signalNames[14]); #endif - signalNumberToNameMap->add(SIGTERM, signalNames[15]); + add(SIGTERM, signalNames[15]); #ifdef SIGCHLD - signalNumberToNameMap->add(SIGCHLD, signalNames[16]); + add(SIGCHLD, signalNames[16]); #endif #ifdef SIGCONT - signalNumberToNameMap->add(SIGCONT, signalNames[17]); + add(SIGCONT, signalNames[17]); #endif #ifdef SIGSTOP - signalNumberToNameMap->add(SIGSTOP, signalNames[18]); + add(SIGSTOP, signalNames[18]); #endif #ifdef SIGTSTP - signalNumberToNameMap->add(SIGTSTP, signalNames[19]); + add(SIGTSTP, signalNames[19]); #endif #ifdef SIGTTIN - signalNumberToNameMap->add(SIGTTIN, signalNames[20]); + add(SIGTTIN, signalNames[20]); #endif #ifdef SIGTTOU - signalNumberToNameMap->add(SIGTTOU, signalNames[21]); + add(SIGTTOU, signalNames[21]); #endif #ifdef SIGURG - signalNumberToNameMap->add(SIGURG, signalNames[22]); + add(SIGURG, signalNames[22]); #endif #ifdef SIGXCPU - signalNumberToNameMap->add(SIGXCPU, signalNames[23]); + add(SIGXCPU, signalNames[23]); #endif #ifdef SIGXFSZ - signalNumberToNameMap->add(SIGXFSZ, signalNames[24]); + add(SIGXFSZ, signalNames[24]); #endif #ifdef SIGVTALRM - signalNumberToNameMap->add(SIGVTALRM, signalNames[25]); + add(SIGVTALRM, signalNames[25]); #endif #ifdef SIGPROF - signalNumberToNameMap->add(SIGPROF, signalNames[26]); + add(SIGPROF, signalNames[26]); #endif - signalNumberToNameMap->add(SIGWINCH, signalNames[27]); + add(SIGWINCH, signalNames[27]); #ifdef SIGIO - signalNumberToNameMap->add(SIGIO, signalNames[28]); + add(SIGIO, signalNames[28]); #endif #ifdef SIGINFO - signalNumberToNameMap->add(SIGINFO, signalNames[29]); + add(SIGINFO, signalNames[29]); #endif #ifdef SIGSYS - signalNumberToNameMap->add(SIGSYS, signalNames[30]); + add(SIGSYS, signalNames[30]); #endif #ifdef SIGBREAK - signalNumberToNameMap->add(SIGBREAK, signalNames[31]); + add(SIGBREAK, signalNames[31]); #endif }); } @@ -1277,19 +1274,29 @@ static void loadSignalNumberToNameMap() extern "C" bool Bun__onSignalForJS(int signalNumber, Zig::GlobalObject* globalObject) { Process* process = globalObject->processObject(); + auto& vm = JSC::getVM(globalObject); - loadSignalNumberToNameMap(); - auto entry = signalNumberToNameMap->find(signalNumber); - // Identifier::fromString dereferences the null String of a missing key. - if (entry == signalNumberToNameMap->end()) [[unlikely]] + loadSignalNumberToNamesMap(); + auto entry = signalNumberToNamesMap->find(signalNumber); + if (entry == signalNumberToNamesMap->end()) [[unlikely]] return false; - const String& signalName = entry->value; - Identifier signalNameIdentifier = Identifier::fromString(JSC::getVM(globalObject), signalName); - MarkedArgumentBuffer args; - args.append(jsString(JSC::getVM(globalObject), signalNameIdentifier.string())); - args.append(jsNumber(signalNumber)); - return process->wrapped().emitForBindings(signalNameIdentifier, args); + // Node has one watcher per name, so emit the event under every name that has a listener. + Vector eventNames; + for (const String& signalName : entry->value) { + Identifier eventName = Identifier::fromString(vm, signalName); + if (process->wrapped().hasEventListeners(eventName)) + eventNames.append(eventName); + } + + bool fired = false; + for (const Identifier& eventName : eventNames) { + MarkedArgumentBuffer args; + args.append(jsString(vm, eventName.string())); + args.append(jsNumber(signalNumber)); + fired |= process->wrapped().emitForBindings(eventName, args); + } + return fired; } #if OS(WINDOWS) @@ -1304,7 +1311,7 @@ void signalHandler(uv_signal_t* signal, int signalNumber) #endif { #if OS(WINDOWS) - if (signalNumberToNameMap->find(signalNumber) == signalNumberToNameMap->end()) [[unlikely]] + if (!signalNumberToNamesMap->contains(signalNumber)) [[unlikely]] return; auto* context = ScriptExecutionContext::getMainThreadScriptExecutionContext(); @@ -1324,9 +1331,8 @@ extern "C" void Bun__logUnhandledException(JSC::EncodedJSValue exception); extern "C" int Bun__handleUncaughtException(JSC::JSGlobalObject* lexicalGlobalObject, JSC::JSValue exception, int isRejection) { - if (!lexicalGlobalObject->inherits(Zig::GlobalObject::info())) - return false; - auto* globalObject = uncheckedDowncast(lexicalGlobalObject); + // The exception can come from a node:vm context, which doesn't have its own process object. + auto* globalObject = defaultGlobalObject(lexicalGlobalObject); auto* process = globalObject->processObject(); auto& wrapped = process->wrapped(); auto& vm = JSC::getVM(globalObject); @@ -1374,14 +1380,14 @@ extern "C" int Bun__handleUncaughtException(JSC::JSGlobalObject* lexicalGlobalOb auto capture = process->getUncaughtExceptionCaptureCallback(); if (!capture.isEmpty() && !capture.isUndefinedOrNull()) { auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); - (void)call(lexicalGlobalObject, capture, args, "uncaughtExceptionCaptureCallback"_s); + (void)call(globalObject, capture, args, "uncaughtExceptionCaptureCallback"_s); if (auto ex = scope.exception()) { (void)scope.tryClearException(); if (vm.hasPendingTerminationException()) [[unlikely]] return true; // if an exception is thrown in the uncaughtException handler, we abort Bun__logUnhandledException(JSValue::encode(JSValue(ex))); - Bun__Process__exit(lexicalGlobalObject, 1); + Bun__Process__exit(globalObject, 1); } } else if (wrapped.listenerCount(uncaughtExceptionIdent) > 0) { auto emitScope = DECLARE_TOP_EXCEPTION_SCOPE(vm); @@ -1559,8 +1565,17 @@ __attribute__((noinline)) static void forwardSignal(int signalNumber) static int watchModeStickySignal = 0; #if !OS(WINDOWS) +#if OS(LINUX) +// Tells the seccomp SIGSYS handler in c-bindings.cpp whether to forward a SIGSYS sent with kill(2). +extern "C" bool Bun__forwardSIGSYSToJS(bool enabled); +#endif + static void installForwardSignalHandler(int signalNumber) { +#if OS(LINUX) + if (signalNumber == SIGSYS && Bun__forwardSIGSYSToJS(true)) + return; +#endif struct sigaction action; memset(&action, 0, sizeof(struct sigaction)); action.sa_handler = forwardSignal; @@ -1570,6 +1585,18 @@ static void installForwardSignalHandler(int signalNumber) sigaction(signalNumber, &action, nullptr); } +static void uninstallForwardSignalHandler(int signalNumber) +{ +#if OS(LINUX) + if (signalNumber == SIGSYS && Bun__forwardSIGSYSToJS(false)) + return; +#endif + if (void (*oldHandler)(int) = signal(signalNumber, SIG_DFL); oldHandler != forwardSignal) { + // Don't uninstall the old handler if it's not the one we installed. + signal(signalNumber, oldHandler); + } +} + extern "C" void Bun__installWatchModeSignalHandler(int signalNumber) { Bun__ensureSignalHandler(); @@ -1581,6 +1608,21 @@ extern "C" void Bun__installWatchModeSignalHandler(int signalNumber) extern "C" void Bun__MemoryPressure__install(JSC::JSGlobalObject* global); extern "C" void Bun__MemoryPressure__uninstall(JSC::JSGlobalObject* global); +// The OS handler is per signal number, so count the listeners under every name for that number. +static int listenerCountForSignal(EventEmitter& eventEmitter, const Identifier& eventName, int signalNumber) +{ + int listenerCount = eventEmitter.listenerCount(eventName); + // On platforms without SIGINFO, "SIGINFO" maps to 255, which isn't in the map. + auto names = signalNumberToNamesMap->find(signalNumber); + if (names == signalNumberToNamesMap->end()) + return listenerCount; + for (const String& signalName : names->value) { + if (signalName != eventName.string()) + listenerCount += eventEmitter.listenerCount(Identifier::fromString(eventEmitter.scriptExecutionContext()->vm(), signalName)); + } + return listenerCount; +} + static void onDidChangeListeners(EventEmitter& eventEmitter, const Identifier& eventName, bool isAdded) { if (Bun__isMainThreadVM()) { @@ -1623,14 +1665,14 @@ static void onDidChangeListeners(EventEmitter& eventEmitter, const Identifier& e // Signal Handlers loadSignalNumberMap(); - loadSignalNumberToNameMap(); + loadSignalNumberToNamesMap(); if (!signalToContextIdsMap) { signalToContextIdsMap = new HashMap(); } if (auto signalNumber = signalNameToNumberMap->get(eventName.string())) { - int listenerCount = eventEmitter.listenerCount(eventName); + int listenerCount = listenerCountForSignal(eventEmitter, eventName, signalNumber); // Mirror the count for the watcher thread's --watch-kill-signal check. Bun__onSignalListenerCountChanged(signalNumber, listenerCount); #if OS(LINUX) @@ -1676,10 +1718,7 @@ static void onDidChangeListeners(EventEmitter& eventEmitter, const Identifier& e // "has JS listeners" source of truth that e.g. self-kill flush consults. if (signalNumber != watchModeStickySignal) { #if !OS(WINDOWS) - if (void (*oldHandler)(int) = signal(signalNumber, SIG_DFL); oldHandler != forwardSignal) { - // Don't uninstall the old handler if it's not the one we installed. - signal(signalNumber, oldHandler); - } + uninstallForwardSignalHandler(signalNumber); #else SignalHandleValue signal_handle = signalToContextIdsMap->get(signalNumber); Bun__UVSignalHandle__close(signal_handle.handle); @@ -1900,6 +1939,16 @@ struct ExecveCloexecRestorer { fcntl(entry.first, F_SETFD, entry.second); } }; + +// String::utf8() asserts on failure, and converting a Latin-1 string of 2^30 or more characters fails. +static bool appendUTF8(Vector& storage, const WTF::String& string) +{ + auto utf8 = string.tryGetUTF8(); + if (!utf8) [[unlikely]] + return false; + storage.append(WTF::move(*utf8)); + return true; +} #endif JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Process_functionExecve, __attribute__((minsize)), (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) @@ -1977,7 +2026,10 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Process_functionExecve, __attribute__(( return Bun::ERR::INVALID_ARG_VALUE(scope, globalObject, makeString("args["_s, i, "]"_s), item, "must be a string without null bytes"_s); } - argvStorage.append(str.utf8()); + if (!appendUTF8(argvStorage, str)) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return {}; + } } // Node declares env = process.env as the default, so an omitted env @@ -2020,11 +2072,20 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Process_functionExecve, __attribute__(( return Bun::ERR::INVALID_ARG_VALUE(scope, globalObject, "env"_s, envValue, "must be an object with string keys and values without null bytes"_s); } - envStorage.append(makeString(keyStr, '=', valueStr).utf8()); + WTF::String entry = tryMakeString(keyStr, '=', valueStr); + if (entry.isNull() || !appendUTF8(envStorage, entry)) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return {}; + } } } - UTF8CString execPathUtf8 = execPath.utf8(); + auto execPathConversion = execPath.tryGetUTF8(); + if (!execPathConversion) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return {}; + } + UTF8CString execPathUtf8 = WTF::move(*execPathConversion); // Build the null-terminated argv/envp pointer arrays only after the // backing storage is fully populated so there is no risk of pointers @@ -3151,9 +3212,8 @@ JSC_DEFINE_CUSTOM_SETTER(setProcessPpid, (JSC::JSGlobalObject * globalObject, JS if (!thisObject) { return false; } - auto& vm = JSC::getVM(globalObject); - thisObject->putDirect(vm, propertyName, JSValue::decode(encodedValue), 0); - return true; + auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject)); + RELEASE_AND_RETURN(scope, Bun::defineOwnDataProperty(globalObject, thisObject, propertyName, JSValue::decode(encodedValue), 0)); } static JSValue constructArgv0(VM& vm, JSObject* processObject) @@ -3308,16 +3368,10 @@ static JSValue constructRevision(VM& vm, JSObject* processObject) static JSValue constructEnv(VM& vm, JSObject* processObject) { auto* globalObject = uncheckedDowncast(processObject->globalObject()); - // Lazy property builder: exceptions must not propagate into - // reifyStaticProperty, which performs no exception check. + // Returning empty with an exception pending skips reification, so this read throws and the next read retries. auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); - JSValue env = globalObject->processEnvObject(); - if (auto* exception = scope.exception()) [[unlikely]] { - (void)scope.tryClearException(); - Zig::GlobalObject::reportUncaughtExceptionAtEventLoop(globalObject, exception); - RETURN_IF_EXCEPTION(scope, JSC::jsUndefined()); - return JSC::jsUndefined(); - } + JSObject* env = globalObject->processEnvObject(); + RETURN_IF_EXCEPTION(scope, {}); return env; } @@ -3363,6 +3417,13 @@ JSC_DEFINE_HOST_FUNCTION(Process_functiongetgroups, (JSGlobalObject * globalObje return JSValue::encode(groups); } +static void throwUnknownCredential(JSC::ThrowScope& throwScope, JSGlobalObject* globalObject, ASCIILiteral prefix, const String& name) +{ + MessageBuilder message; + message.append(prefix, name); + throwScope.throwException(globalObject, createError(globalObject, ErrorCode::ERR_UNKNOWN_CREDENTIAL, message)); +} + static JSValue maybe_uid_by_name(JSC::ThrowScope& throwScope, JSGlobalObject* globalObject, JSValue value) { if (!value.isNumber() && !value.isString()) return JSValue::decode(Bun::ERR::INVALID_ARG_TYPE(throwScope, globalObject, "id"_s, "number or string"_s, value)); @@ -3370,18 +3431,19 @@ static JSValue maybe_uid_by_name(JSC::ThrowScope& throwScope, JSGlobalObject* gl auto str = value.getString(globalObject); RETURN_IF_EXCEPTION(throwScope, {}); - auto utf8 = str.utf8(); - auto name = utf8.legacyCStringPointer(); struct passwd pwd; struct passwd* pp = nullptr; char buf[8192]; - if (getpwnam_r(name, &pwd, buf, sizeof(buf), &pp) == 0 && pp != nullptr) { - return jsNumber(pp->pw_uid); + // A name longer than `buf` can't match an entry, and nss-systemd aborts on very long names. + if (str.length() < sizeof(buf)) { + auto utf8 = str.utf8(); + if (getpwnam_r(utf8.legacyCStringPointer(), &pwd, buf, sizeof(buf), &pp) == 0 && pp != nullptr) { + return jsNumber(pp->pw_uid); + } } - auto message = makeString("User identifier does not exist: "_s, str); - throwScope.throwException(globalObject, createError(globalObject, ErrorCode::ERR_UNKNOWN_CREDENTIAL, message)); + throwUnknownCredential(throwScope, globalObject, "User identifier does not exist: "_s, str); return {}; } @@ -3392,18 +3454,18 @@ static JSValue maybe_gid_by_name(JSC::ThrowScope& throwScope, JSGlobalObject* gl auto str = value.getString(globalObject); RETURN_IF_EXCEPTION(throwScope, {}); - auto utf8 = str.utf8(); - auto name = utf8.legacyCStringPointer(); struct group pwd; struct group* pp = nullptr; char buf[8192]; - if (getgrnam_r(name, &pwd, buf, sizeof(buf), &pp) == 0 && pp != nullptr) { - return jsNumber(pp->gr_gid); + if (str.length() < sizeof(buf)) { + auto utf8 = str.utf8(); + if (getgrnam_r(utf8.legacyCStringPointer(), &pwd, buf, sizeof(buf), &pp) == 0 && pp != nullptr) { + return jsNumber(pp->gr_gid); + } } - auto message = makeString("Group identifier does not exist: "_s, str); - throwScope.throwException(globalObject, createError(globalObject, ErrorCode::ERR_UNKNOWN_CREDENTIAL, message)); + throwUnknownCredential(throwScope, globalObject, "Group identifier does not exist: "_s, str); return {}; } @@ -3579,7 +3641,12 @@ JSC_DEFINE_HOST_FUNCTION(Process_functioninitgroups, (JSGlobalObject * globalObj if (user.isString()) { auto str = user.getString(globalObject); RETURN_IF_EXCEPTION(scope, {}); - userNameUTF8 = str.utf8(); + auto utf8 = str.tryGetUTF8(); + if (!utf8) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return {}; + } + userNameUTF8 = WTF::move(*utf8); userName = userNameUTF8.legacyCStringPointer(); } else { uid_t uid = static_cast(user.toUInt32(globalObject)); diff --git a/src/jsc/bindings/BunProcessReportObjectWindows.cpp b/src/jsc/bindings/BunProcessReportObjectWindows.cpp index 08f51d753148..4e0715f5c739 100644 --- a/src/jsc/bindings/BunProcessReportObjectWindows.cpp +++ b/src/jsc/bindings/BunProcessReportObjectWindows.cpp @@ -405,8 +405,9 @@ JSValue constructReportObjectWindows(VM& vm, Zig::GlobalObject* globalObject, Pr Bun::putDirectNamed(vm, report, "workers"_s, workersArray); // Environment variables - Bun::putDirectNamed(vm, report, "environmentVariables"_s, globalObject->processEnvObject()); + JSObject* env = globalObject->processEnvObject(); RETURN_IF_EXCEPTION(scope, {}); + Bun::putDirectNamed(vm, report, "environmentVariables"_s, env); return report; } diff --git a/src/jsc/bindings/BunString.cpp b/src/jsc/bindings/BunString.cpp index 98dd90b34c36..d278811da302 100644 --- a/src/jsc/bindings/BunString.cpp +++ b/src/jsc/bindings/BunString.cpp @@ -89,10 +89,23 @@ extern "C" [[ZIG_EXPORT(zero_is_throw)]] JSC::EncodedJSValue BunString__createUT if (length > WTF::String::MaxLength) [[unlikely]] { return Bun::ERR::STRING_TOO_LONG(scope, globalObject); } - return JSValue::encode(jsString(vm, WTF::String(std::span(reinterpret_cast(ptr), length)))); + // WTF::String(span) crashes if the allocation fails, and the caller controls the length. + std::span characters; + auto impl = WTF::StringImpl::tryCreateUninitialized(length, characters); + if (!impl) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return {}; + } + memcpy(characters.data(), ptr, length); + return JSValue::encode(jsString(vm, WTF::String(impl.releaseNonNull()))); } - auto str = Zig::convertUTF8ToString(std::span { reinterpret_cast(ptr), length }); + std::span bytes { reinterpret_cast(ptr), length }; + // convertUTF8ToString crashes if the allocation fails, so only use it for small or invalid input. + constexpr size_t minFallibleLength = 64 * 1024; + auto str = length >= minFallibleLength && simdutf::validate_utf8(ptr, length) + ? Zig::tryConvertValidUTF8ToString(bytes) + : Zig::convertUTF8ToString(bytes); if (str.isNull()) [[unlikely]] { throwOutOfMemoryError(globalObject, scope); return {}; @@ -167,10 +180,9 @@ static WTF::String errorMessage(const BunString* str) extern "C" JSC::EncodedJSValue BunString__toErrorInstance(const BunString* str, JSC::JSGlobalObject* globalObject, BunErrorKind kind) { WTF::String message = errorMessage(str); - if (message.isNull() && !str->isEmpty()) [[unlikely]] { - // Allocation failed or the message exceeds the maximum string length. - return {}; - } + // Callers throw, reject with, or set properties on the result, so always return an error object. + if (message.isNull() && (str->tag == BunStringTag::Dead || !str->isEmpty())) [[unlikely]] + message = "The error message exceeds the maximum string length"_s; JSC::ErrorType type = JSC::ErrorType::Error; switch (kind) { case BunErrorKind::Error: @@ -232,6 +244,18 @@ JSC::JSString* toJS(JSC::JSGlobalObject* globalObject, BunString bunString) UNREACHABLE(); } +JSC::Identifier toIdentifier(JSC::VM& vm, const BunString& bunString) +{ + ASSERT(!bunString.isDead()); + if (bunString.isEmpty()) { + return vm.propertyNames->emptyIdentifier; + } + if (bunString.tag == BunStringTag::StaticEncodedSlice) { + return JSC::Identifier::fromString(vm, Zig::toStringStatic(bunString.impl.encoded)); + } + return JSC::Identifier::fromString(vm, bunString.toWTFString()); +} + extern "C" [[ZIG_EXPORT(nothrow)]] BunString BunString__threadIsolatedCopy(const BunString* str) { if (str->tag == BunStringTag::WTFStringImpl) @@ -879,8 +903,7 @@ extern "C" JSC::EncodedJSValue JSC__JSValue__upsertBunStringArray( } JSC::JSValue newValue = JSC::JSValue::decode(encodedValue); auto& vm = global->vm(); - WTF::String str = key->tag == BunStringTag::Empty ? WTF::emptyString() : key->toWTFString(); - Identifier id = Identifier::fromString(vm, str); + Identifier id = Bun::toIdentifier(vm, *key); auto existingValue = target->getIfPropertyExists(global, id); RETURN_IF_EXCEPTION(scope, {}); @@ -896,11 +919,12 @@ extern "C" JSC::EncodedJSValue JSC__JSValue__upsertBunStringArray( array->putDirectIndex(global, 0, existingValue); RETURN_IF_EXCEPTION(scope, {}); array->putDirectIndex(global, 1, newValue); - target->putDirect(vm, id, array, 0); + RETURN_IF_EXCEPTION(scope, {}); + target->putDirectMayBeIndex(global, id, array); } } else { // No existing value, just put the new value directly - target->putDirect(vm, id, newValue, 0); + target->putDirectMayBeIndex(global, id, newValue); } RETURN_IF_EXCEPTION(scope, {}); @@ -916,8 +940,7 @@ extern "C" void JSC__JSValue__putBunString( JSC::JSObject* target = JSC::JSValue::decode(encodedTarget).getObject(); JSC::JSValue value = JSC::JSValue::decode(encodedValue); auto& vm = global->vm(); - WTF::String str = key->tag == BunStringTag::Empty ? WTF::emptyString() : key->toWTFString(); - Identifier id = Identifier::fromString(vm, str); + Identifier id = Bun::toIdentifier(vm, *key); target->putDirect(vm, id, value, 0); } diff --git a/src/jsc/bindings/ConsoleObject.cpp b/src/jsc/bindings/ConsoleObject.cpp index 5c0a357fb120..657e056418f7 100644 --- a/src/jsc/bindings/ConsoleObject.cpp +++ b/src/jsc/bindings/ConsoleObject.cpp @@ -60,30 +60,29 @@ void ConsoleObject::messageWithTypeAndLevel(MessageType type, MessageLevel level } void ConsoleObject::count(JSGlobalObject* globalObject, const String& label) { - auto input = label.tryGetUTF8().value(); - Bun__ConsoleObject__count(this->m_client, globalObject, reinterpret_cast(input.data()), input.length()); + auto input = Bun::toString(label); + Bun__ConsoleObject__count(this->m_client, globalObject, &input); } void ConsoleObject::countReset(JSGlobalObject* globalObject, const String& label) { - auto input = label.tryGetUTF8().value(); - Bun__ConsoleObject__countReset(this->m_client, globalObject, reinterpret_cast(input.data()), input.length()); + auto input = Bun::toString(label); + Bun__ConsoleObject__countReset(this->m_client, globalObject, &input); } -void ConsoleObject::takeHeapSnapshot(JSC::JSGlobalObject* globalObject, const String& label) +void ConsoleObject::takeHeapSnapshot(JSC::JSGlobalObject* globalObject, const String&) { - auto input = label.tryGetUTF8().value(); - Bun__ConsoleObject__takeHeapSnapshot(this->m_client, globalObject, reinterpret_cast(input.data()), input.length()); + Bun__ConsoleObject__takeHeapSnapshot(this->m_client, globalObject); } void ConsoleObject::time(JSGlobalObject* globalObject, const String& label) { - auto input = label.tryGetUTF8().value(); - Bun__ConsoleObject__time(this->m_client, globalObject, reinterpret_cast(input.data()), input.length()); + auto input = Bun::toString(label); + Bun__ConsoleObject__time(this->m_client, globalObject, &input); } void ConsoleObject::timeLog(JSGlobalObject* globalObject, const String& label, Ref&& arguments) { - auto input = label.tryGetUTF8().value(); + auto input = Bun::toString(label); auto args = arguments.ptr(); JSC::EncodedJSValue jsArgs[255]; @@ -93,12 +92,12 @@ void ConsoleObject::timeLog(JSGlobalObject* globalObject, const String& label, jsArgs[i] = JSC::JSValue::encode(val); } - Bun__ConsoleObject__timeLog(this->m_client, globalObject, reinterpret_cast(input.data()), input.length(), jsArgs, count); + Bun__ConsoleObject__timeLog(this->m_client, globalObject, &input, jsArgs, count); } void ConsoleObject::timeEnd(JSGlobalObject* globalObject, const String& label) { - auto input = label.tryGetUTF8().value(); - Bun__ConsoleObject__timeEnd(this->m_client, globalObject, reinterpret_cast(input.data()), input.length()); + auto input = Bun::toString(label); + Bun__ConsoleObject__timeEnd(this->m_client, globalObject, &input); } void ConsoleObject::timeStamp(JSGlobalObject* globalObject, Ref&& args) { diff --git a/src/jsc/bindings/Cookie.cpp b/src/jsc/bindings/Cookie.cpp index 05ca8271b849..0413c74abb80 100644 --- a/src/jsc/bindings/Cookie.cpp +++ b/src/jsc/bindings/Cookie.cpp @@ -2,6 +2,7 @@ #include "EncodeURIComponent.h" #include "JSCookie.h" #include "helpers.h" +#include "StringSizeLimit.h" #include #include #include @@ -166,11 +167,13 @@ bool Cookie::isExpired() const return currentTime > m_expires; } -String Cookie::toString(JSC::VM& vm) const +ExceptionOr Cookie::toString(JSC::VM& vm) const { - StringBuilder builder; + StringBuilder builder(OverflowPolicy::RecordOverflow); appendTo(vm, builder); - return builder.toString(); + if (builder.hasOverflowed() || Bun::exceedsStringLimit(builder.length())) [[unlikely]] + return Exception { OutOfMemoryError }; + return String { builder.toString() }; } static inline bool isValidCharacterInCookieName(char16_t c) diff --git a/src/jsc/bindings/Cookie.h b/src/jsc/bindings/Cookie.h index 86a1d512b71b..ef66916a109c 100644 --- a/src/jsc/bindings/Cookie.h +++ b/src/jsc/bindings/Cookie.h @@ -105,8 +105,8 @@ class Cookie : public RefCounted { bool isExpired() const; - void appendTo(JSC::VM& vm, StringBuilder& builder) const; - String toString(JSC::VM& vm) const; + // Returns OutOfMemoryError if the Set-Cookie string exceeds the max string length. + ExceptionOr toString(JSC::VM& vm) const; JSC::JSValue toJSON(JSC::VM& vm, JSC::JSGlobalObject*) const; size_t memoryCost() const; @@ -120,6 +120,9 @@ class Cookie : public RefCounted { int64_t expires, bool secure, CookieSameSite sameSite, bool httpOnly, double maxAge, bool partitioned); + // `builder` must use OverflowPolicy::RecordOverflow, and the caller must check hasOverflowed(). + void appendTo(JSC::VM& vm, StringBuilder& builder) const; + String m_name; String m_value; String m_domain; diff --git a/src/jsc/bindings/CookieMap.cpp b/src/jsc/bindings/CookieMap.cpp index 8fbb9f9a3cf0..a393790ec036 100644 --- a/src/jsc/bindings/CookieMap.cpp +++ b/src/jsc/bindings/CookieMap.cpp @@ -1,5 +1,6 @@ #include "CookieMap.h" #include "JSCookieMap.h" +#include "JSDOMExceptionHandling.h" #include #include #include @@ -15,10 +16,22 @@ namespace WebCore { template static void CookieMap__writeFetchHeadersToUWSResponse(CookieMap* cookie_map, JSC::JSGlobalObject* global_this, Res* res) { + auto& vm = global_this->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + // Loop over modified cookies and write Set-Cookie headers to the response for (auto& cookie : cookie_map->getAllChanges()) { - auto utf8 = cookie->toString(global_this->vm()).utf8(); - res->writeHeader("Set-Cookie", utf8.legacyCStringPointer()); + auto header = cookie->toString(vm); + if (header.hasException()) [[unlikely]] { + propagateException(*global_this, scope, header.releaseException()); + return; + } + auto utf8 = header.returnValue().tryGetUTF8(); + if (!utf8) [[unlikely]] { + throwOutOfMemoryError(global_this, scope); + return; + } + res->writeHeader("Set-Cookie", utf8->legacyCStringPointer()); } } extern "C" void CookieMap__write(CookieMap* cookie_map, JSC::JSGlobalObject* global_this, UWSResponseKind kind, void* arg2) diff --git a/src/jsc/bindings/DLHandleMap.h b/src/jsc/bindings/DLHandleMap.h index e62429790621..3887af689c75 100644 --- a/src/jsc/bindings/DLHandleMap.h +++ b/src/jsc/bindings/DLHandleMap.h @@ -3,6 +3,7 @@ #include "root.h" #include "v8/node.h" #include "napi.h" +#include #include #include #include @@ -16,8 +17,21 @@ namespace Bun { +struct V8ModuleRegistration { + node::node_module* module; + + // The live VM that last ran this module's init. Only set if bindsToVM(). See claimForVM and vmDestroyed. + std::optional owner; + + // True if only one live VM at a time may run the init. A context-unaware init (NODE_MODULE, NAN) keeps its handles in statics. + static bool bindsToVM(const node::node_module& mod) + { + return !mod.nm_context_register_func && mod.nm_register_func && mod.nm_version == REPORTED_NODEJS_ABI_VERSION; + } +}; + // A module can be either V8 C++ style or NAPI style -using DLModuleRegistration = std::variant; +using DLModuleRegistration = std::variant; // Thread-safe map for tracking dlopen handles to module registrations. // This allows re-loading the same native module multiple times, matching Node.js behavior. @@ -27,6 +41,8 @@ using DLModuleRegistration = std::variant; // its static constructors run and call node_module_register() or napi_module_register(). // On subsequent loads, dlopen() returns the same handle but the constructors don't run again. // We use this map to look up and replay all saved registrations. +// +// Node.js rejects any second load of a bindsToVM() module. Bun rejects it only while another live VM owns the module. class DLHandleMap { public: #if OS(WINDOWS) @@ -46,15 +62,19 @@ class DLHandleMap { return *s_instance; } - // Add a V8 C++ module registration to the vector for this handle - void add(DLHandle handle, node::node_module* module) + // Add a V8 C++ module registration to the vector for this handle. `vm` is about to run its init. + void add(DLHandle handle, node::node_module* module, JSC::VM& vm) { ASSERT(handle != nullptr); ASSERT(module != nullptr); + std::optional owner; + if (V8ModuleRegistration::bindsToVM(*module)) + owner = vm.identifier(); + WTF::Locker locker { m_lock }; auto& registrations = m_map.ensure(handle, [] { return WTF::Vector(); }).iterator->value; - registrations.append(DLModuleRegistration(module)); + registrations.append(DLModuleRegistration(V8ModuleRegistration { module, owner })); } // Add a NAPI module registration to the vector for this handle @@ -83,6 +103,53 @@ class DLHandleMap { return it->value; } + // Makes `vm` the owner of this handle's bindsToVM() modules. Returns false without changing anything if another live VM owns one. + bool claimForVM(DLHandle handle, JSC::VM& vm) + { + ASSERT(handle != nullptr); + auto identifier = vm.identifier(); + + WTF::Locker locker { m_lock }; + + auto it = m_map.find(handle); + ASSERT(it != m_map.end()); + if (it == m_map.end()) { + return true; + } + + auto& registrations = it->value; + for (auto& registration : registrations) { + auto* v8Module = std::get_if(®istration); + if (v8Module && v8Module->owner && *v8Module->owner != identifier) { + return false; + } + } + + for (auto& registration : registrations) { + auto* v8Module = std::get_if(®istration); + if (v8Module && V8ModuleRegistration::bindsToVM(*v8Module->module)) { + v8Module->owner = identifier; + } + } + + return true; + } + + // Releases the modules a destroyed VM owned so another VM can claim them. + void vmDestroyed(JSC::VMIdentifier identifier) + { + WTF::Locker locker { m_lock }; + + for (auto& registrations : m_map.values()) { + for (auto& registration : registrations) { + auto* v8Module = std::get_if(®istration); + if (v8Module && v8Module->owner == identifier) { + v8Module->owner.reset(); + } + } + } + } + private: DLHandleMap() = default; ~DLHandleMap() = default; diff --git a/src/jsc/bindings/DOMFormData.cpp b/src/jsc/bindings/DOMFormData.cpp index 4f3bca439c39..d3517723951d 100644 --- a/src/jsc/bindings/DOMFormData.cpp +++ b/src/jsc/bindings/DOMFormData.cpp @@ -30,6 +30,8 @@ #include "config.h" #include "DOMFormData.h" +#include "URLSearchParams.h" +#include "VectorSizeLimit.h" #include "wtf/DebugHeap.h" #include @@ -47,11 +49,17 @@ Ref DOMFormData::create(ScriptExecutionContext* context) return adoptRef(*new DOMFormData(context)); } -Ref DOMFormData::create(ScriptExecutionContext* context, const StringView& urlEncodedString) +ExceptionOr> DOMFormData::create(ScriptExecutionContext* context, StringView urlEncodedString) { + auto form = URLSearchParams::parseURLEncodedForm(urlEncodedString); + if (form.hasException()) + return form.releaseException(); + auto newFormData = adoptRef(*new DOMFormData(context)); - for (auto& entry : WTF::URLParser::parseURLEncodedForm(urlEncodedString)) { - newFormData->append(entry.key, entry.value); + for (auto& entry : form.returnValue()) { + auto result = newFormData->append(entry.key, entry.value); + if (result.hasException()) [[unlikely]] + return result.releaseException(); } return newFormData; @@ -92,15 +100,22 @@ static auto createStringEntry(const String& name, const String& value) -> DOMFor }; } -void DOMFormData::append(const String& name, const String& value) +ExceptionOr DOMFormData::appendItem(Item&& item) +{ + if (m_items.size() >= Bun::maxVectorSize() || !m_items.tryAppend(WTF::move(item))) [[unlikely]] + return Exception { OutOfMemoryError }; + return {}; +} + +ExceptionOr DOMFormData::append(const String& name, const String& value) { - m_items.append(createStringEntry(name, value)); + return appendItem(createStringEntry(name, value)); } -void DOMFormData::append(const String& name, RefPtr blob, const String& filename) +ExceptionOr DOMFormData::append(const String& name, RefPtr blob, const String& filename) { blob->setFileName(replaceUnpairedSurrogatesWithReplacementCharacter(String(filename))); - m_items.append({ replaceUnpairedSurrogatesWithReplacementCharacter(String(name)), blob }); + return appendItem({ replaceUnpairedSurrogatesWithReplacementCharacter(String(name)), blob }); } void DOMFormData::remove(const StringView name) { @@ -141,18 +156,18 @@ bool DOMFormData::has(const StringView name) return false; } -void DOMFormData::set(const String& name, const String& value) +ExceptionOr DOMFormData::set(const String& name, const String& value) { - set(name, { name, value }); + return set(name, { name, value }); } -void DOMFormData::set(const String& name, RefPtr blob, const String& filename) +ExceptionOr DOMFormData::set(const String& name, RefPtr blob, const String& filename) { blob->setFileName(filename); - set(name, { name, blob }); + return set(name, { name, blob }); } -void DOMFormData::set(const String& name, Item&& item) +ExceptionOr DOMFormData::set(const String& name, Item&& item) { std::optional initialMatchLocation; @@ -171,10 +186,10 @@ void DOMFormData::set(const String& name, Item&& item) return item.name == name; }, *initialMatchLocation + 1); - return; + return {}; } - m_items.append(WTF::move(item)); + return appendItem(WTF::move(item)); } DOMFormData::Iterator::Iterator(DOMFormData& target) diff --git a/src/jsc/bindings/DOMFormData.h b/src/jsc/bindings/DOMFormData.h index c969b503bdd2..ed888a0ae87d 100644 --- a/src/jsc/bindings/DOMFormData.h +++ b/src/jsc/bindings/DOMFormData.h @@ -31,6 +31,7 @@ #pragma once #include "ContextDestructionObserver.h" +#include "ExceptionOr.h" #include #include #include @@ -40,7 +41,6 @@ namespace WebCore { class ScriptExecutionContext; -template class ExceptionOr; class HTMLElement; class HTMLFormElement; DECLARE_ALLOCATOR_WITH_HEAP_IDENTIFIER(DOMFormData); @@ -61,18 +61,18 @@ class DOMFormData : public RefCounted, public ContextDestructionObs }; static Ref create(ScriptExecutionContext*); - static Ref create(ScriptExecutionContext*, const StringView& urlEncodedString); + static ExceptionOr> create(ScriptExecutionContext*, StringView urlEncodedString); const Vector& items() const { return m_items; } - void append(const String& name, const String& value); - void append(const String& name, RefPtr, const String& filename = {}); + ExceptionOr append(const String& name, const String& value); + ExceptionOr append(const String& name, RefPtr, const String& filename = {}); void remove(const StringView name); std::optional get(const StringView name); Vector getAll(const StringView name); bool has(const StringView name); - void set(const String& name, const String& value); - void set(const String& name, RefPtr, const String& filename = {}); + ExceptionOr set(const String& name, const String& value); + ExceptionOr set(const String& name, RefPtr, const String& filename = {}); size_t count() const { return m_items.size(); } size_t memoryCost() const; @@ -94,7 +94,8 @@ class DOMFormData : public RefCounted, public ContextDestructionObs private: explicit DOMFormData(ScriptExecutionContext*); - void set(const String& name, Item&&); + ExceptionOr appendItem(Item&&); + ExceptionOr set(const String& name, Item&&); Vector m_items; }; diff --git a/src/jsc/bindings/DOMURL.cpp b/src/jsc/bindings/DOMURL.cpp index 23668383be86..6fef3c49f966 100644 --- a/src/jsc/bindings/DOMURL.cpp +++ b/src/jsc/bindings/DOMURL.cpp @@ -165,7 +165,7 @@ ExceptionOr DOMURL::setHref(const String& url) m_url = WTF::move(completeURL); m_searchParamsDirty = false; if (m_searchParams) - m_searchParams->updateFromAssociatedURL(); + return m_searchParams->updateFromAssociatedURL(); return {}; } @@ -188,10 +188,14 @@ void DOMURL::flushPendingSearchParamsUpdate() const self->m_url.setQuery(WTF::move(serialized)); } -URLSearchParams& DOMURL::searchParams() +ExceptionOr DOMURL::searchParams() { - if (!m_searchParams) - m_searchParams = URLSearchParams::create(search(), this); + if (!m_searchParams) { + auto searchParams = URLSearchParams::create(search(), this); + if (searchParams.hasException()) + return searchParams.releaseException(); + m_searchParams = searchParams.releaseReturnValue(); + } return *m_searchParams; } diff --git a/src/jsc/bindings/DOMURL.h b/src/jsc/bindings/DOMURL.h index 6928f44f3052..3724f2237787 100644 --- a/src/jsc/bindings/DOMURL.h +++ b/src/jsc/bindings/DOMURL.h @@ -56,7 +56,7 @@ class DOMURL final : public RefCounted, public CanMakeWeakPtr, p } ExceptionOr setHref(const String&); - URLSearchParams& searchParams(); + ExceptionOr searchParams(); void markSearchParamsDirty() { m_searchParamsDirty = true; } size_t memoryCost() const diff --git a/src/jsc/bindings/ErrorStackFrame.cpp b/src/jsc/bindings/ErrorStackFrame.cpp index 1e7baf4c2055..cc1b930e7d02 100644 --- a/src/jsc/bindings/ErrorStackFrame.cpp +++ b/src/jsc/bindings/ErrorStackFrame.cpp @@ -8,7 +8,7 @@ namespace Bun { using namespace JSC; -/// Adjust a `ZigStackFramePosition` by a number of bytes. This accounts for when the adjustment +/// Adjust a `ZigStackFramePosition` by a number of code units. This accounts for when the adjustment /// crosses line boundaries, and thus requires the source code in order to properly compute /// the result. void adjustPositionBackwards(ZigStackFramePosition& pos, int amount, CodeBlock* code) @@ -31,20 +31,12 @@ void adjustPositionBackwards(ZigStackFramePosition& pos, int amount, CodeBlock* } auto source = provider->source(); - if (!source.is8Bit()) { - // Debug-only assertion - // Bun does not yet use 16-bit sources anywhere. The transpiler ensures everything - // fit's into latin1 / 8-bit strings for on-average lower memory usage. - ASSERT_NOT_REACHED("16-bit source re-mapping is not implemented here."); - - pos.line_zero_based = 0; - pos.column_zero_based = 0; - pos.byte_position = 0; - return; - } + unsigned length = source.length(); for (int i = 0; i < amount; i++) { - if (source[pos.byte_position - i] == '\n') { + // byte_position equals the source length when the callee ends at the end of the source. + unsigned index = pos.byte_position - i; + if (index < length && source[index] == '\n') { pos.line_zero_based = pos.line_zero_based - 1; } } diff --git a/src/jsc/bindings/Exception.h b/src/jsc/bindings/Exception.h index c14e1d7ba8b4..632b764610b0 100644 --- a/src/jsc/bindings/Exception.h +++ b/src/jsc/bindings/Exception.h @@ -70,4 +70,14 @@ inline Exception isolatedCopy(Exception&& value) return Exception { value.code(), value.releaseMessage().isolatedCopy(), value.releaseExtra().isolatedCopy() }; } +// Returns an OutOfMemoryError exception if the message exceeds the max string length. +template +Exception exceptionWithMessage(ExceptionCode code, const StringTypes&... parts) +{ + String message = tryMakeString(parts...); + if (message.isNull()) [[unlikely]] + return Exception { OutOfMemoryError }; + return Exception { code, WTF::move(message) }; +} + } diff --git a/src/jsc/bindings/FormatStackTraceForJS.cpp b/src/jsc/bindings/FormatStackTraceForJS.cpp index 2608c4d02b5e..7a61fe847992 100644 --- a/src/jsc/bindings/FormatStackTraceForJS.cpp +++ b/src/jsc/bindings/FormatStackTraceForJS.cpp @@ -738,6 +738,11 @@ JSC_DEFINE_HOST_FUNCTION(errorConstructorFuncAppendStackTrace, (JSC::JSGlobalObj return {}; } + // appendVector() can't append a vector to itself, and the clear() below would drop the trace. + if (source == destination) { + return JSC::JSValue::encode(jsUndefined()); + } + // A destination that is materialized, or is being formatted right now, never renders frames again. if (destination->hasMaterializedErrorInfo()) { return JSC::JSValue::encode(jsUndefined()); diff --git a/src/jsc/bindings/ImportMetaObject.cpp b/src/jsc/bindings/ImportMetaObject.cpp index 5eee041298e5..cb4be748d1af 100644 --- a/src/jsc/bindings/ImportMetaObject.cpp +++ b/src/jsc/bindings/ImportMetaObject.cpp @@ -50,6 +50,7 @@ #include "wtf/text/StringView.h" #include "isBuiltinModule.h" +#include "VectorSizeLimit.h" #include "WebCoreJSBuiltins.h" namespace Zig { @@ -206,7 +207,7 @@ extern "C" JSC::EncodedJSValue functionImportMeta__resolveSyncPrivate(JSC::JSGlo JSC::JSValue moduleName = callFrame->argument(0); JSValue from = callFrame->argument(1); - bool isESM = callFrame->argument(2).asBoolean(); + bool isESM = callFrame->argument(2).isTrue(); bool isRequireDotResolve = callFrame->argument(3).isTrue(); JSValue userPathList = callFrame->argument(4); JSValue parentModule = callFrame->argument(5); @@ -255,6 +256,11 @@ extern "C" JSC::EncodedJSValue functionImportMeta__resolveSyncPrivate(JSC::JSGlo RETURN_IF_EXCEPTION(scope, {}); auto str = string->value(globalObject); RETURN_IF_EXCEPTION(scope, {}); + // The override bypasses the resolver, which is what normally rejects null bytes. + if (str->contains('\0')) [[unlikely]] { + Bun::ERR::INVALID_ARG_VALUE(scope, globalObject, "path"_s, string, "must be a string, Uint8Array, or URL without null bytes"_s); + return {}; + } WTF::String prefixed = Bun::isUnprefixedNodeBuiltin(str); if (!prefixed.isNull()) { return JSValue::encode(jsString(vm, prefixed)); @@ -296,7 +302,12 @@ extern "C" JSC::EncodedJSValue functionImportMeta__resolveSyncPrivate(JSC::JSGlo WTF::String pathStr = path.toWTFString(globalObject); if (scope.exception()) [[unlikely]] goto cleanup; - paths.append(Bun::toStringRef(pathStr)); + BunString pathRef = Bun::toStringRef(pathStr); + if (!Bun::tryAppendWithinLimit(paths, pathRef)) [[unlikely]] { + pathRef.deref(); + throwOutOfMemoryError(globalObject, scope); + goto cleanup; + } } result = Bun__resolveSyncWithPaths(lexicalGlobalObject, JSC::JSValue::encode(moduleName), JSValue::encode(from), isESM, isRequireDotResolve, paths.begin(), paths.size()); @@ -512,7 +523,10 @@ JSC_DEFINE_CUSTOM_SETTER(jsImportMetaObjectSetter_require, (JSGlobalObject * jsG JSC_DEFINE_CUSTOM_GETTER(jsImportMetaObjectGetter_env, (JSGlobalObject * jsGlobalObject, JSC::EncodedJSValue thisValue, PropertyName propertyName)) { auto* globalObject = uncheckedDowncast(jsGlobalObject); - return JSValue::encode(globalObject->m_processEnvObject.getInitializedOnMainThread(globalObject)); + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + JSObject* env = globalObject->processEnvObject(); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(env); } extern "C" JSC::EncodedJSValue SYSV_ABI BunObject_getter_main(JSC::JSGlobalObject*); diff --git a/src/jsc/bindings/InspectorLifecycleAgent.cpp b/src/jsc/bindings/InspectorLifecycleAgent.cpp index dc32891c9187..b0485edfd79f 100644 --- a/src/jsc/bindings/InspectorLifecycleAgent.cpp +++ b/src/jsc/bindings/InspectorLifecycleAgent.cpp @@ -171,15 +171,17 @@ Protocol::ErrorStringOr InspectorLifecycleAgent::getModuleGraph() Ref> argv = JSON::ArrayOf::create(); { - - auto* array = uncheckedDowncast(process->getArgv(global)); + // process.argv is writable, so this can be any value. + auto argvValue = process->getArgv(global); RETURN_IF_EXCEPTION(scope, fail("Failed to get argv"_s)); - for (size_t i = 0, length = array->length(); i < length; i++) { - auto value = array->getIndex(global, i); - RETURN_IF_EXCEPTION(scope, fail("Failed to get value at index"_s)); - auto string = value.toWTFString(global); - RETURN_IF_EXCEPTION(scope, fail("Failed to convert value to string"_s)); - argv->addItem(string); + if (auto* array = dynamicDowncast(argvValue)) { + for (size_t i = 0, length = array->length(); i < length; i++) { + auto value = array->getIndex(global, i); + RETURN_IF_EXCEPTION(scope, fail("Failed to get value at index"_s)); + auto string = value.toWTFString(global); + RETURN_IF_EXCEPTION(scope, fail("Failed to convert value to string"_s)); + argv->addItem(string); + } } } diff --git a/src/jsc/bindings/JSBuffer.cpp b/src/jsc/bindings/JSBuffer.cpp index c0325e63b350..e02362286172 100644 --- a/src/jsc/bindings/JSBuffer.cpp +++ b/src/jsc/bindings/JSBuffer.cpp @@ -414,6 +414,19 @@ bool Bun::rejectBytesNoCopyAboveArrayBufferLimit(JSC::JSGlobalObject* globalObje return true; } +WTF::MallocSpan Bun::copySharedBytes(JSC::JSGlobalObject* globalObject, JSC::ThrowScope& scope, std::span bytes) +{ + if (bytes.empty()) + return {}; + auto copy = WTF::MallocSpan::tryMalloc(bytes.size()); + if (!copy) [[unlikely]] { + JSC::throwOutOfMemoryError(globalObject, scope); + return {}; + } + memcpySpan(copy.mutableSpan(), bytes); + return copy; +} + JSC::EncodedJSValue JSBuffer__bufferFromPointerAndLengthAndDeinit(JSC::JSGlobalObject* lexicalGlobalObject, char* ptr, size_t length, void* ctx, JSTypedArrayBytesDeallocator bytesDeallocator) { JSC::JSUint8Array* uint8Array = nullptr; @@ -2143,7 +2156,13 @@ JSC::EncodedJSValue jsBufferToString(JSC::JSGlobalObject* lexicalGlobalObject, T length = byteLength - offset; } - return jsBufferToStringFromBytes(lexicalGlobalObject, scope, castedThis->span().subspan(offset, length), encoding); + auto bytes = castedThis->span().subspan(offset, length); + if (castedThis->isShared() && encoding == BufferEncodingType::utf8) [[unlikely]] { + auto copy = Bun::copySharedBytes(lexicalGlobalObject, scope, bytes); + RETURN_IF_EXCEPTION(scope, {}); + return jsBufferToStringFromBytes(lexicalGlobalObject, scope, copy.span(), encoding); + } + return jsBufferToStringFromBytes(lexicalGlobalObject, scope, bytes, encoding); } // Mirrors v8::Value::IntegerValue(): NaN becomes 0 and anything outside the @@ -2937,7 +2956,8 @@ static JSC::JSArrayBufferView* bufferAccessReceiver(JSC::JSGlobalObject* lexical { auto* view = dynamicDowncast(thisValue); if (!view) [[unlikely]] { - Bun::ERR::INVALID_ARG_TYPE(scope, lexicalGlobalObject, "buf"_s, "Buffer"_s, thisValue); + // In a bare call `this` is the caller's scope object. toThis() converts it to undefined, which is what a strict JS callee sees. + Bun::ERR::INVALID_ARG_TYPE(scope, lexicalGlobalObject, "buf"_s, "Buffer"_s, thisValue.toThis(lexicalGlobalObject, JSC::ECMAMode::strict())); return nullptr; } return view; diff --git a/src/jsc/bindings/JSBuffer.h b/src/jsc/bindings/JSBuffer.h index 2104d8a50a35..87e2851b027b 100644 --- a/src/jsc/bindings/JSBuffer.h +++ b/src/jsc/bindings/JSBuffer.h @@ -25,6 +25,7 @@ #include #include #include +#include #include #include "BufferEncodingType.h" @@ -46,6 +47,10 @@ std::optional byteLength(JSC::JSString* str, JSC::JSGlobalObject* lexica // RangeError `new ArrayBuffer(length)` would throw, and returns true. bool rejectBytesNoCopyAboveArrayBufferLimit(JSC::JSGlobalObject*, JSC::ThrowScope&, const void* bytes, size_t length, JSTypedArrayBytesDeallocator, void* deallocatorContext); +// Copies bytes out of a SharedArrayBuffer. Another thread can write to it between the UTF-8 decoders' +// two passes, so they decode the copy. Throws OutOfMemoryError if the copy fails. +WTF::MallocSpan copySharedBytes(JSC::JSGlobalObject*, JSC::ThrowScope&, std::span bytes); + namespace Buffer { const size_t kMaxLength = MAX_ARRAY_BUFFER_SIZE; diff --git a/src/jsc/bindings/JSBunRequest.cpp b/src/jsc/bindings/JSBunRequest.cpp index a7be8377324b..3057b67a29fc 100644 --- a/src/jsc/bindings/JSBunRequest.cpp +++ b/src/jsc/bindings/JSBunRequest.cpp @@ -119,7 +119,9 @@ JSBunRequest* JSBunRequest::clone(JSC::VM& vm, JSGlobalObject* globalObject) for (auto& property : propertyNames) { auto value = params->get(globalObject, property); RETURN_IF_EXCEPTION(throwScope, nullptr); - paramsClone->putDirect(vm, property, value); + // User code can add an array-index key like "0" to `params`. + paramsClone->putDirectMayBeIndex(globalObject, property, value); + RETURN_IF_EXCEPTION(throwScope, nullptr); } clone->setParams(paramsClone); diff --git a/src/jsc/bindings/JSBundlerPlugin.cpp b/src/jsc/bindings/JSBundlerPlugin.cpp index 3fc0a012114b..4dbf911cdc5b 100644 --- a/src/jsc/bindings/JSBundlerPlugin.cpp +++ b/src/jsc/bindings/JSBundlerPlugin.cpp @@ -22,7 +22,7 @@ #include #include "BunClientData.h" #include "ModuleLoader.h" -#include +#include #include #include #include @@ -215,10 +215,22 @@ DEFINE_VISIT_OUTPUT_CONSTRAINTS(JSBundlerPlugin); const JSC::ClassInfo JSBundlerPlugin::s_info = { "BundlerPlugin"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSBundlerPlugin) }; +// Throws and returns nullptr if `this` isn't a JSBundlerPlugin. JS can get this cell from bun:jsc's getProtectedObjects(). +static JSBundlerPlugin* pluginReceiver(JSC::JSGlobalObject* globalObject, JSC::CallFrame* callFrame) +{ + auto* thisObject = dynamicDowncast(callFrame->thisValue()); + if (!thisObject) [[unlikely]] + Bun::throwInvalidThisCallError(globalObject, callFrame, "BundlerPlugin"_s); + return thisObject; +} + /// `BundlerPlugin.prototype.addFilter(filter: RegExp, namespace: string, isOnLoad: 0 | 1): void` JSC_DEFINE_HOST_FUNCTION(jsBundlerPluginFunction_addFilter, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { - JSBundlerPlugin* thisObject = uncheckedDowncast(callFrame->thisValue()); + JSBundlerPlugin* thisObject = pluginReceiver(globalObject, callFrame); + if (!thisObject) [[unlikely]] + return {}; + if (thisObject->plugin.tombstoned) { return JSC::JSValue::encode(JSC::jsUndefined()); } @@ -240,6 +252,10 @@ JSC_DEFINE_HOST_FUNCTION(jsBundlerPluginFunction_addFilter, (JSC::JSGlobalObject uint32_t isOnLoad = callFrame->argument(2).toUInt32(globalObject); RETURN_IF_EXCEPTION(scope, {}); + // Check after the last conversion that can run user code, so nothing runs between the check and the append. + if (thisObject->plugin.filtersFrozen) [[unlikely]] + return Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_STATE, "addFilter() called after the build started"_s); + unsigned index = 0; if (isOnLoad) { thisObject->plugin.onLoad.append(vm, regExp->regExp(), namespaceStr, index); @@ -285,9 +301,15 @@ void BundlerPlugin::NativePluginList::append(JSC::VM& vm, JSC::RegExp* filter, S bool BundlerPlugin::FilterRegExp::match(JSC::VM& vm, const String& path) { + if (!m_bytecode || path.isNull()) + return false; WTF::Locker locker { lock }; Yarr::MatchingContextHolder regExpContext(vm, nullptr, Yarr::MatchFrom::CompilerThread); - return regex.match(path) != -1; + Vector offsets; + offsets.grow(m_bytecode->m_offsetsSize); + for (unsigned i = 0; i < m_bytecode->m_body->m_numSubpatterns + 1; ++i) + offsets[i * 2] = Yarr::offsetNoMatch; + return Yarr::interpret(m_bytecode.get(), path, 0, offsets.mutableSpan().data()) != Yarr::offsetNoMatch; } int BundlerPlugin::NativePluginList::call(JSC::VM& vm, BundlerPlugin* plugin, int* shouldContinue, void* bunContextPtr, const BunString* namespaceStr, const BunString* pathString, OnBeforeParseArguments* onBeforeParseArgs, OnBeforeParseResult* onBeforeParseResult) @@ -340,9 +362,12 @@ int BundlerPlugin::NativePluginList::call(JSC::VM& vm, BundlerPlugin* plugin, in } JSC_DEFINE_HOST_FUNCTION(jsBundlerPluginFunction_onBeforeParse, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { + JSBundlerPlugin* thisObject = pluginReceiver(globalObject, callFrame); + if (!thisObject) [[unlikely]] + return {}; + auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - JSBundlerPlugin* thisObject = uncheckedDowncast(callFrame->thisValue()); if (thisObject->plugin.tombstoned) { return JSC::JSValue::encode(JSC::jsUndefined()); } @@ -413,9 +438,15 @@ JSC_DEFINE_HOST_FUNCTION(jsBundlerPluginFunction_onBeforeParse, (JSC::JSGlobalOb Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, "Expected external (3rd argument) to be a NAPI external"_s); return {}; } - thisObject->plugin.onBeforeParseExternals.append(vm, thisObject, externalPtr); } + // Check after the last conversion that can run user code, so nothing runs between the check and the appends. + if (thisObject->plugin.filtersFrozen) [[unlikely]] + return Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_STATE, "onBeforeParse() called after the build started"_s); + + if (externalPtr) + thisObject->plugin.onBeforeParseExternals.append(vm, thisObject, externalPtr); + thisObject->plugin.onBeforeParse.append(vm, newRegexp, namespaceStr, callback, native_plugin_name ? *native_plugin_name : nullptr, externalPtr); return JSC::JSValue::encode(JSC::jsUndefined()); @@ -423,7 +454,10 @@ JSC_DEFINE_HOST_FUNCTION(jsBundlerPluginFunction_onBeforeParse, (JSC::JSGlobalOb JSC_DEFINE_HOST_FUNCTION(jsBundlerPluginFunction_addError, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { - JSBundlerPlugin* thisObject = uncheckedDowncast(callFrame->thisValue()); + JSBundlerPlugin* thisObject = pluginReceiver(globalObject, callFrame); + if (!thisObject) [[unlikely]] + return {}; + void* context = UNWRAP_BUNDLER_PLUGIN(callFrame); if (auto kind = thisObject->plugin.takeRequest(context)) thisObject->plugin.addError(context, thisObject, JSValue::encode(callFrame->argument(1)), static_cast(*kind)); @@ -432,7 +466,10 @@ JSC_DEFINE_HOST_FUNCTION(jsBundlerPluginFunction_addError, (JSC::JSGlobalObject } JSC_DEFINE_HOST_FUNCTION(jsBundlerPluginFunction_onLoadAsync, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { - JSBundlerPlugin* thisObject = uncheckedDowncast(callFrame->thisValue()); + JSBundlerPlugin* thisObject = pluginReceiver(globalObject, callFrame); + if (!thisObject) [[unlikely]] + return {}; + if (thisObject->plugin.takeRequest(BundlerPlugin::RequestKind::Load, UNWRAP_BUNDLER_PLUGIN(callFrame))) { thisObject->plugin.onLoadAsync( UNWRAP_BUNDLER_PLUGIN(callFrame), @@ -445,7 +482,10 @@ JSC_DEFINE_HOST_FUNCTION(jsBundlerPluginFunction_onLoadAsync, (JSC::JSGlobalObje } JSC_DEFINE_HOST_FUNCTION(jsBundlerPluginFunction_onResolveAsync, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { - JSBundlerPlugin* thisObject = uncheckedDowncast(callFrame->thisValue()); + JSBundlerPlugin* thisObject = pluginReceiver(globalObject, callFrame); + if (!thisObject) [[unlikely]] + return {}; + if (thisObject->plugin.takeRequest(BundlerPlugin::RequestKind::Resolve, UNWRAP_BUNDLER_PLUGIN(callFrame))) { thisObject->plugin.onResolveAsync( UNWRAP_BUNDLER_PLUGIN(callFrame), @@ -471,7 +511,10 @@ extern "C" JSC::EncodedJSValue JSBundlerPlugin__appendDeferPromise(Bun::JSBundle JSC_DEFINE_HOST_FUNCTION(jsBundlerPluginFunction_generateDeferPromise, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { - JSBundlerPlugin* thisObject = uncheckedDowncast(callFrame->thisValue()); + JSBundlerPlugin* thisObject = pluginReceiver(globalObject, callFrame); + if (!thisObject) [[unlikely]] + return {}; + void* context = UNWRAP_BUNDLER_PLUGIN(callFrame); // Only a request the plugins still hold can defer: once answered it is the bundle thread's again. if (!thisObject->plugin.holdsRequest(BundlerPlugin::RequestKind::Load, context)) { @@ -513,8 +556,11 @@ void JSBundlerPlugin::finishCreation(JSC::VM& vm) JSC::JSFunction::create(vm, globalObject, WebCore::bundlerPluginRunSetupFunctionCodeGenerator(vm), globalObject)); }); + // Every property the builtins in BundlerPlugin.ts read from this object must be an own property. this->putDirect(vm, Identifier::fromString(vm, String("onLoad"_s)), jsUndefined(), 0); this->putDirect(vm, Identifier::fromString(vm, String("onResolve"_s)), jsUndefined(), 0); + this->putDirect(vm, Identifier::fromString(vm, String("onEndCallbacks"_s)), jsUndefined(), 0); + this->putDirect(vm, Identifier::fromString(vm, String("promises"_s)), jsUndefined(), 0); Bun::reifyStaticPropertyTable(vm, JSBundlerPlugin::info(), JSBundlerPluginHashTable, *this); } @@ -598,7 +644,8 @@ extern "C" Bun::JSBundlerPlugin* JSBundlerPlugin__create(Zig::GlobalObject* glob JSBundlerPlugin::createStructure( globalObject->vm(), globalObject, - globalObject->objectPrototype()), + // Null prototype, so an accessor on Object.prototype can't get this object as its receiver. + jsNull()), nullptr, target); } @@ -649,7 +696,9 @@ extern "C" JSC::EncodedJSValue JSBundlerPlugin__runSetupFunction( arguments.append(JSValue::decode(encodedOnstartPromisesArray)); arguments.append(JSValue::decode(encodedIsLast)); arguments.append(JSValue::decode(encodedIsBake)); - auto* lexicalGlobalObject = uncheckedDowncast(JSValue::decode(encodedSetupFunction))->globalObject(); + // setup() can be any callable, and a callable Proxy isn't a JSFunction. + auto* setupObject = JSValue::decode(encodedSetupFunction).getObject(); + auto* lexicalGlobalObject = setupObject ? setupObject->globalObject() : plugin->globalObject(); auto result = JSC::profiledCall(lexicalGlobalObject, ProfilingReason::API, setupFunction, callData, plugin, arguments); RETURN_IF_EXCEPTION(scope, {}); // should be able to use RELEASE_AND_RETURN, no? observed it returning undefined with exception active @@ -695,6 +744,11 @@ extern "C" void JSBundlerPlugin__tombstone(Bun::JSBundlerPlugin* plugin) plugin->plugin.tombstone(); } +extern "C" void JSBundlerPlugin__freezeFilters(Bun::JSBundlerPlugin* plugin) +{ + plugin->plugin.filtersFrozen = true; +} + extern "C" JSC::EncodedJSValue JSBundlerPlugin__runOnEndCallbacks(Bun::JSBundlerPlugin* plugin, JSC::EncodedJSValue encodedBuildPromise, JSC::EncodedJSValue encodedBuildResult, JSC::EncodedJSValue encodedRejection) { auto& vm = plugin->vm(); diff --git a/src/jsc/bindings/JSBundlerPlugin.h b/src/jsc/bindings/JSBundlerPlugin.h index 6341c08e986f..0f20bac56294 100644 --- a/src/jsc/bindings/JSBundlerPlugin.h +++ b/src/jsc/bindings/JSBundlerPlugin.h @@ -4,9 +4,10 @@ #include "root.h" #include "headers-handwritten.h" #include -#include +#include #include "napi_external.h" #include +#include #include "WriteBarrierList.h" #include @@ -23,23 +24,32 @@ class BundlerPlugin final { public: /// In native plugins, the regular expression could be called concurrently on multiple threads. /// Therefore, we need a mutex to synchronize access. + /// Compiles the bytecode itself because Yarr::RegularExpression asserts that the flags are a subset of i, m and v. class FilterRegExp { public: String m_pattern; - Yarr::RegularExpression regex; + // The bytecode points to its allocator, so the allocator can't move when a Vector grows. + std::unique_ptr m_allocator; + std::unique_ptr m_bytecode; WTF::Lock lock {}; FilterRegExp(FilterRegExp&& other) : m_pattern(WTF::move(other.m_pattern)) - , regex(WTF::move(other.regex)) + , m_allocator(WTF::move(other.m_allocator)) + , m_bytecode(WTF::move(other.m_bytecode)) { } FilterRegExp(const String& pattern, OptionSet flags) // Ensure it's safe for cross-thread usage. : m_pattern(pattern.isolatedCopy()) - , regex(m_pattern, flags) + , m_allocator(WTF::makeUnique()) { + Yarr::ErrorCode errorCode = Yarr::ErrorCode::NoError; + Yarr::YarrPattern yarrPattern(m_pattern, flags, errorCode); + if (Yarr::hasError(errorCode)) + return; + m_bytecode = Yarr::byteCompile(yarrPattern, m_allocator.get(), errorCode); } bool match(JSC::VM& vm, const String& path); @@ -170,6 +180,8 @@ class BundlerPlugin final { JSBundlerPluginOnResolveAsyncCallback onResolveAsync; void* config { nullptr }; bool tombstoned { false }; + // Set on the JS thread before a bundle pass. After that, other threads read the filter lists without a lock. + bool filtersFrozen { false }; private: WTF::HashMap held; diff --git a/src/jsc/bindings/JSCFFIBridge.cpp b/src/jsc/bindings/JSCFFIBridge.cpp index cf8c923b7107..97093039fabb 100644 --- a/src/jsc/bindings/JSCFFIBridge.cpp +++ b/src/jsc/bindings/JSCFFIBridge.cpp @@ -2,21 +2,30 @@ #include "root.h" #include +#include #include #include #include #include #include +#include #include "ScriptExecutionContext.h" #include #include #include +#include #include "ZigGlobalObject.h" #include "headers-handwritten.h" static_assert(static_cast(JSC::FFI::Type::Char) == 0, "FFI::Type tag drift"); +static_assert(static_cast(JSC::FFI::Type::Int64) == 7, "FFI::Type tag drift"); +static_assert(static_cast(JSC::FFI::Type::Uint64) == 8, "FFI::Type tag drift"); static_assert(static_cast(JSC::FFI::Type::Pointer) == 12, "FFI::Type tag drift"); +static_assert(static_cast(JSC::FFI::Type::CString) == 14, "FFI::Type tag drift"); +static_assert(static_cast(JSC::FFI::Type::Int64Fast) == 15, "FFI::Type tag drift"); +static_assert(static_cast(JSC::FFI::Type::Uint64Fast) == 16, "FFI::Type tag drift"); +static_assert(static_cast(JSC::FFI::Type::Function) == 17, "FFI::Type tag drift"); static_assert(static_cast(JSC::FFI::Type::JSValue) == 19, "FFI::Type tag drift"); static_assert(static_cast(JSC::FFI::Type::Buffer) == 20, "FFI::Type tag drift"); static_assert(static_cast(JSC::FFI::Type::BufferLength) == 21, "FFI::Type tag drift"); @@ -119,3 +128,33 @@ extern "C" void Bun__JSCFFICallbackClose(JSC::EncodedJSValue callbackValue) if (auto* callback = dynamicDowncast(JSC::JSValue::decode(callbackValue))) callback->close(); } + +// Implements JSVALUE_TO_SLOT_SLOW in src/runtime/ffi/FFI.h. Converts a cc() wrapper's arguments the way dlopen() does. +extern "C" uint64_t Bun__FFI__jsValueToSlotSlow(JSC::JSGlobalObject* globalObject, int32_t abiType, bool* threw, JSC::EncodedJSValue encodedValue) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto type = static_cast(abiType); + JSC::JSValue value = JSC::JSValue::decode(encodedValue); + + // The wrapper already holds raw pointers into the buffers of earlier arguments, and JS could free those buffers. + // writeSlotFromJSValue only runs JS to get `ptr` from an object, so read it here without running a getter. + bool isPointer = type == JSC::FFI::Type::Pointer || type == JSC::FFI::Type::CString || type == JSC::FFI::Type::Function; + if (auto* object = value.getObject(); isPointer && object && !object->inherits() && !object->inherits()) { + value = object->getDirect(vm, JSC::Identifier::fromString(vm, "ptr"_s)); + if (!value || !(value.isNumber() || value.isBigInt())) { + JSC::throwTypeError(globalObject, scope, makeString("bun:ffi cannot convert argument to '"_s, JSC::FFI::name(type), '\'')); + *threw = true; + return 0; + } + } + + uint64_t slot = 0; + // Nothing could free a transcoded `cstring` after the call, so pass a null arena, which makes a JS string throw. + JSC::FFI::writeSlotFromJSValue(globalObject, globalObject->ffiContext(), type, value, slot, nullptr); + if (scope.exception()) [[unlikely]] { + *threw = true; + return 0; + } + return slot; +} diff --git a/src/jsc/bindings/JSCommonJSModule.cpp b/src/jsc/bindings/JSCommonJSModule.cpp index bec3e3802776..aed824b8ae5a 100644 --- a/src/jsc/bindings/JSCommonJSModule.cpp +++ b/src/jsc/bindings/JSCommonJSModule.cpp @@ -50,6 +50,7 @@ #include #include #include "BunClientData.h" +#include "ObjectBindings.h" #include "IsolatedModuleCache.h" #include #include "ImportMetaObject.h" @@ -89,9 +90,6 @@ namespace Bun { using namespace JSC; -JSC_DECLARE_HOST_FUNCTION(jsFunctionRequireCommonJS); -JSC_DECLARE_HOST_FUNCTION(jsFunctionRequireNativeModule); - static bool canPerformFastEnumeration(Structure* s) { if (s->typeInfo().overridesGetOwnPropertySlot()) @@ -426,17 +424,21 @@ JSC_DEFINE_HOST_FUNCTION(requireResolvePathsFunction, (JSGlobalObject * globalOb JSC_DEFINE_CUSTOM_GETTER(jsRequireCacheGetter, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) { - Zig::GlobalObject* thisObject = uncheckedDowncast(globalObject); + // The FTL passes the inlined code's realm, which can be a node:vm context. Contexts don't have a require cache. + Zig::GlobalObject* thisObject = defaultGlobalObject(globalObject); + auto& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); auto* requirer = requirerOf(JSValue::decode(thisValue)); JSModuleGraph* graph = requirer ? requirer->moduleGraph() : nullptr; - if (!graph) - return JSValue::encode(thisObject->lazyRequireCacheObject()); + if (!graph) { + JSObject* cache = thisObject->lazyRequireCacheObject(); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(cache); + } // A require() that belongs to a Bun.ModuleGraph: a view of that graph's cache. if (JSValue existing = graph->requireCache()) return JSValue::encode(existing); - auto& vm = globalObject->vm(); - auto scope = DECLARE_THROW_SCOPE(vm); - JSValue cache = createRequireCacheObject(globalObject, graph->requireMap(), requirer); + JSValue cache = createRequireCacheObject(thisObject, graph->requireMap(), requirer); RETURN_IF_EXCEPTION(scope, {}); graph->setRequireCache(vm, cache); return JSValue::encode(cache); @@ -460,13 +462,14 @@ JSC_DEFINE_CUSTOM_SETTER(jsRequireCacheSetter, if (!thisObject) return false; - thisObject->putDirect(globalObject->vm(), propertyName, JSValue::decode(value), 0); - return true; + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + RELEASE_AND_RETURN(scope, Bun::defineOwnDataProperty(globalObject, thisObject, propertyName, JSValue::decode(value), 0)); } JSC_DEFINE_CUSTOM_GETTER(jsRequireExtensionsGetter, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) { - Zig::GlobalObject* thisObject = uncheckedDowncast(globalObject); + // `globalObject` can be a node:vm context. See jsRequireCacheGetter. + Zig::GlobalObject* thisObject = defaultGlobalObject(globalObject); return JSValue::encode(thisObject->lazyRequireExtensionsObject()); } @@ -478,8 +481,8 @@ JSC_DEFINE_CUSTOM_SETTER(jsRequireExtensionsSetter, if (!thisObject) return false; - thisObject->putDirect(globalObject->vm(), propertyName, JSValue::decode(value), 0); - return true; + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + RELEASE_AND_RETURN(scope, Bun::defineOwnDataProperty(globalObject, thisObject, propertyName, JSValue::decode(value), 0)); } static const HashTableValue RequireResolveFunctionPrototypeValues[] = { @@ -899,11 +902,10 @@ class JSCommonJSModulePrototype final : public JSC::JSNonFinalObject { using Base = JSC::JSNonFinalObject; static JSCommonJSModulePrototype* create( JSC::VM& vm, - JSC::JSGlobalObject* globalObject, JSC::Structure* structure) { JSCommonJSModulePrototype* prototype = new (NotNull, Bun::allocatePlainObjectCell(vm, sizeof(JSCommonJSModulePrototype))) JSCommonJSModulePrototype(vm, structure); - prototype->finishCreation(vm, globalObject); + prototype->finishCreation(vm); return prototype; } @@ -933,24 +935,11 @@ class JSCommonJSModulePrototype final : public JSC::JSNonFinalObject { return &vm.plainObjectSpace(); } - void finishCreation(JSC::VM& vm, JSC::JSGlobalObject* globalObject) + void finishCreation(JSC::VM& vm) { Base::finishCreation(vm); ASSERT(inherits(info())); Bun::reifyStaticPropertyTable(vm, info(), JSCommonJSModulePrototypeTableValues, *this); - - this->putDirectNativeFunction( - vm, - globalObject, - clientData(vm)->builtinNames().requirePrivateName(), - 2, - jsFunctionRequireCommonJS, ImplementationVisibility::Public, NoIntrinsic, JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete); - this->putDirectNativeFunction( - vm, - globalObject, - clientData(vm)->builtinNames().requireNativeModulePrivateName(), - 0, - jsFunctionRequireNativeModule, ImplementationVisibility::Public, NoIntrinsic, JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete); } }; @@ -968,7 +957,7 @@ JSC::Structure* JSCommonJSModule::createStructure( { auto& vm = JSC::getVM(globalObject); - auto* prototype = JSCommonJSModulePrototype::create(vm, globalObject, JSCommonJSModulePrototype::createStructure(vm, globalObject, globalObject->objectPrototype())); + auto* prototype = JSCommonJSModulePrototype::create(vm, JSCommonJSModulePrototype::createStructure(vm, globalObject, globalObject->objectPrototype())); // Do not set the number of inline properties on this structure // there may be an off-by-one error in the Structure which causes `require.id` to become the require @@ -1132,7 +1121,7 @@ void populateESMExports( PropertySlot slot(exports, PropertySlot::InternalMethodType::VMInquiry, &vm); auto has = exports->getPropertySlot(globalObject, esModuleMarker, slot); RETURN_IF_EXCEPTION(scope, ); - if (has) { + if (has && !slot.isAccessor()) { JSValue value = slot.getValue(globalObject, esModuleMarker); CLEAR_IF_EXCEPTION(scope); if (!value.isUndefinedOrNull()) { @@ -1387,7 +1376,7 @@ const JSC::ClassInfo JSCommonJSModule::s_info = { "Module"_s, &Base::s_info, nul const JSC::ClassInfo RequireResolveFunctionPrototype::s_info = { "resolve"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(RequireResolveFunctionPrototype) }; const JSC::ClassInfo RequireFunctionPrototype::s_info = { "require"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(RequireFunctionPrototype) }; -ALWAYS_INLINE EncodedJSValue finishRequireWithError(Zig::GlobalObject* globalObject, JSCommonJSModule* referrerModule, JSC::ThrowScope& throwScope, JSC::JSValue specifierValue) +ALWAYS_INLINE EncodedJSValue finishRequireWithError(Zig::GlobalObject* globalObject, JSCommonJSModule* child, JSC::ThrowScope& throwScope, JSC::JSValue specifierValue) { auto& vm = JSC::getVM(globalObject); JSC::JSValue exception = throwScope.exception(); @@ -1401,7 +1390,7 @@ ALWAYS_INLINE EncodedJSValue finishRequireWithError(Zig::GlobalObject* globalObj // On error, remove the module from the require map // so that it can be re-evaluated on the next require. // The entry can already be gone: `delete require.cache[__filename]; throw ...`, or a graph's dispose(). - requireMapOf(globalObject, referrerModule->moduleGraph())->remove(globalObject, specifierValue); + requireMapOf(globalObject, child->moduleGraph())->remove(globalObject, specifierValue); RETURN_IF_EXCEPTION(throwScope, {}); throwScope.throwException(globalObject, exception); @@ -1409,30 +1398,25 @@ ALWAYS_INLINE EncodedJSValue finishRequireWithError(Zig::GlobalObject* globalObj } #define REQUIRE_CJS_RETURN_IF_EXCEPTION \ if (throwScope.exception()) [[unlikely]] \ - return finishRequireWithError(globalObject, referrerModule, throwScope, specifierValue) + return finishRequireWithError(globalObject, child, throwScope, specifierValue) -// JSCommonJSModule.$require(resolvedId, newModule, userArgumentCount, userOptions) +// $requireCommonJS(resolvedId, newModule, parentFilename, userArgumentCount, userOptions) JSC_DEFINE_HOST_FUNCTION(jsFunctionRequireCommonJS, (JSGlobalObject * lexicalGlobalObject, CallFrame* callframe)) { auto* globalObject = uncheckedDowncast(lexicalGlobalObject); auto& vm = JSC::getVM(globalObject); auto throwScope = DECLARE_THROW_SCOPE(vm); - ASSERT(callframe->argumentCount() == 4); - // If overriddenRequire is called with invalid this, execution could potentially reach here. - JSCommonJSModule* referrerModule = dynamicDowncast(callframe->thisValue()); - if (!referrerModule) - return throwVMTypeError(globalObject, throwScope); + ASSERT(callframe->argumentCount() == 5); + // This is always a new JSCommonJSModule in its parent's Bun.ModuleGraph, so the cast cannot fail. + JSCommonJSModule* child = uncheckedDowncast(callframe->uncheckedArgument(1)); JSValue specifierValue = callframe->uncheckedArgument(0); // If Module._resolveFilename is overridden, this could cause this to be a non-string WTF::String specifier = specifierValue.toWTFString(globalObject); REQUIRE_CJS_RETURN_IF_EXCEPTION; // If this.filename is overridden, this could cause this to be a non-string - WTF::String referrer = referrerModule->filename().toWTFString(globalObject); + WTF::String referrer = callframe->uncheckedArgument(2).toWTFString(globalObject); REQUIRE_CJS_RETURN_IF_EXCEPTION; - // This is always a new JSCommonJSModule object; cast cannot fail. - JSCommonJSModule* child = uncheckedDowncast(callframe->uncheckedArgument(1)); - BunString referrerStr = Bun::toString(referrer); BunString typeAttributeStr = { BunStringTag::Dead }; String typeAttribute = String(); @@ -1440,10 +1424,10 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionRequireCommonJS, (JSGlobalObject * lexicalGlo // We need to be able to wire in the "type" import attribute from bundled code.. // So we do it via CommonJS require(). // $argumentCount() always returns a Int32 JSValue - int32_t userArgumentCount = callframe->argument(2).asInt32(); + int32_t userArgumentCount = callframe->argument(3).asInt32(); // If they called require(id), skip the check for the type attribute if (userArgumentCount >= 2) [[unlikely]] { - JSValue options = callframe->uncheckedArgument(3); + JSValue options = callframe->uncheckedArgument(4); if (options.isObject()) { JSObject* obj = options.getObject(); // This getter is expensive and rare. @@ -1480,10 +1464,6 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionRequireNativeModule, (JSGlobalObject * lexica auto& vm = JSC::getVM(globalObject); auto throwScope = DECLARE_THROW_SCOPE(vm); - JSCommonJSModule* thisObject = dynamicDowncast(callframe->thisValue()); - if (!thisObject) - return throwVMTypeError(globalObject, throwScope); - JSValue specifierValue = callframe->argument(0); WTF::String specifier = specifierValue.toWTFString(globalObject); RETURN_IF_EXCEPTION(throwScope, {}); @@ -1564,13 +1544,9 @@ void JSCommonJSModule::evaluateWithPotentiallyOverriddenCompile( if (JSValue compileFunction = this->m_overriddenCompile.get()) { auto& vm = globalObject->vm(); auto scope = DECLARE_THROW_SCOPE(vm); - if (!compileFunction) { - throwTypeError(globalObject, scope, "overridden module._compile is not a function (called from overridden Module._extensions)"_s); - return; - } - JSC::CallData callData = JSC::getCallData(compileFunction.asCell()); + JSC::CallData callData = JSC::getCallData(compileFunction); if (callData.type == JSC::CallData::Type::None) { - throwTypeError(globalObject, scope, "overridden module._compile is not a function (called from overridden Module._extensions)"_s); + throwTypeError(globalObject, scope, "module._compile is not a function"_s); return; } WTF::String sourceString = source.source_code.transferToWTFString(); diff --git a/src/jsc/bindings/JSCommonJSModule.h b/src/jsc/bindings/JSCommonJSModule.h index 713000b34f30..8a9e31d6011f 100644 --- a/src/jsc/bindings/JSCommonJSModule.h +++ b/src/jsc/bindings/JSCommonJSModule.h @@ -28,6 +28,8 @@ static constexpr ASCIILiteral commonJSDefaultWrapperEnd = "})"_s; JSC_DECLARE_HOST_FUNCTION(jsFunctionCreateCommonJSModule); JSC_DECLARE_HOST_FUNCTION(jsFunctionEvaluateCommonJSModule); +JSC_DECLARE_HOST_FUNCTION(jsFunctionRequireCommonJS); +JSC_DECLARE_HOST_FUNCTION(jsFunctionRequireNativeModule); JSC_DECLARE_HOST_FUNCTION(functionJSCommonJSModule_compile); void populateESMExports( diff --git a/src/jsc/bindings/JSDOMExceptionHandling.cpp b/src/jsc/bindings/JSDOMExceptionHandling.cpp index c27a93221109..c6d927a5ceea 100644 --- a/src/jsc/bindings/JSDOMExceptionHandling.cpp +++ b/src/jsc/bindings/JSDOMExceptionHandling.cpp @@ -58,17 +58,12 @@ void reportException(JSGlobalObject* lexicalGlobalObject, JSC::Exception* except (void)scope.tryClearException(); vm.clearLastException(); - auto* globalObject = uncheckedDowncast(lexicalGlobalObject); - // if (auto* window = dynamicDowncast( globalObject)) { - // if (!window->wrapped().isCurrentlyDisplayedInFrame()) - // return; - // } - int lineNumber = 0; int columnNumber = 0; String exceptionSourceURL; - Zig::GlobalObject::reportUncaughtExceptionAtEventLoop(globalObject, exception); + // The listener's realm can be a node:vm context, which isn't a Zig::GlobalObject. + Zig::GlobalObject::reportUncaughtExceptionAtEventLoop(lexicalGlobalObject, exception); RETURN_IF_EXCEPTION(scope, ); if (exceptionDetails) { diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.cpp b/src/jsc/bindings/JSEnvironmentVariableMap.cpp index 38d46e108c62..3f7172c3bb7a 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.cpp +++ b/src/jsc/bindings/JSEnvironmentVariableMap.cpp @@ -192,6 +192,9 @@ bool JSEnvironmentVariableMap::put(JSCell* cell, JSGlobalObject* globalObject, P if (propertyName.publicName() && propertyName.publicName()->isEmpty()) return true; + // A cached put would store the raw value and skip this function. Inline caches also can't handle a toString() that adds or deletes a key. + slot.disableCaching(); + JSString* string = coerceEnvValue(globalObject, scope, value); RETURN_IF_EXCEPTION(scope, false); @@ -842,7 +845,9 @@ bool JSSharedEnvMap::defineOwnProperty(JSObject* object, JSGlobalObject* globalO if (!existing.isNull()) { syncWindowsEnv(store, String(uid), nullptr); store->remove(String(uid)); - object->putDirect(vm, propertyName, jsString(vm, existing), 0); + // An env var name can be an array index. + object->putDirectMayBeIndex(globalObject, propertyName, jsString(vm, existing)); + RETURN_IF_EXCEPTION(scope, false); } } } @@ -921,6 +926,7 @@ RefPtr ensureSharedEnvStoreForWorker(Zig::GlobalObject* globalOb // Founding a new tree. processEnvObject() forces the lazy init so the OS // environment is captured before the swap below. JSObject* envObject = globalObject->processEnvObject(); + RETURN_IF_EXCEPTION(scope, nullptr); if (!envObject->staticPropertiesReified()) { envObject->reifyAllStaticProperties(globalObject); RETURN_IF_EXCEPTION(scope, nullptr); @@ -976,7 +982,7 @@ RefPtr ensureSharedEnvStoreForWorker(Zig::GlobalObject* globalOb return store; } -JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) +JSObject* createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) { VM& vm = globalObject->vm(); auto scope = DECLARE_THROW_SCOPE(vm); @@ -995,7 +1001,7 @@ JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) } JSArray* keyArray = constructEmptyArray(globalObject, nullptr, count); - RETURN_IF_EXCEPTION(scope, {}); + RETURN_IF_EXCEPTION(scope, nullptr); #else auto* structure = JSEnvironmentVariableMap::createStructure(vm, globalObject, globalObject->objectPrototype()); JSC::JSObject* object = JSEnvironmentVariableMap::create(vm, structure); @@ -1016,7 +1022,9 @@ JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) // We can't really trust that the OS gives us valid UTF-8 auto name = String::fromUTF8ReplacingInvalidSequences(std::span { chars, len }); #if OS(WINDOWS) - keyArray->putByIndexInline(globalObject, (unsigned)i, jsString(vm, name), false); + // Avoid [[Set]], which can run an indexed setter on Object.prototype while `list` points into the env table. + keyArray->putDirectIndex(globalObject, (unsigned)i, jsString(vm, name)); + RETURN_IF_EXCEPTION(scope, nullptr); #endif if (name == TZ) { hasTZ = true; @@ -1041,14 +1049,16 @@ JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) // CustomGetterSetter doesn't support indexed properties yet. // This causes strange issues when the environment variable name is an integer. if (chars[0] >= '0' && chars[0] <= '9') [[unlikely]] { - if (auto index = parseIndex(identifier)) { + if (parseIndex(identifier)) { EncodedSlice valueString = { nullptr, 0 }; EncodedSlice nameStr = toEncodedSlice(name); if (Bun__getEnvValue(globalObject, &nameStr, &valueString)) { JSValue value = jsString(vm, Zig::toStringCopy(valueString)); - RETURN_IF_EXCEPTION(scope, {}); - object->putDirectIndex(globalObject, *index, value, 0, PutDirectIndexLikePutDirect); - RETURN_IF_EXCEPTION(scope, {}); + RETURN_IF_EXCEPTION(scope, nullptr); + // putDirectIndex falls back to [[Set]] on this exotic object, which can run an indexed setter on Object.prototype. + PropertyDescriptor descriptor(value, 0); + JSObject::defineOwnProperty(object, globalObject, identifier, descriptor, false); + RETURN_IF_EXCEPTION(scope, nullptr); } continue; } @@ -1089,25 +1099,18 @@ JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) auto editWindowsEnvVar = JSC::JSFunction::create(vm, globalObject, 0, String("editWindowsEnvVar"_s), jsEditWindowsEnvVar, ImplementationVisibility::Public); JSC::JSFunction* getSourceEvent = JSC::JSFunction::create(vm, globalObject, processObjectInternalsWindowsEnvCodeGenerator(vm), globalObject); - RETURN_IF_EXCEPTION(scope, {}); + RETURN_IF_EXCEPTION(scope, nullptr); JSC::MarkedArgumentBuffer args; args.append(object); args.append(keyArray); args.append(editWindowsEnvVar); args.append(JSC::JSFunction::create(vm, globalObject, 2, "coerceForWrite"_s, jsProcessEnvCoerceForWrite, ImplementationVisibility::Private)); args.append(JSC::JSFunction::create(vm, globalObject, 1, "resetForDelete"_s, jsProcessEnvResetForDelete, ImplementationVisibility::Private)); - auto clientData = WebCore::clientData(vm); JSC::CallData callData = JSC::getCallData(getSourceEvent); - NakedPtr returnedException = nullptr; - auto result = JSC::profiledCall(globalObject, JSC::ProfilingReason::API, getSourceEvent, callData, globalObject->globalThis(), args, returnedException); - RETURN_IF_EXCEPTION(scope, {}); - - if (returnedException) { - throwException(globalObject, scope, returnedException.get()); - return jsUndefined(); - } - - RELEASE_AND_RETURN(scope, result); + JSValue result = JSC::profiledCall(globalObject, JSC::ProfilingReason::API, getSourceEvent, callData, globalObject->globalThis(), args); + RETURN_IF_EXCEPTION(scope, nullptr); + // windowsEnv returns `new Proxy(...)`. + return asObject(result); #else return object; #endif diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.h b/src/jsc/bindings/JSEnvironmentVariableMap.h index c83ce7134246..eba84b418261 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.h +++ b/src/jsc/bindings/JSEnvironmentVariableMap.h @@ -18,7 +18,8 @@ namespace Bun { class JSEnvironmentVariableMap final : public JSC::JSNonFinalObject { public: using Base = JSC::JSNonFinalObject; - static constexpr unsigned StructureFlags = Base::StructureFlags | JSC::OverridesPut; + // The second flag keeps indexed properties in the sparse map, so every indexed store reaches putByIndex(). + static constexpr unsigned StructureFlags = Base::StructureFlags | JSC::OverridesPut | JSC::InterceptsGetOwnPropertySlotByIndexEvenWhenLengthIsNotZero; static JSEnvironmentVariableMap* create(JSC::VM& vm, JSC::Structure* structure) { @@ -53,7 +54,8 @@ class JSEnvironmentVariableMap final : public JSC::JSNonFinalObject { } }; -JSC::JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject); +// Returns null with an exception pending if it throws. On Windows, JS finishes building the map. +JSC::JSObject* createEnvironmentVariablesMap(Zig::GlobalObject* globalObject); // Setting TZ must make *existing* Date instances recompute local time. JSC's DateCache // reset only clears shared slots; live DateInstances keep their own cached fields diff --git a/src/jsc/bindings/JSFFIFunction.cpp b/src/jsc/bindings/JSFFIFunction.cpp index 314e5c9e6e25..0f8dcaaeff3a 100644 --- a/src/jsc/bindings/JSFFIFunction.cpp +++ b/src/jsc/bindings/JSFFIFunction.cpp @@ -26,8 +26,11 @@ #include "root.h" #include "JSFFIFunction.h" +#include #include +#include #include +#include "ZigGeneratedClasses.h" #include "ZigGlobalObject.h" #include @@ -82,6 +85,42 @@ extern "C" JSC::EncodedJSValue Bun__CreateFFIFunctionValue(Zig::GlobalObject* gl return Bun__CreateFFIFunctionWithDataValue(globalObject, symbolName, argCount, functionPointer, nullptr); } +// Returns true if one of the functions is still on the stack, in which case the caller must not free the compiled code. +extern "C" bool Bun__FFI__closeFunctions(Zig::GlobalObject* globalObject, JSC::EncodedJSValue libraryValue) +{ + auto* library = dynamicDowncast(JSC::JSValue::decode(libraryValue)); + if (!library) + return false; + JSC::JSValue functionsValue = library->m_functionsValue.get(); + auto* functions = functionsValue ? dynamicDowncast(functionsValue) : nullptr; + if (!functions) + return false; + const unsigned length = functions->length(); + for (unsigned i = 0; i < length; ++i) { + if (auto* function = dynamicDowncast(functions->getIndexQuickly(i))) + function->close(); + } + + auto& vm = JSC::getVM(globalObject); + bool isRunning = false; + JSC::StackVisitor::visit(vm.topCallFrame, vm, [&](JSC::StackVisitor& visitor) -> WTF::IterationStatus { + JSC::CalleeBits callee = visitor->callee(); + if (!callee.isCell() || !callee.asCell()) + return WTF::IterationStatus::Continue; + JSC::JSValue calleeValue = callee.asCell(); + for (unsigned i = 0; i < length; ++i) { + if (functions->getIndexQuickly(i) == calleeValue) { + isRunning = true; + return WTF::IterationStatus::Done; + } + } + return WTF::IterationStatus::Continue; + }); + + library->m_functionsValue.clear(); + return isRunning; +} + namespace Zig { using namespace JSC; @@ -107,30 +146,37 @@ DEFINE_VISIT_CHILDREN(JSFFIFunction); JSFFIFunction* JSFFIFunction::create(VM& vm, Zig::GlobalObject* globalObject, unsigned length, const String& name, FFIFunction FFIFunction, Intrinsic intrinsic, NativeFunction nativeConstructor) { - NativeExecutable* executable = vm.getHostFunction(FFIFunction, ImplementationVisibility::Public, intrinsic, FFIFunction, nullptr, length, name); + NativeExecutable* executable = vm.getHostFunction(FFIFunction, ImplementationVisibility::Public, intrinsic, nativeConstructor, nullptr, length, name); Structure* structure = globalObject->FFIFunctionStructure(); JSFFIFunction* function = new (NotNull, allocateCell(vm)) JSFFIFunction(vm, executable, globalObject, structure, reinterpret_cast(WTF::move(FFIFunction))); function->finishCreation(vm); return function; } -#if OS(WINDOWS) - +// Reads m_function on every call, so close() affects all callers. On Windows x64 this also bridges SYSV_ABI to the MS ABI. JSC_DEFINE_HOST_FUNCTION(JSFFIFunction::trampoline, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { const auto* function = uncheckedDowncast(callFrame->jsCallee()); return function->function()(globalObject, callFrame); } -#endif +// Replaces the compiled code after close(), so it uses CFFIFunction's ABI, not a host function's. +static JSC::EncodedJSValue closedLibraryFunction(JSC::JSGlobalObject* globalObject, JSC::CallFrame* callFrame) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* function = uncheckedDowncast(callFrame->jsCallee()); + return JSC::throwVMTypeError(globalObject, scope, makeString("bun:ffi: cannot call '"_s, function->name(vm), "' because its library was closed"_s)); +} + +void JSFFIFunction::close() +{ + m_function = closedLibraryFunction; +} JSFFIFunction* JSFFIFunction::createForFFI(VM& vm, Zig::GlobalObject* globalObject, unsigned length, const String& name, CFFIFunction FFIFunction) { -#if OS(WINDOWS) - NativeExecutable* executable = vm.getHostFunction(trampoline, ImplementationVisibility::Public, NoIntrinsic, trampoline, nullptr, length, name); -#else - NativeExecutable* executable = vm.getHostFunction(FFIFunction, ImplementationVisibility::Public, NoIntrinsic, FFIFunction, nullptr, length, name); -#endif + NativeExecutable* executable = vm.getHostFunction(trampoline, ImplementationVisibility::Public, NoIntrinsic, callHostFunctionAsConstructor, nullptr, length, name); Structure* structure = globalObject->FFIFunctionStructure(); JSFFIFunction* function = new (NotNull, allocateCell(vm)) JSFFIFunction(vm, executable, globalObject, structure, reinterpret_cast(WTF::move(FFIFunction))); function->finishCreation(vm); diff --git a/src/jsc/bindings/JSFFIFunction.h b/src/jsc/bindings/JSFFIFunction.h index 05318ca9ae67..8a54bb73c6df 100644 --- a/src/jsc/bindings/JSFFIFunction.h +++ b/src/jsc/bindings/JSFFIFunction.h @@ -75,12 +75,11 @@ class JSFFIFunction final : public JSC::JSFunction { const CFFIFunction function() const { return m_function; } -#if OS(WINDOWS) + // Only valid for a function created by createForFFI. Calling it after close() throws a TypeError. + void close(); static JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES trampoline(JSGlobalObject* globalObject, CallFrame* callFrame); -#endif - void* dataPtr; void* symbolFromDynamicLibrary { nullptr }; diff --git a/src/jsc/bindings/JSMockFunction.cpp b/src/jsc/bindings/JSMockFunction.cpp index 5c09121b9372..da1faeea8041 100644 --- a/src/jsc/bindings/JSMockFunction.cpp +++ b/src/jsc/bindings/JSMockFunction.cpp @@ -1444,6 +1444,22 @@ extern "C" [[ZIG_EXPORT(nothrow)]] double JSMock__getCurrentUnixTimeMs() return WTF::WallTime::now().secondsSinceEpoch().milliseconds(); } +extern "C" [[ZIG_EXPORT(nothrow)]] JSC::EncodedJSValue JSMock__toThisStrict(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue thisValue) +{ + return JSValue::encode(JSValue::decode(thisValue).toThis(globalObject, ECMAMode::strict())); +} + +// `target` is whatever `globalThis.setTimeout` is set to, so it can be any object. It's fine if it rejects the marker. +extern "C" [[ZIG_EXPORT(check_slow)]] void JSMock__defineFakeTimersMarker(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue encodedTarget) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSObject* target = asObject(JSValue::decode(encodedTarget)); + target->createDataProperty(globalObject, Identifier::fromString(vm, "clock"_s), jsBoolean(true), false); + RETURN_IF_EXCEPTION(scope, ); +} + BUN_DEFINE_HOST_FUNCTION(JSMock__jsNow, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) { return JSValue::encode(jsNumber(globalObject->jsDateNow())); @@ -1474,7 +1490,7 @@ BUN_DEFINE_HOST_FUNCTION(JSMock__jsSetSystemTime, (JSC::JSGlobalObject * globalO // from this value instead of the activation-time clock. Bun__FakeTimers__setSystemTime(globalObject, ms); - return JSValue::encode(callframe->thisValue()); + return JSValue::encode(callframe->thisValue().toThis(globalObject, ECMAMode::strict())); } BUN_DEFINE_HOST_FUNCTION(JSMock__jsRestoreAllMocks, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) @@ -1528,6 +1544,12 @@ BUN_DEFINE_HOST_FUNCTION(JSMock__jsSpyOn, (JSC::JSGlobalObject * lexicalGlobalOb if (object->type() == JSC::JSType::GlobalProxyType) object = uncheckedDowncast(object)->target(); + // A WebAssembly GC object's Structure can't change, so the direct stores below would abort. + if (object->type() == JSC::JSType::WebAssemblyGCObjectType) { + throwTypeError(globalObject, scope, "spyOn(target, prop) does not support WebAssembly GC objects"_s); + return {}; + } + JSC::PropertySlot slot(object, JSC::PropertySlot::InternalMethodType::HasProperty); bool hasValue = object->getPropertySlot(globalObject, propertyKey, slot); RETURN_IF_EXCEPTION(scope, {}); diff --git a/src/jsc/bindings/JSNodePerformanceHooksHistogramPrototype.cpp b/src/jsc/bindings/JSNodePerformanceHooksHistogramPrototype.cpp index ef6ee132d587..5dfb105deb5e 100644 --- a/src/jsc/bindings/JSNodePerformanceHooksHistogramPrototype.cpp +++ b/src/jsc/bindings/JSNodePerformanceHooksHistogramPrototype.cpp @@ -424,26 +424,15 @@ JSC_DEFINE_HOST_FUNCTION(jsFunction_createHistogram, (JSGlobalObject * globalObj } // Extern declarations for the native timer implementation -extern "C" void Timer_enableEventLoopDelayMonitoring(void* vm, JSC::EncodedJSValue histogram, int32_t resolution); +extern "C" void Timer_enableEventLoopDelayMonitoring(void* vm, JSC::EncodedJSValue histogram, double resolution); extern "C" void Timer_disableEventLoopDelayMonitoring(); // Create histogram for event loop delay monitoring -JSC_DEFINE_HOST_FUNCTION(jsFunction_monitorEventLoopDelay, (JSGlobalObject * globalObject, CallFrame* callFrame)) +JSC_DEFINE_HOST_FUNCTION(jsFunction_monitorEventLoopDelay, (JSGlobalObject * globalObject, CallFrame*)) { VM& vm = globalObject->vm(); auto scope = DECLARE_THROW_SCOPE(vm); - int32_t resolution = 10; // default 10ms - if (callFrame->argumentCount() > 0) { - resolution = callFrame->argument(0).toInt32(globalObject); - RETURN_IF_EXCEPTION(scope, {}); - - if (resolution < 1) { - throwRangeError(globalObject, scope, "Resolution must be >= 1"_s); - return JSValue::encode(jsUndefined()); - } - } - // Create histogram with range for event loop delays (1ns to 1 hour) auto* zigGlobalObject = defaultGlobalObject(globalObject); Structure* structure = zigGlobalObject->m_JSNodePerformanceHooksHistogramClassStructure.get(zigGlobalObject); @@ -480,7 +469,7 @@ JSC_DEFINE_HOST_FUNCTION(jsFunction_enableEventLoopDelay, (JSGlobalObject * glob return JSValue::encode(jsUndefined()); } - int32_t resolution = callFrame->argument(1).toInt32(globalObject); + double resolution = callFrame->argument(1).toNumber(globalObject); RETURN_IF_EXCEPTION(scope, {}); // Reset histogram data on enable diff --git a/src/jsc/bindings/JSPropertyIterator.cpp b/src/jsc/bindings/JSPropertyIterator.cpp index 7df4acb78ac4..c0222028c8bb 100644 --- a/src/jsc/bindings/JSPropertyIterator.cpp +++ b/src/jsc/bindings/JSPropertyIterator.cpp @@ -60,24 +60,22 @@ extern "C" JSPropertyIterator* Bun__JSPropertyIterator__create(JSC::JSGlobalObje if (object->type() == JSC::ProxyObjectType) [[unlikely]] { // Check if we're actually iterating through the JSEnvironmentVariableMap's proxy. auto* zigGlobal = defaultGlobalObject(globalObject); - if (zigGlobal->m_processEnvObject.isInitialized()) { - if (object == zigGlobal->m_processEnvObject.get(zigGlobal)) { - object->methodTable()->getOwnPropertyNames( - object, - globalObject, - array, - DontEnumPropertiesMode::Exclude); - RETURN_IF_EXCEPTION(scope, nullptr); - - *count = array.size(); - if (array.size() == 0) { - return nullptr; - } - - auto* iter = JSPropertyIterator::create(vm, array.releaseData()); - iter->isSpecialProxy = true; - return iter; + if (object == zigGlobal->m_processEnvObject.get()) { + object->methodTable()->getOwnPropertyNames( + object, + globalObject, + array, + DontEnumPropertiesMode::Exclude); + RETURN_IF_EXCEPTION(scope, nullptr); + + *count = array.size(); + if (array.size() == 0) { + return nullptr; } + + auto* iter = JSPropertyIterator::create(vm, array.releaseData()); + iter->isSpecialProxy = true; + return iter; } } #endif diff --git a/src/jsc/bindings/JSSocketAddressDTO.cpp b/src/jsc/bindings/JSSocketAddressDTO.cpp index 2bf085601822..f85e35e45424 100644 --- a/src/jsc/bindings/JSSocketAddressDTO.cpp +++ b/src/jsc/bindings/JSSocketAddressDTO.cpp @@ -82,3 +82,28 @@ extern "C" JSC::EncodedJSValue JSSocketAddressDTO__create(JSGlobalObject* global return JSValue::encode(thisObject); } + +// `out` is a user-provided object, so use [[Set]] for each field. Like Node's AddressToJS, ignore a store that `out` rejects. +extern "C" [[ZIG_EXPORT(false_is_throw)]] bool JSSocketAddressDTO__assign(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue encodedOut, JSC::EncodedJSValue address, JSC::EncodedJSValue port, bool isIPv6) +{ + VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSObject* out = asObject(JSValue::decode(encodedOut)); + auto& commonStrings = Bun::commonStrings(vm); + JSValue family = isIPv6 ? commonStrings.IPv6String() : commonStrings.IPv4String(); + + PutPropertySlot familySlot(out, false); + out->methodTable()->put(out, globalObject, Identifier::fromString(vm, "family"_s), family, familySlot); + RETURN_IF_EXCEPTION(scope, false); + + PutPropertySlot addressSlot(out, false); + out->methodTable()->put(out, globalObject, Identifier::fromString(vm, "address"_s), JSValue::decode(address), addressSlot); + RETURN_IF_EXCEPTION(scope, false); + + PutPropertySlot portSlot(out, false); + out->methodTable()->put(out, globalObject, WebCore::builtinNames(vm).portPublicName(), JSValue::decode(port), portSlot); + RETURN_IF_EXCEPTION(scope, false); + + return true; +} diff --git a/src/jsc/bindings/JSStringDecoder.cpp b/src/jsc/bindings/JSStringDecoder.cpp index 903c326d2934..102199fc6454 100644 --- a/src/jsc/bindings/JSStringDecoder.cpp +++ b/src/jsc/bindings/JSStringDecoder.cpp @@ -11,6 +11,7 @@ #include "JSBufferEncodingType.h" #include #include "BunClientData.h" +#include "ObjectBindings.h" #include "wtf/text/ASCIILiteral.h" #include "wtf/text/StringImpl.h" #include "wtf/unicode/CharacterNames.h" @@ -420,6 +421,11 @@ static inline JSC::EncodedJSValue jsStringDecoderPrototypeFunction_writeBody(JSC return Bun::ERR::INVALID_ARG_TYPE(throwScope, lexicalGlobalObject, "buf"_s, "Buffer, TypedArray, or DataView"_s, buffer); } + if (view->isShared()) [[unlikely]] { + auto copy = Bun::copySharedBytes(lexicalGlobalObject, throwScope, view->span()); + RETURN_IF_EXCEPTION(throwScope, {}); + RELEASE_AND_RETURN(throwScope, JSC::JSValue::encode(castedThis->write(vm, lexicalGlobalObject, copy.mutableSpan().data(), copy.sizeInBytes()))); + } RELEASE_AND_RETURN(throwScope, JSC::JSValue::encode(castedThis->write(vm, lexicalGlobalObject, reinterpret_cast(view->vector()), view->byteLength()))); } static inline JSC::EncodedJSValue jsStringDecoderPrototypeFunction_endBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, JSStringDecoder* castedThis) @@ -436,6 +442,11 @@ static inline JSC::EncodedJSValue jsStringDecoderPrototypeFunction_endBody(JSC:: throwVMTypeError(lexicalGlobalObject, throwScope, "Expected Uint8Array"_s); return {}; } + if (view->isShared()) [[unlikely]] { + auto copy = Bun::copySharedBytes(lexicalGlobalObject, throwScope, view->span()); + RETURN_IF_EXCEPTION(throwScope, {}); + RELEASE_AND_RETURN(throwScope, JSC::JSValue::encode(castedThis->end(vm, lexicalGlobalObject, copy.mutableSpan().data(), copy.sizeInBytes()))); + } RELEASE_AND_RETURN(throwScope, JSC::JSValue::encode(castedThis->end(vm, lexicalGlobalObject, reinterpret_cast(view->vector()), view->byteLength()))); } static inline JSC::EncodedJSValue jsStringDecoderPrototypeFunction_textBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, JSStringDecoder* castedThis) @@ -458,6 +469,11 @@ static inline JSC::EncodedJSValue jsStringDecoderPrototypeFunction_textBody(JSC: uint32_t byteLength = view->byteLength(); if (offset < 0 || static_cast(offset) > byteLength) RELEASE_AND_RETURN(throwScope, JSC::JSValue::encode(JSC::jsEmptyString(vm))); + if (view->isShared()) [[unlikely]] { + auto copy = Bun::copySharedBytes(lexicalGlobalObject, throwScope, view->span().subspan(offset, byteLength - offset)); + RETURN_IF_EXCEPTION(throwScope, {}); + RELEASE_AND_RETURN(throwScope, JSC::JSValue::encode(castedThis->write(vm, lexicalGlobalObject, copy.mutableSpan().data(), copy.sizeInBytes()))); + } RELEASE_AND_RETURN(throwScope, JSC::JSValue::encode(castedThis->write(vm, lexicalGlobalObject, reinterpret_cast(view->vector()) + offset, byteLength - offset))); } @@ -613,8 +629,10 @@ JSC::EncodedJSValue JSStringDecoderConstructor::call(JSC::JSGlobalObject* lexica auto clientData = WebCore::clientData(vm); JSObject* thisObject = asObject(thisValue); - thisObject->putDirect(vm, clientData->builtinNames().decodePrivateName(), jsObject, JSC::PropertyAttribute::DontEnum | 0); - thisObject->putDirect(vm, clientData->builtinNames().encodingPublicName(), convertEnumerationToJS(*lexicalGlobalObject, encoding), JSC::PropertyAttribute::DontEnum | 0); + Bun::defineOwnDataProperty(lexicalGlobalObject, thisObject, clientData->builtinNames().encodingPublicName(), convertEnumerationToJS(*lexicalGlobalObject, encoding), JSC::PropertyAttribute::DontEnum | 0); + RETURN_IF_EXCEPTION(throwScope, {}); + Bun::defineOwnDataProperty(lexicalGlobalObject, thisObject, clientData->builtinNames().decodePrivateName(), jsObject, JSC::PropertyAttribute::DontEnum | 0); + RETURN_IF_EXCEPTION(throwScope, {}); return JSC::JSValue::encode(thisObject); } diff --git a/src/jsc/bindings/JSWrappingFunction.cpp b/src/jsc/bindings/JSWrappingFunction.cpp index 57f53d111b23..eedd5538edb8 100644 --- a/src/jsc/bindings/JSWrappingFunction.cpp +++ b/src/jsc/bindings/JSWrappingFunction.cpp @@ -27,11 +27,10 @@ JS_EXPORT_PRIVATE JSWrappingFunction* JSWrappingFunction::create( JSC::JSObject* wrappedFn = wrappedFnValue.getObject(); ASSERT(wrappedFn != nullptr); - auto nameStr = symbolName->tag == BunStringTag::Empty ? WTF::emptyString() : symbolName->toWTFString(); - auto name = Identifier::fromString(vm, nameStr); + auto name = Bun::toIdentifier(vm, *symbolName); // Pass callHostFunctionAsConstructor so `new` on the wrapper throws a // TypeError instead of jumping to a null native constructor. - NativeExecutable* executable = vm.getHostFunction(functionPointer, ImplementationVisibility::Public, callHostFunctionAsConstructor, 0, nameStr); + NativeExecutable* executable = vm.getHostFunction(functionPointer, ImplementationVisibility::Public, callHostFunctionAsConstructor, 0, name.string()); // Structure* structure = globalObject->FFIFunctionStructure(); Structure* structure = JSWrappingFunction::createStructure(vm, globalObject, globalObject->objectPrototype()); diff --git a/src/jsc/bindings/JSX509Certificate.cpp b/src/jsc/bindings/JSX509Certificate.cpp index 628652c14a88..6b017d1a4261 100644 --- a/src/jsc/bindings/JSX509Certificate.cpp +++ b/src/jsc/bindings/JSX509Certificate.cpp @@ -576,12 +576,14 @@ bool JSX509Certificate::checkEmail(JSGlobalObject* globalObject, std::spanvm(); auto scope = DECLARE_THROW_SCOPE(vm); - auto result = view().checkIp(ip, 0); + // utf8() asserts on failure. A string too long to convert isn't an IP address. + auto ipUtf8 = ip.tryGetUTF8(); + auto result = ipUtf8 ? view().checkIp(ipUtf8->legacyCStringPointer(), 0) : ncrypto::X509View::CheckMatch::INVALID_NAME; return handleMatchResult(globalObject, "Invalid IP address"_s, scope, result); } diff --git a/src/jsc/bindings/JSX509Certificate.h b/src/jsc/bindings/JSX509Certificate.h index bbd47e85dff7..12448a523c07 100644 --- a/src/jsc/bindings/JSX509Certificate.h +++ b/src/jsc/bindings/JSX509Certificate.h @@ -69,7 +69,7 @@ class JSX509Certificate final : public JSC::JSDestructibleObject { // can differ from the queried host (wildcard SANs, case-insensitive matches). bool checkHost(JSGlobalObject*, std::span, uint32_t flags, WTF::String* peerName = nullptr); bool checkEmail(JSGlobalObject*, std::span, uint32_t flags); - bool checkIP(JSGlobalObject*, const char*); + bool checkIP(JSGlobalObject*, WTF::StringView); bool checkIssued(JSGlobalObject*, JSX509Certificate* issuer); bool checkPrivateKey(const KeyObject&); bool verify(const KeyObject&); diff --git a/src/jsc/bindings/JSX509CertificatePrototype.cpp b/src/jsc/bindings/JSX509CertificatePrototype.cpp index aeb5ac2dcecb..fd0b1ac96372 100644 --- a/src/jsc/bindings/JSX509CertificatePrototype.cpp +++ b/src/jsc/bindings/JSX509CertificatePrototype.cpp @@ -310,13 +310,12 @@ JSC_DEFINE_HOST_FUNCTION(jsX509CertificateProtoFuncCheckIP, (JSGlobalObject * gl RETURN_IF_EXCEPTION(scope, {}); auto view = ipString->view(globalObject); RETURN_IF_EXCEPTION(scope, {}); - WTF::UTF8CString ip = view->utf8(); // ignore flags // uint32_t flags = getFlags(vm, globalObject, scope, callFrame->argument(1)); // RETURN_IF_EXCEPTION(scope, {}); - auto check = thisObject->checkIP(globalObject, ip.legacyCStringPointer()); + auto check = thisObject->checkIP(globalObject, view); RETURN_IF_EXCEPTION(scope, {}); if (!check) return JSValue::encode(jsUndefined()); return JSValue::encode(ipString); diff --git a/src/jsc/bindings/ModuleLoader.cpp b/src/jsc/bindings/ModuleLoader.cpp index e339cda8f80b..7b7335b005b3 100644 --- a/src/jsc/bindings/ModuleLoader.cpp +++ b/src/jsc/bindings/ModuleLoader.cpp @@ -220,6 +220,13 @@ OnLoadResult handleOnLoadResultNotPromise(Zig::GlobalObject* globalObject, JSC:: } if (wasModuleMock) { + if (!objectValue.isObject()) [[unlikely]] { + scope.throwException(globalObject, JSC::createTypeError(globalObject, "mock(module, fn) requires a function that returns an object"_s)); + result.value.error = scope.exception(); + (void)scope.tryClearException(); + result.type = OnLoadResultTypeError; + return result; + } result.type = OnLoadResultTypeObject; result.value.object = objectValue; return result; @@ -301,12 +308,20 @@ OnLoadResult handleOnLoadResultNotPromise(Zig::GlobalObject* globalObject, JSC:: JSC::JSString* contentsJSString = contentsValue.toStringOrNull(globalObject); RETURN_IF_EXCEPTION(scope, result); if (contentsJSString) { - result.value.sourceText.string = Zig::toEncodedSlice(contentsJSString, globalObject); + auto contents = contentsJSString->view(globalObject); RETURN_IF_EXCEPTION(scope, result); - result.value.sourceText.value = contentsValue; + result.value.sourceText.string = Zig::toEncodedSlice(contents.data); + result.value.sourceText.value = contents.owner; } } else if (JSC::JSArrayBufferView* view = dynamicDowncast(contentsValue)) { - result.value.sourceText.string = EncodedSlice { reinterpret_cast(view->vector()), view->byteLength() }; + // Copy the bytes. While the transpiler reads them, another thread can write to a SharedArrayBuffer and a macro can overwrite or detach any buffer. + if (!result.sourceTextCopy.tryAppend(view->span())) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + result.value.error = scope.exception(); + (void)scope.tryClearException(); + return result; + } + result.value.sourceText.string = EncodedSlice { result.sourceTextCopy.span().data(), result.sourceTextCopy.size() }; result.value.sourceText.value = contentsValue; } } @@ -424,15 +439,7 @@ static JSValue handleVirtualModuleResult( } } - JSC::ensureStillAliveHere(object); - auto function = generateObjectModuleSourceCode( - globalObject, - object); - auto source = JSC::SourceCode( - JSC::SyntheticSourceProvider::create(WTF::move(function), - JSC::SourceOrigin(), specifier->toWTFString(BunString::ZeroCopy))); - JSC::ensureStillAliveHere(object); - RELEASE_AND_RETURN(scope, rejectOrResolve(JSSourceCode::create(globalObject->vm(), WTF::move(source)))); + RELEASE_AND_RETURN(scope, rejectOrResolve(createObjectModuleSourceCode(globalObject, object, specifier->toWTFString(BunString::ZeroCopy)))); } case OnLoadResultTypePromise: { @@ -1070,12 +1077,7 @@ static JSValue fetchESMSourceCode( if (!value) { RELEASE_AND_RETURN(scope, reject(JSC::createSyntaxError(globalObject, "Failed to parse Object"_s))); } - auto function = generateJSValueExportDefaultObjectSourceCode(globalObject, value); - auto source = JSC::SourceCode( - JSC::SyntheticSourceProvider::create(WTF::move(function), - JSC::SourceOrigin(), WTF::move(moduleKey))); - JSC::ensureStillAliveHere(value); - RELEASE_AND_RETURN(scope, rejectOrResolve(JSSourceCode::create(vm, WTF::move(source)))); + RELEASE_AND_RETURN(scope, rejectOrResolve(createJSValueExportDefaultObjectSourceCode(globalObject, value, WTF::move(moduleKey)))); } // CommonJS modules from src/js/* @@ -1184,14 +1186,7 @@ static JSValue fetchESMSourceCode( } // JSON can become strings, null, numbers, booleans so we must handle "export default 123" - auto function = generateJSValueModuleSourceCode( - globalObject, - value); - auto source = JSC::SourceCode( - JSC::SyntheticSourceProvider::create(WTF::move(function), - JSC::SourceOrigin(), specifier->toWTFString(BunString::ZeroCopy))); - JSC::ensureStillAliveHere(value); - RELEASE_AND_RETURN(scope, rejectOrResolve(JSSourceCode::create(globalObject->vm(), WTF::move(source)))); + RELEASE_AND_RETURN(scope, rejectOrResolve(createJSValueModuleSourceCode(globalObject, value, specifier->toWTFString(BunString::ZeroCopy)))); } // TOML and JSONC may go through here else if (res->result.value.tag == SyntheticModuleType::ExportsObject) { @@ -1201,14 +1196,7 @@ static JSValue fetchESMSourceCode( } // JSON can become strings, null, numbers, booleans so we must handle "export default 123" - auto function = generateJSValueModuleSourceCode( - globalObject, - value); - auto source = JSC::SourceCode( - JSC::SyntheticSourceProvider::create(WTF::move(function), - JSC::SourceOrigin(), specifier->toWTFString(BunString::ZeroCopy))); - JSC::ensureStillAliveHere(value); - RELEASE_AND_RETURN(scope, rejectOrResolve(JSSourceCode::create(globalObject->vm(), WTF::move(source)))); + RELEASE_AND_RETURN(scope, rejectOrResolve(createJSValueModuleSourceCode(globalObject, value, specifier->toWTFString(BunString::ZeroCopy)))); } else if (res->result.value.tag == SyntheticModuleType::ExportDefaultObject) { JSC::JSValue value = JSC::JSValue::decode(res->result.value.jsvalue_for_export); if (!value) { @@ -1216,14 +1204,7 @@ static JSValue fetchESMSourceCode( } // JSON can become strings, null, numbers, booleans so we must handle "export default 123" - auto function = generateJSValueExportDefaultObjectSourceCode( - globalObject, - value); - auto source = JSC::SourceCode( - JSC::SyntheticSourceProvider::create(WTF::move(function), - JSC::SourceOrigin(), specifier->toWTFString(BunString::ZeroCopy))); - JSC::ensureStillAliveHere(value); - RELEASE_AND_RETURN(scope, rejectOrResolve(JSSourceCode::create(globalObject->vm(), WTF::move(source)))); + RELEASE_AND_RETURN(scope, rejectOrResolve(createJSValueExportDefaultObjectSourceCode(globalObject, value, specifier->toWTFString(BunString::ZeroCopy)))); } auto provider = Zig::SourceProvider::create(globalObject, res->result.value); diff --git a/src/jsc/bindings/ModuleLoader.h b/src/jsc/bindings/ModuleLoader.h index 17725477118c..9d058aa065e9 100644 --- a/src/jsc/bindings/ModuleLoader.h +++ b/src/jsc/bindings/ModuleLoader.h @@ -48,6 +48,9 @@ struct OnLoadResult { OnLoadResultValue value; OnLoadResultType type; bool wasMock; + // Backs `value.sourceText.string` when `contents` is a typed array. + // No inline capacity, so moving the struct doesn't invalidate the pointer. + WTF::Vector sourceTextCopy; }; extern "C" bool isBunTest; diff --git a/src/jsc/bindings/NodeExeImportsWindows.cpp b/src/jsc/bindings/NodeExeImportsWindows.cpp new file mode 100644 index 000000000000..307641f34235 --- /dev/null +++ b/src/jsc/bindings/NodeExeImportsWindows.cpp @@ -0,0 +1,356 @@ +#include "root.h" + +#if OS(WINDOWS) + +#include "NodeExeImportsWindows.h" +#include +#include +#include +#include +#include + +namespace Bun { + +namespace { + +// Bounds-checked view of a mapped PE image. Windows doesn't validate the delay-load directory at load time, so read +// everything through at() / cstring(), which only return memory inside the headers or a mapped section. +class MappedImage { +public: + static bool open(void* base, MappedImage& out) + { + auto* bytes = static_cast(base); + auto* dos = reinterpret_cast(bytes); + if (dos->e_magic != IMAGE_DOS_SIGNATURE || dos->e_lfanew <= 0 || dos->e_lfanew > 0x1000000) + return false; + auto* nt = reinterpret_cast(bytes + dos->e_lfanew); + if (nt->Signature != IMAGE_NT_SIGNATURE || nt->OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR64_MAGIC) + return false; + size_t sectionTableEnd = dos->e_lfanew + offsetof(IMAGE_NT_HEADERS64, OptionalHeader) + nt->FileHeader.SizeOfOptionalHeader + + size_t(nt->FileHeader.NumberOfSections) * sizeof(IMAGE_SECTION_HEADER); + if (sectionTableEnd > nt->OptionalHeader.SizeOfHeaders || nt->OptionalHeader.SizeOfHeaders > nt->OptionalHeader.SizeOfImage) + return false; + out.m_base = bytes; + out.m_nt = nt; + out.m_sections = IMAGE_FIRST_SECTION(nt); + out.m_sectionCount = nt->FileHeader.NumberOfSections; + return true; + } + + const IMAGE_DATA_DIRECTORY* directory(unsigned index) const + { + if (index >= nt().OptionalHeader.NumberOfRvaAndSizes) + return nullptr; + const IMAGE_DATA_DIRECTORY* dir = &nt().OptionalHeader.DataDirectory[index]; + return dir->VirtualAddress && dir->Size ? dir : nullptr; + } + + template T* at(DWORD rva) const + { + return readableFrom(rva) >= sizeof(T) ? reinterpret_cast(m_base + rva) : nullptr; + } + + // Returns how many consecutive elements are readable starting at rva. + size_t countAvailable(DWORD rva, size_t elementSize) const { return readableFrom(rva) / elementSize; } + + const char* cstring(DWORD rva) const + { + size_t n = readableFrom(rva); + auto* s = reinterpret_cast(m_base + rva); + return n && strnlen(s, n) < n ? s : nullptr; + } + +private: + const IMAGE_NT_HEADERS64& nt() const { return *m_nt; } + + size_t readableFrom(DWORD rva) const + { + if (rva >= nt().OptionalHeader.SizeOfImage) + return 0; + if (rva < nt().OptionalHeader.SizeOfHeaders) + return nt().OptionalHeader.SizeOfHeaders - rva; + for (unsigned i = 0; i < m_sectionCount; ++i) { + const IMAGE_SECTION_HEADER& s = m_sections[i]; + DWORD size = s.Misc.VirtualSize ? s.Misc.VirtualSize : s.SizeOfRawData; + if (rva < s.VirtualAddress || rva - s.VirtualAddress >= size) + continue; + if (!(s.Characteristics & (IMAGE_SCN_MEM_READ | IMAGE_SCN_MEM_WRITE | IMAGE_SCN_MEM_EXECUTE))) + return 0; + if (size > nt().OptionalHeader.SizeOfImage - s.VirtualAddress) + size = nt().OptionalHeader.SizeOfImage - s.VirtualAddress; + return size - (rva - s.VirtualAddress); + } + return 0; + } + + BYTE* m_base { nullptr }; + const IMAGE_NT_HEADERS64* m_nt { nullptr }; + const IMAGE_SECTION_HEADER* m_sections { nullptr }; + unsigned m_sectionCount { 0 }; +}; + +static DWORD systemPageSize() +{ + static DWORD size = [] { + SYSTEM_INFO info; + GetSystemInfo(&info); + return info.dwPageSize; + }(); + return size; +} + +// Makes one page writable for this object's lifetime, then restores its protection. +// An import address table is read-only once the loader has snapped it. +class WritablePage { +public: + explicit WritablePage(void* address) + : m_page(reinterpret_cast(reinterpret_cast(address) & ~uintptr_t(systemPageSize() - 1))) + { + MEMORY_BASIC_INFORMATION info; + if (!VirtualQuery(m_page, &info, sizeof(info)) || info.State != MEM_COMMIT) { + m_ok = false; + return; + } + constexpr DWORD writable = PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY; + constexpr DWORD executable = PAGE_EXECUTE | PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY; + if (info.Protect & writable) + return; + m_ok = VirtualProtect(m_page, systemPageSize(), (info.Protect & executable) ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE, &m_previous); + m_restore = m_ok; + } + ~WritablePage() + { + DWORD unused; + if (m_restore) + VirtualProtect(m_page, systemPageSize(), m_previous, &unused); + } + bool ok() const { return m_ok; } + bool contains(void* address) const { return reinterpret_cast(address) - reinterpret_cast(m_page) < systemPageSize(); } + +private: + void* m_page; + DWORD m_previous { 0 }; + bool m_ok { true }; + bool m_restore { false }; +}; + +static bool isNodeExe(const char* name) +{ + return name && !_stricmp(name, "node.exe"); +} + +static HMODULE hostExecutable() +{ + static HMODULE module = GetModuleHandleW(nullptr); + return module; +} + +// Returns null for a malformed entry or an import by ordinal. node.exe's ordinals are meaningless in this executable. +static const char* importName(const MappedImage& image, const IMAGE_THUNK_DATA64& entry) +{ + if (IMAGE_SNAP_BY_ORDINAL64(entry.u1.Ordinal) || entry.u1.AddressOfData > MAXDWORD - offsetof(IMAGE_IMPORT_BY_NAME, Name)) + return nullptr; + return image.cstring(DWORD(entry.u1.AddressOfData) + offsetof(IMAGE_IMPORT_BY_NAME, Name)); +} + +// Rebinds a load-time node.exe import address table from the node.exe the loader found to this executable's exports. +// Returns null, or the first name this executable doesn't export. Slots for missing names aren't rebound. +static const char* rebindImportAddressTable(const MappedImage& image, DWORD nameTableRva, DWORD addressTableRva) +{ + auto* names = image.at(nameTableRva); + auto* slots = image.at(addressTableRva); + if (!names || !slots) + return ""; + size_t limit = std::min(image.countAvailable(nameTableRva, sizeof(*names)), image.countAvailable(addressTableRva, sizeof(*slots))); + size_t count = 0; + while (count < limit && names[count].u1.AddressOfData) + ++count; + if (count == limit) + return ""; + + const char* missing = nullptr; + for (size_t i = 0; i < count;) { + WritablePage page(&slots[i]); + if (!page.ok()) + return ""; + for (; i < count && page.contains(&slots[i]); ++i) { + const char* name = importName(image, names[i]); + FARPROC function = name ? GetProcAddress(hostExecutable(), name) : nullptr; + if (function) + slots[i].u1.Function = reinterpret_cast(function); + else if (!missing) + missing = name ? name : ""; + } + } + return missing; +} + +// Returns null, or `base`'s first load-time node.exe import that this executable doesn't export. +static const char* redirectNodeExeImports(void* base) +{ + MappedImage image; + if (!MappedImage::open(base, image)) + return nullptr; + const char* missing = nullptr; + + if (const IMAGE_DATA_DIRECTORY* dir = image.directory(IMAGE_DIRECTORY_ENTRY_IMPORT)) { + auto* imports = image.at(dir->VirtualAddress); + size_t limit = image.countAvailable(dir->VirtualAddress, sizeof(*imports)); + for (size_t i = 0; imports && i < limit && imports[i].Name && imports[i].FirstThunk; ++i) { + if (!isNodeExe(image.cstring(imports[i].Name))) + continue; + // Without a name table there's no way to map bound slots back to names. + const char* result = imports[i].OriginalFirstThunk + ? rebindImportAddressTable(image, imports[i].OriginalFirstThunk, imports[i].FirstThunk) + : ""; + if (result && !missing) + missing = result; + } + } + + // For a delay-load import, only set the descriptor's cached module handle. As with node-gyp's hook, delayimp then + // resolves each import against this executable on first call and never calls LoadLibrary("node.exe"). + if (const IMAGE_DATA_DIRECTORY* dir = image.directory(IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT)) { + auto* delays = image.at(dir->VirtualAddress); + size_t limit = image.countAvailable(dir->VirtualAddress, sizeof(*delays)); + for (size_t i = 0; delays && i < limit && delays[i].DllNameRVA; ++i) { + // Attributes.RvaBased == 0 means the VC6 layout, where these fields are virtual addresses. + if (!delays[i].Attributes.RvaBased || !isNodeExe(image.cstring(delays[i].DllNameRVA))) + continue; + auto* handle = delays[i].ModuleHandleRVA ? image.at(delays[i].ModuleHandleRVA) : nullptr; + if (!handle) + continue; + WritablePage page(handle); + if (page.ok()) + InterlockedExchangePointer(reinterpret_cast(handle), hostExecutable()); + } + } + + return missing; +} + +// UNICODE_STRING from . That header's `STRING` is ambiguous with JSC::STRING in a unified build. +struct NtUnicodeString { + USHORT Length; + USHORT MaximumLength; + PWSTR Buffer; +}; + +struct LdrDllNotificationData { + ULONG Flags; + const NtUnicodeString* FullDllName; + const NtUnicodeString* BaseDllName; + PVOID DllBase; + ULONG SizeOfImage; +}; +constexpr ULONG LDR_DLL_NOTIFICATION_REASON_LOADED = 1; +using LdrDllNotificationFunction = VOID(CALLBACK*)(ULONG reason, const LdrDllNotificationData*, PVOID context); +using LdrRegisterDllNotificationFunction = LONG(NTAPI*)(ULONG flags, LdrDllNotificationFunction, PVOID context, PVOID* cookie); + +static thread_local NodeExeImports::Scope* t_currentScope = nullptr; + +} // namespace + +class NodeExeImports::ScopeAccess { +public: + static void record(Scope& scope, void* base, const NtUnicodeString* moduleName, const char* missing) + { + // A module past the capacity is treated as unseen and is redirected again after LoadLibrary returns. + if (scope.m_notifiedCount < std::size(scope.m_notified)) + scope.m_notified[scope.m_notifiedCount++] = base; + if (!missing || scope.m_failed) + return; + scope.m_failed = true; + strncpy_s(scope.m_symbol, missing, _TRUNCATE); + if (moduleName && moduleName->Buffer) + wcsncpy_s(scope.m_module, moduleName->Buffer, std::min(moduleName->Length / sizeof(wchar_t), std::size(scope.m_module) - 1)); + } +}; + +namespace { + +// Runs under the loader lock on the thread that called LoadLibrary, after the new module's imports are snapped and +// before its DllMain. It must not allocate, lock, or call an API that can load a module. +static VOID CALLBACK onDllNotification(ULONG reason, const LdrDllNotificationData* data, PVOID) +{ + NodeExeImports::Scope* scope = t_currentScope; + if (reason != LDR_DLL_NOTIFICATION_REASON_LOADED || !scope || !data || !data->DllBase) + return; + NodeExeImports::ScopeAccess::record(*scope, data->DllBase, data->BaseDllName, redirectNodeExeImports(data->DllBase)); +} + +static bool registerDllNotification() +{ + static bool registered = [] { + // Initialize these here rather than on first use inside the loader callback. + hostExecutable(); + systemPageSize(); + auto ntdll = GetModuleHandleW(L"ntdll.dll"); + auto registerNotification = ntdll ? reinterpret_cast(GetProcAddress(ntdll, "LdrRegisterDllNotification")) : nullptr; + PVOID cookie = nullptr; + return registerNotification && registerNotification(0, onDllNotification, nullptr, &cookie) >= 0; + }(); + return registered; +} + +struct ProcessedModules { + Lock lock; + HashSet modules WTF_GUARDED_BY_LOCK(lock); +}; +static ProcessedModules& processedModules() +{ + static NeverDestroyed set; + return set; +} + +} // namespace + +NodeExeImports::Scope::Scope() + : m_previous(t_currentScope) +{ + registerDllNotification(); + t_currentScope = this; +} + +NodeExeImports::Scope::~Scope() +{ + t_currentScope = m_previous; +} + +WTF::String NodeExeImports::Scope::unresolvedImportError(HMODULE loaded) +{ + if (loaded) { + bool notified = false; + for (size_t i = 0; !notified && i < m_notifiedCount; ++i) + notified = m_notified[i] == loaded; + // There's no notification for a module that was already loaded (by an earlier dlopen, by bun:ffi, or as a dependency). + // Redirect it once, before this dlopen calls any of its exports. + auto& processed = processedModules(); + Locker locker { processed.lock }; + if (processed.modules.add(loaded).isNewEntry && !notified) { + wchar_t path[MAX_PATH]; + DWORD length = GetModuleFileNameW(loaded, path, MAX_PATH); + const wchar_t* base = path + length; + while (base > path && base[-1] != L'\\' && base[-1] != L'/') + --base; + NtUnicodeString name { USHORT((path + length - base) * sizeof(wchar_t)), USHORT((path + length - base) * sizeof(wchar_t)), const_cast(base) }; + ScopeAccess::record(*this, loaded, length ? &name : nullptr, redirectNodeExeImports(loaded)); + } + if (m_failed) + processed.modules.remove(loaded); + } + if (!m_failed) + return {}; + return makeString(m_module[0] ? WTF::String(m_module) : "it"_s, " imports '"_s, WTF::String::fromLatin1(m_symbol), "' from node.exe, which Bun does not provide"_s); +} + +void NodeExeImports::forget(HMODULE module) +{ + auto& processed = processedModules(); + Locker locker { processed.lock }; + processed.modules.remove(module); +} + +} // namespace Bun + +#endif // OS(WINDOWS) diff --git a/src/jsc/bindings/NodeExeImportsWindows.h b/src/jsc/bindings/NodeExeImportsWindows.h new file mode 100644 index 000000000000..b678bbe9c009 --- /dev/null +++ b/src/jsc/bindings/NodeExeImportsWindows.h @@ -0,0 +1,50 @@ +#pragma once + +#include "root.h" + +#if OS(WINDOWS) + +#include +#include + +namespace Bun { + +// Native addons link against node.lib, so they import N-API from a module called "node.exe". Without node-gyp's +// win_delay_load_hook, the loader (load-time import) or delayimp (delay-load import) finds a real node.exe on the DLL +// search path and binds the addon's napi_* calls to it. +// +// While a Scope is alive on a thread, every module the loader maps on that thread has its `node.exe` imports redirected +// to this executable. That happens after the loader snaps the module's imports and before its DllMain runs. +class NodeExeImports { +public: + class ScopeAccess; + class Scope { + public: + Scope(); + ~Scope(); + Scope(const Scope&) = delete; + Scope& operator=(const Scope&) = delete; + + // Call after LoadLibraryExW returns `loaded` (null if it failed). Returns a null String if this executable exports + // every load-time node.exe import of every module loaded in this scope. Otherwise returns a message with the module + // and the symbol, and the caller must FreeLibrary `loaded` and fail the load because that slot is still bound to the + // real node.exe. Also redirects `loaded` if there was no notification for it. + WTF::String unresolvedImportError(HMODULE loaded); + + private: + friend class NodeExeImports::ScopeAccess; + Scope* m_previous; + void* m_notified[32]; + size_t m_notifiedCount { 0 }; + bool m_failed { false }; + char m_symbol[192] { 0 }; + wchar_t m_module[64] { 0 }; + }; + + // Call before unloading `module`, because a later module can be mapped at the same base address. + static void forget(HMODULE module); +}; + +} // namespace Bun + +#endif // OS(WINDOWS) diff --git a/src/jsc/bindings/NodeFSStatBinding.cpp b/src/jsc/bindings/NodeFSStatBinding.cpp index 9abe8d2b873d..6e69feb21c77 100644 --- a/src/jsc/bindings/NodeFSStatBinding.cpp +++ b/src/jsc/bindings/NodeFSStatBinding.cpp @@ -279,12 +279,17 @@ JSC_DEFINE_CUSTOM_GETTER(jsBigIntStatsPrototypeGetter_atime, (JSGlobalObject * g JSC_DEFINE_CUSTOM_SETTER(jsStatsPrototypeFunction_DatePutter, (JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue encodedValue, JSC::PropertyName propertyName)) { auto& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); JSObject* thisObject = dynamicDowncast(JSValue::decode(thisValue).toThis(globalObject, JSC::ECMAMode::strict())); if (!thisObject) return false; - thisObject->putDirect(vm, propertyName, JSValue::decode(encodedValue), 0); - return true; + if (thisObject->type() == JSC::FinalObjectType) [[likely]] { + thisObject->putDirect(vm, propertyName, JSValue::decode(encodedValue), 0); + return true; + } + // putDirect needs a structure transition, which a WebAssembly GC object doesn't support. + RELEASE_AND_RETURN(scope, thisObject->createDataProperty(globalObject, propertyName, JSValue::decode(encodedValue), true)); } JSC_DEFINE_HOST_FUNCTION(jsStatsPrototypeFunction_isBlockDevice, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) diff --git a/src/jsc/bindings/NodeHTTP.cpp b/src/jsc/bindings/NodeHTTP.cpp index 1290a9a55a1e..b1a65e791dad 100644 --- a/src/jsc/bindings/NodeHTTP.cpp +++ b/src/jsc/bindings/NodeHTTP.cpp @@ -648,12 +648,12 @@ static void writeAutoHeaders(uWS::HttpResponse* response, uint32_t autoHe { if (autoHeaderBits & kAutoHeaderDate) { auto line = cachedDateHeaderLine(); - response->uWS::template AsyncSocket::write(line.data(), (int)line.length()); + response->uWS::template AsyncSocket::write(line.data(), line.length()); } if (autoHeaderBits & kAutoHeaderConnKeepAlive) { if (autoHeaderBits & kAutoHeaderKeepAliveTimeout) { auto blob = keepAliveHeaderBlob(keepAliveTimeoutSecs); - response->uWS::template AsyncSocket::write(blob.data(), (int)blob.length()); + response->uWS::template AsyncSocket::write(blob.data(), blob.length()); } else { static constexpr const char ka[] = "Connection: keep-alive\r\n"; response->uWS::template AsyncSocket::write(ka, sizeof(ka) - 1); diff --git a/src/jsc/bindings/NodeURL.cpp b/src/jsc/bindings/NodeURL.cpp index 27289f3d3b92..2adfbf6619ac 100644 --- a/src/jsc/bindings/NodeURL.cpp +++ b/src/jsc/bindings/NodeURL.cpp @@ -1,6 +1,7 @@ #include "NodeURL.h" #include "ASCIIHostPunycodeCheck.h" #include "ErrorCode.h" +#include "VectorSizeLimit.h" #include "wtf/URL.h" #include "wtf/URLParser.h" #include @@ -38,21 +39,30 @@ enum class IDNAMode : uint8_t { // Runs a uidna_nameTo* conversion with the U_BUFFER_OVERFLOW_ERROR retry // protocol; on completion `status`/`info` hold the final results. +// Sets `status` to U_MEMORY_ALLOCATION_ERROR if the input or output is too large for its buffer. using UIDNAFunction = int32_t (*)(const UIDNA*, const char16_t*, int32_t, char16_t*, int32_t, UIDNAInfo*, UErrorCode*); static String runUIDNA(UIDNAFunction convert, const UIDNA* idna, const String& input, UErrorCode& status, UIDNAInfo& info) { String domain = input; - if (domain.is8Bit()) + if (domain.is8Bit()) { + if (!StringImpl::isValidLength(domain.length())) [[unlikely]] { + status = U_MEMORY_ALLOCATION_ERROR; + return {}; + } domain.convertTo16Bit(); + } const auto span = domain.span16(); Vector buffer(256); int32_t length = convert(idna, span.data(), span.size(), buffer.begin(), buffer.size(), &info, &status); if (status == U_BUFFER_OVERFLOW_ERROR) { + if (static_cast(length) > maxVectorSize() || !buffer.tryGrow(length)) [[unlikely]] { + status = U_MEMORY_ALLOCATION_ERROR; + return {}; + } status = U_ZERO_ERROR; info = UIDNA_INFO_INITIALIZER; - buffer.grow(length); length = convert(idna, span.data(), span.size(), buffer.begin(), buffer.size(), &info, &status); } if (U_FAILURE(status)) @@ -93,9 +103,8 @@ static String icuToASCII(const String& input, IDNAMode mode) // Port of Node's icu-based ToUnicode (removed in nodejs/node#55156): UTS #46 // ToUnicode always produces output, so info.errors is deliberately ignored. -static String icuToUnicode(const String& input) +static String icuToUnicode(const String& input, UErrorCode& status) { - UErrorCode status = U_ZERO_ERROR; UIDNAInfo info = UIDNA_INFO_INITIALIZER; return runUIDNA(uidna_nameToUnicode, toUnicodeIDNA(), input, status, info); } @@ -118,8 +127,10 @@ bool hasValidPunycodeHost(WTF::StringView host) // Mirrors Node's url.domainToASCII/domainToUnicode, which run the input // through a WHATWG URL host parse (ada's url.set_hostname on a "ws://x" // base). Returns a null String when host parsing fails. -static String parseDomainAsHost(const String& domain) +static String parseDomainAsHost(JSC::JSGlobalObject* globalObject, const String& domain) { + auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject)); + // The hostname setter's basic-URL parse stops at the first path, query, // fragment, or backslash (special scheme) terminator. StringView view { domain }; @@ -147,7 +158,12 @@ static String parseDomainAsHost(const String& domain) return {}; } - WTF::URL url(makeString("ws://"_s, host, "/"_s)); + auto urlString = tryMakeString("ws://"_s, host, "/"_s); + if (urlString.isNull()) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return {}; + } + WTF::URL url(WTF::move(urlString)); if (!url.isValid()) return {}; @@ -179,7 +195,8 @@ JSC_DEFINE_HOST_FUNCTION(jsDomainToASCII, (JSC::JSGlobalObject * globalObject, J auto domain = callFrame->argument(0).toWTFString(globalObject); RETURN_IF_EXCEPTION(scope, {}); - auto host = parseDomainAsHost(domain); + auto host = parseDomainAsHost(globalObject, domain); + RETURN_IF_EXCEPTION(scope, {}); if (host.isNull()) return JSC::JSValue::encode(jsEmptyString(vm)); return JSC::JSValue::encode(JSC::jsString(vm, host)); @@ -198,11 +215,17 @@ JSC_DEFINE_HOST_FUNCTION(jsDomainToUnicode, (JSC::JSGlobalObject * globalObject, // Node: validate through the host parse first, then run ToUnicode on the // resulting ASCII host. - auto host = parseDomainAsHost(domain); + auto host = parseDomainAsHost(globalObject, domain); + RETURN_IF_EXCEPTION(scope, {}); if (host.isNull()) return JSC::JSValue::encode(jsEmptyString(vm)); - auto unicode = icuToUnicode(host); + UErrorCode status = U_ZERO_ERROR; + auto unicode = icuToUnicode(host, status); + if (status == U_MEMORY_ALLOCATION_ERROR) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return {}; + } if (unicode.isNull()) return JSC::JSValue::encode(jsEmptyString(vm)); return JSC::JSValue::encode(JSC::jsString(vm, unicode)); @@ -252,7 +275,8 @@ JSC_DEFINE_HOST_FUNCTION(jsIcuToUnicode, (JSC::JSGlobalObject * globalObject, JS auto input = callFrame->argument(0).toWTFString(globalObject); RETURN_IF_EXCEPTION(scope, {}); - auto result = icuToUnicode(input); + UErrorCode status = U_ZERO_ERROR; + auto result = icuToUnicode(input, status); if (result.isNull()) { throwException(globalObject, scope, createError(globalObject, ErrorCode::ERR_INVALID_ARG_VALUE, "Cannot convert name to Unicode"_s)); return {}; diff --git a/src/jsc/bindings/NodeVM.cpp b/src/jsc/bindings/NodeVM.cpp index 4c05340489ca..97978e72035a 100644 --- a/src/jsc/bindings/NodeVM.cpp +++ b/src/jsc/bindings/NodeVM.cpp @@ -39,6 +39,7 @@ #include "JavaScriptCore/JSGlobalProxyInlines.h" #include "GCDefferalContext.h" #include "JSBuffer.h" +#include "xxhash3.h" #include #include @@ -60,6 +61,7 @@ #include "JavaScriptCore/ProgramCodeBlock.h" #include "JavaScriptCore/GlobalObjectMethodTable.h" #include "NodeVMScriptFetcher.h" +#include "ZigSourceProvider.h" #include "wtf/FileHandle.h" #include "JavaScriptCore/GetterSetter.h" @@ -78,12 +80,16 @@ static JSPromise* importModuleInner(JSGlobalObject* globalObject, JSString* modu static JSValue scriptFetchParametersToImportAttributes(JSGlobalObject* globalObject, JSC::ScriptFetchParameters* params) { auto& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); auto* obj = constructEmptyObject(vm, globalObject->nullPrototypeObjectStructure()); if (!params) return obj; if (!params->attributes().isEmpty()) { - for (auto& [key, value] : params->attributes()) - obj->putDirect(vm, JSC::Identifier::fromUid(vm, key.get()), jsString(vm, value)); + // An attribute key can be an array index, e.g. `import(x, { with: { "0": "a" } })`. + for (auto& [key, value] : params->attributes()) { + obj->putDirectMayBeIndex(globalObject, JSC::Identifier::fromUid(vm, key.get()), jsString(vm, value)); + RETURN_IF_EXCEPTION(scope, {}); + } return obj; } switch (params->type()) { @@ -119,20 +125,26 @@ bool extractCachedData(JSValue cachedDataValue, WTF::Vector& outCachedD return false; } + std::span bytes; if (auto* arrayBufferView = dynamicDowncast(cachedDataValue)) { - if (!arrayBufferView->isDetached()) { - outCachedData = arrayBufferView->span(); - return true; + if (arrayBufferView->isDetached()) { + return false; } + bytes = arrayBufferView->span(); } else if (auto* arrayBuffer = dynamicDowncast(cachedDataValue); arrayBuffer && arrayBuffer->impl()) { - outCachedData = arrayBuffer->impl()->toVector(); - return true; + bytes = arrayBuffer->impl()->span(); + } else { + return false; } - return false; + // Don't copy a buffer that's too big for a Vector. The caller rejects empty cachedData. + if (WTF::isValidCapacityForVector(bytes.size())) { + outCachedData = bytes; + } + return true; } -Ref createOwnedCachedBytecode(std::span bytes) +static Ref createOwnedCachedBytecode(std::span bytes) { // UnlinkedFunctionExecutable's Decoder constructor (CachedTypes.cpp) keeps the Decoder and a // payload offset, and decodes the body on the function's first call, borrowed payload or not. @@ -141,6 +153,37 @@ Ref createOwnedCachedBytecode(std::span byte return JSC::CachedBytecode::create(WTF::move(payload), {}); } +// cachedData is the bytecode prefixed with its XXH3-64 hash. JSC's decoder trusts its input, so corrupt bytes must never reach it. +JSC::JSUint8Array* createCachedDataBuffer(JSGlobalObject* globalObject, std::span bytecode) +{ + JSC::JSUint8Array* buffer = WebCore::createUninitializedBuffer(globalObject, sizeof(uint64_t) + bytecode.size()); + if (!buffer) [[unlikely]] { + return nullptr; + } + + const uint64_t hash = highway_xxhash3_64(bytecode.data(), bytecode.size(), 0); + auto bytes = buffer->typedSpan(); + WTF::memcpySpan(bytes, WTF::asByteSpan(hash)); + WTF::memcpySpan(bytes.subspan(sizeof(hash)), bytecode); + return buffer; +} + +RefPtr unwrapCachedData(std::span cachedData) +{ + uint64_t hash; + if (cachedData.size() <= sizeof(hash)) { + return nullptr; + } + + memcpy(&hash, cachedData.data(), sizeof(hash)); + auto bytecode = cachedData.subspan(sizeof(hash)); + if (hash != highway_xxhash3_64(bytecode.data(), bytecode.size(), 0)) { + return nullptr; + } + + return createOwnedCachedBytecode(bytecode); +} + JSC::JSFunction* constructAnonymousFunction(JSC::JSGlobalObject* globalObject, const ArgList& args, const SourceOrigin& sourceOrigin, CompileFunctionOptions&& options, JSC::SourceTaintedOrigin sourceTaintOrigin, JSC::JSScope* scope) { ASSERT(scope); @@ -221,10 +264,13 @@ JSC::JSFunction* constructAnonymousFunction(JSC::JSGlobalObject* globalObject, c TriState bytecodeAccepted = TriState::Indeterminate; - if (!options.cachedData.isEmpty()) { - cachedBytecode = createOwnedCachedBytecode(options.cachedData.span()); - SourceCodeKey key(sourceCode, {}, JSC::SourceCodeType::ProgramType, lexicallyScopedFeatures, JSC::JSParserScriptMode::Classic, JSC::DerivedContextType::None, JSC::EvalContextType::None, false, {}, std::nullopt); - unlinkedProgramCodeBlock = JSC::decodeCodeBlock(vm, key, *cachedBytecode); + // Like Node, reject an empty cachedData. + if (options.cachedDataProvided) { + cachedBytecode = unwrapCachedData(options.cachedData.span()); + if (cachedBytecode) { + SourceCodeKey key(sourceCode, {}, JSC::SourceCodeType::ProgramType, lexicallyScopedFeatures, JSC::JSParserScriptMode::Classic, JSC::DerivedContextType::None, JSC::EvalContextType::None, false, {}, std::nullopt); + unlinkedProgramCodeBlock = JSC::decodeCodeBlock(vm, key, *cachedBytecode); + } if (unlinkedProgramCodeBlock == nullptr) { bytecodeAccepted = TriState::False; } else { @@ -265,7 +311,7 @@ JSC::JSFunction* constructAnonymousFunction(JSC::JSGlobalObject* globalObject, c if (options.produceCachedData) { RefPtr producedBytecode = getBytecode(globalObject, JSC::SourceCodeType::ProgramType, sourceCode); if (producedBytecode) { - JSC::JSUint8Array* buffer = WebCore::createBuffer(globalObject, producedBytecode->span()); + JSC::JSUint8Array* buffer = createCachedDataBuffer(globalObject, producedBytecode->span()); RETURN_IF_EXCEPTION(throwScope, nullptr); Bun::putDirectNamed(vm, function, "cachedData"_s, buffer); Bun::putDirectNamed(vm, function, "cachedDataProduced"_s, jsBoolean(true)); @@ -323,6 +369,7 @@ static JSPromise* importModuleInner(JSGlobalObject* globalObject, JSString* modu auto scope = DECLARE_THROW_SCOPE(vm); JSValue importAttributes = scriptFetchParametersToImportAttributes(globalObject, parameters.get()); + RETURN_IF_EXCEPTION(scope, nullptr); MarkedArgumentBuffer args; args.append(moduleName); @@ -330,7 +377,7 @@ static JSPromise* importModuleInner(JSGlobalObject* globalObject, JSString* modu args.append(owner); } else if (auto* nodeVmGlobalObject = dynamicDowncast(globalObject)) { if (nodeVmGlobalObject->isNotContextified()) { - args.append(nodeVmGlobalObject->specialSandbox()); + args.append(nodeVmGlobalObject->globalThis()); } else { args.append(nodeVmGlobalObject->contextifiedObject()); } @@ -462,14 +509,14 @@ RefPtr getBytecode(JSGlobalObject* globalObject, JSC::Sourc // What JSC itself keys module code with: always strict, never tainted by a global scope extension. JSC::LexicallyScopedFeatures lexicallyScopedFeatures = StrictModeLexicallyScopedFeature; JSC::UnlinkedModuleProgramCodeBlock* unlinked = JSC::recursivelyGenerateUnlinkedCodeBlockForModuleProgram(vm, source, lexicallyScopedFeatures, JSParserScriptMode::Module, {}, parserError, EvalContextType::None); - if (!unlinked || parserError.isValid()) + if (!unlinked || parserError.isValid() || hasStringTooLongForBytecodeCache(vm, source)) return nullptr; return JSC::serializeBytecode(vm, unlinked, source, JSC::SourceCodeType::ModuleType, lexicallyScopedFeatures, JSParserScriptMode::Module, fileHandle, bytecodeCacheError, {}); } ASSERT(type == JSC::SourceCodeType::ProgramType); JSC::LexicallyScopedFeatures lexicallyScopedFeatures = globalObject->globalScopeExtension() ? TaintedByWithScopeLexicallyScopedFeature : NoLexicallyScopedFeatures; JSC::UnlinkedProgramCodeBlock* unlinked = JSC::recursivelyGenerateUnlinkedCodeBlockForProgram(vm, source, lexicallyScopedFeatures, JSParserScriptMode::Classic, {}, parserError, EvalContextType::None); - if (!unlinked || parserError.isValid()) + if (!unlinked || parserError.isValid() || hasStringTooLongForBytecodeCache(vm, source)) return nullptr; return JSC::serializeBytecode(vm, unlinked, source, JSC::SourceCodeType::ProgramType, lexicallyScopedFeatures, JSParserScriptMode::Classic, fileHandle, bytecodeCacheError, {}); } @@ -485,8 +532,7 @@ JSC::EncodedJSValue createCachedData(JSGlobalObject* globalObject, const JSC::So return throwVMError(globalObject, scope, "createCachedData failed"_s); } - std::span bytes = bytecode->span(); - JSC::JSUint8Array* buffer = WebCore::createBuffer(globalObject, bytes); + JSC::JSUint8Array* buffer = createCachedDataBuffer(globalObject, bytecode->span()); RETURN_IF_EXCEPTION(scope, {}); if (!buffer) { @@ -761,7 +807,7 @@ void getNodeVMContextOptions(JSGlobalObject* globalObject, JSC::VM& vm, JSC::Thr auto allowStringsValue = codeGenerationObject->getIfPropertyExists(globalObject, optionNames(vm).strings(vm)); RETURN_IF_EXCEPTION(scope, ); - if (allowStringsValue) { + if (allowStringsValue && !allowStringsValue.isUndefined()) { if (!allowStringsValue.isBoolean()) { ERR::INVALID_ARG_TYPE(scope, globalObject, WTF::makeString("options."_s, codeGenerationKey.string(), ".strings"_s), "boolean"_s, allowStringsValue); return; @@ -772,7 +818,7 @@ void getNodeVMContextOptions(JSGlobalObject* globalObject, JSC::VM& vm, JSC::Thr auto allowWasmValue = codeGenerationObject->getIfPropertyExists(globalObject, optionNames(vm).wasm(vm)); RETURN_IF_EXCEPTION(scope, ); - if (allowWasmValue) { + if (allowWasmValue && !allowWasmValue.isUndefined()) { if (!allowWasmValue.isBoolean()) { ERR::INVALID_ARG_TYPE(scope, globalObject, WTF::makeString("options."_s, codeGenerationKey.string(), ".wasm"_s), "boolean"_s, allowWasmValue); return; @@ -805,10 +851,6 @@ NodeVMGlobalObject* getGlobalObjectFromContext(JSGlobalObject* globalObject, JSV auto* zigGlobalObject = defaultGlobalObject(globalObject); JSValue scopeValue = zigGlobalObject->vmModuleContextMap()->get(context); if (scopeValue.isUndefined()) { - if (auto* specialSandbox = dynamicDowncast(context)) { - return specialSandbox->parentGlobal(); - } - if (auto* proxy = dynamicDowncast(context)) { if (auto* nodeVmGlobalObject = dynamicDowncast(proxy->target())) { return nodeVmGlobalObject; @@ -854,10 +896,6 @@ bool isContext(JSGlobalObject* globalObject, JSValue value) return true; } - if (value.inherits(NodeVMSpecialSandbox::info())) { - return true; - } - if (auto* proxy = dynamicDowncast(value); proxy && proxy->target()) { return proxy->target()->inherits(NodeVMGlobalObject::info()); } @@ -896,54 +934,6 @@ bool isUseMainContextDefaultLoaderConstant(JSGlobalObject* globalObject, JSValue using namespace NodeVM; -template JSC::GCClient::IsoSubspace* NodeVMSpecialSandbox::subspaceFor(JSC::VM& vm) -{ - if constexpr (mode == JSC::SubspaceAccess::Concurrently) - return nullptr; - return WebCore::subspaceForImpl(vm, BUN_SUBSPACE_SLOTS(m_clientSubspaceForNodeVMSpecialSandbox, m_subspaceForNodeVMSpecialSandbox)); -} - -NodeVMSpecialSandbox* NodeVMSpecialSandbox::create(VM& vm, NodeVMGlobalObject* globalObject) -{ - // The structure's prototype must be the sandbox realm's Object.prototype so that the - // prototype chain of globalThis inside a DONT_CONTEXTIFY context never reaches the - // host realm. This can't be cached on the host global because it differs per sandbox. - auto* structure = createStructure(vm, globalObject, globalObject->objectPrototype()); - NodeVMSpecialSandbox* ptr = new (NotNull, allocateCell(vm)) NodeVMSpecialSandbox(vm, structure, globalObject); - ptr->finishCreation(vm); - return ptr; -} - -JSC::Structure* NodeVMSpecialSandbox::createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) -{ - return Bun::createClassStructure(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); -} - -NodeVMSpecialSandbox::NodeVMSpecialSandbox(VM& vm, Structure* structure, NodeVMGlobalObject* globalObject) - : Base(vm, structure) -{ - m_parentGlobal.set(vm, this, globalObject); -} - -void NodeVMSpecialSandbox::finishCreation(VM& vm) -{ - Base::finishCreation(vm); - ASSERT(inherits(info())); -} - -const JSC::ClassInfo NodeVMSpecialSandbox::s_info = { "NodeVMSpecialSandbox"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(NodeVMSpecialSandbox) }; - -template -void NodeVMSpecialSandbox::visitChildrenImpl(JSCell* cell, Visitor& visitor) -{ - auto* thisObject = uncheckedDowncast(cell); - ASSERT_GC_OBJECT_INHERITS(thisObject, info()); - Base::visitChildren(thisObject, visitor); - visitor.append(thisObject->m_parentGlobal); -} - -DEFINE_VISIT_CHILDREN(NodeVMSpecialSandbox); - NodeVMGlobalObject::NodeVMGlobalObject(JSC::VM& vm, JSC::Structure* structure, NodeVMContextOptions contextOptions, JSValue importer) : Base(vm, structure, &globalObjectMethodTable()) , m_dynamicImportCallback(vm, this, importer) @@ -1188,12 +1178,6 @@ bool NodeVMGlobalObject::put(JSCell* cell, JSGlobalObject* globalObject, Propert RELEASE_AND_RETURN(scope, Base::put(cell, globalObject, propertyName, value, slot)); } - if (thisObject->m_contextOptions.notContextified) { - JSObject* specialSandbox = thisObject->specialSandbox(); - slot.setThisValue(specialSandbox); - RELEASE_AND_RETURN(scope, specialSandbox->putInline(globalObject, propertyName, value, slot)); - } - slot.setThisValue(sandbox); bool result = sandbox->methodTable()->put(sandbox, globalObject, propertyName, value, slot); @@ -1211,31 +1195,6 @@ bool NodeVMGlobalObject::put(JSCell* cell, JSGlobalObject* globalObject, Propert // This is copy-pasted from JSC's ProxyObject.cpp static const ASCIILiteral s_proxyAlreadyRevokedErrorMessage { "Proxy has already been revoked. No more operations are allowed to be performed on it"_s }; -bool NodeVMSpecialSandbox::getOwnPropertySlot(JSObject* cell, JSGlobalObject* globalObject, PropertyName propertyName, PropertySlot& slot) -{ - VM& vm = JSC::getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); - - auto* thisObject = uncheckedDowncast(cell); - NodeVMGlobalObject* parentGlobal = thisObject->parentGlobal(); - - if (propertyName == vm.propertyNames->globalThis) [[unlikely]] { - slot.disableCaching(); - slot.setThisValue(thisObject); - slot.setValue(thisObject, slot.attributes(), thisObject); - return true; - } - - bool result = parentGlobal->getOwnPropertySlot(parentGlobal, globalObject, propertyName, slot); - RETURN_IF_EXCEPTION(scope, false); - - if (result) { - return true; - } - - RELEASE_AND_RETURN(scope, Base::getOwnPropertySlot(cell, globalObject, propertyName, slot)); -} - bool NodeVMGlobalObject::getOwnPropertySlot(JSObject* cell, JSGlobalObject* globalObject, PropertyName propertyName, PropertySlot& slot) { VM& vm = JSC::getVM(globalObject); @@ -1243,15 +1202,6 @@ bool NodeVMGlobalObject::getOwnPropertySlot(JSObject* cell, JSGlobalObject* glob auto* thisObject = uncheckedDowncast(cell); - bool notContextified = thisObject->isNotContextified(); - - if (notContextified && propertyName == vm.propertyNames->globalThis) [[unlikely]] { - slot.disableCaching(); - slot.setThisValue(thisObject); - slot.setValue(thisObject, slot.attributes(), thisObject->specialSandbox()); - return true; - } - if (JSObject* contextifiedObject = thisObject->contextifiedObject()) { slot.setThisValue(contextifiedObject); // Unfortunately we must special case ProxyObjects. Why? @@ -1327,33 +1277,31 @@ bool NodeVMGlobalObject::getOwnPropertySlot(JSObject* cell, JSGlobalObject* glob goto try_from_global; } - if (!notContextified) { - if (slot.internalMethodType() == JSC::PropertySlot::InternalMethodType::Get) { - bool result = contextifiedObject->getPropertySlot(globalObject, propertyName, slot); + if (slot.internalMethodType() == JSC::PropertySlot::InternalMethodType::Get) { + bool result = contextifiedObject->getPropertySlot(globalObject, propertyName, slot); + RETURN_IF_EXCEPTION(scope, false); + if (result) { + // Materialize the value (like V8's contextify GetterCallback) so that, + // when a lookup on the sandbox yields the sandbox object itself, we can + // substitute the context's global proxy to keep identities consistent. + JSValue value = slot.getValue(globalObject, propertyName); RETURN_IF_EXCEPTION(scope, false); - if (result) { - // Materialize the value (like V8's contextify GetterCallback) so that, - // when a lookup on the sandbox yields the sandbox object itself, we can - // substitute the context's global proxy to keep identities consistent. - JSValue value = slot.getValue(globalObject, propertyName); - RETURN_IF_EXCEPTION(scope, false); - if (value == contextifiedObject) - value = thisObject->globalThis(); - unsigned ignoredAttributes = 0; - slot.disableCaching(); - slot.setValue(contextifiedObject, ignoredAttributes, value); - return true; - } - } else { - // For [[GetOwnProperty]] and [[HasProperty]], only own properties of the - // sandbox may be reported on the context's global object. Properties from - // the sandbox's prototype chain remain readable through normal lookup - // (InternalMethodType::Get above) but must not appear as own properties, - // matching Node's contextify Query/Descriptor callbacks. - bool result = contextifiedObject->methodTable()->getOwnPropertySlot(contextifiedObject, globalObject, propertyName, slot); - RETURN_IF_EXCEPTION(scope, false); - if (result) return true; + if (value == contextifiedObject) + value = thisObject->globalThis(); + unsigned ignoredAttributes = 0; + slot.disableCaching(); + slot.setValue(contextifiedObject, ignoredAttributes, value); + return true; } + } else { + // For [[GetOwnProperty]] and [[HasProperty]], only own properties of the + // sandbox may be reported on the context's global object. Properties from + // the sandbox's prototype chain remain readable through normal lookup + // (InternalMethodType::Get above) but must not appear as own properties, + // matching Node's contextify Query/Descriptor callbacks. + bool result = contextifiedObject->methodTable()->getOwnPropertySlot(contextifiedObject, globalObject, propertyName, slot); + RETURN_IF_EXCEPTION(scope, false); + if (result) return true; } try_from_global: @@ -1362,19 +1310,7 @@ bool NodeVMGlobalObject::getOwnPropertySlot(JSObject* cell, JSGlobalObject* glob RETURN_IF_EXCEPTION(scope, false); } - bool result = Base::getOwnPropertySlot(cell, globalObject, propertyName, slot); - RETURN_IF_EXCEPTION(scope, false); - - if (result) { - return true; - } - - if (thisObject->m_contextOptions.notContextified) { - JSObject* specialSandbox = thisObject->specialSandbox(); - RELEASE_AND_RETURN(scope, JSObject::getOwnPropertySlot(specialSandbox, globalObject, propertyName, slot)); - } - - return false; + RELEASE_AND_RETURN(scope, Base::getOwnPropertySlot(cell, globalObject, propertyName, slot)); } bool NodeVMGlobalObject::getOwnPropertySlotByIndex(JSObject* cell, JSGlobalObject* globalObject, unsigned index, PropertySlot& slot) @@ -1393,7 +1329,7 @@ bool NodeVMGlobalObject::defineOwnProperty(JSObject* cell, JSGlobalObject* globa RELEASE_AND_RETURN(scope, Base::defineOwnProperty(cell, globalObject, propertyName, descriptor, shouldThrow)); } - auto* contextifiedObject = thisObject->isNotContextified() ? thisObject->specialSandbox() : thisObject->m_sandbox.get(); + auto* contextifiedObject = thisObject->m_sandbox.get(); PropertySlot slot(globalObject, PropertySlot::InternalMethodType::GetOwnProperty, nullptr); bool isDeclaredOnGlobalProxy = globalObject->JSC::JSGlobalObject::getOwnPropertySlot(globalObject, globalObject, propertyName, slot); @@ -1440,7 +1376,6 @@ void NodeVMGlobalObject::visitChildrenImpl(JSCell* cell, Visitor& visitor) Base::visitChildren(cell, visitor); auto* thisObject = uncheckedDowncast(cell); visitor.append(thisObject->m_sandbox); - visitor.append(thisObject->m_specialSandbox); visitor.append(thisObject->m_dynamicImportCallback); } @@ -1599,13 +1534,6 @@ JSC_DEFINE_HOST_FUNCTION(vmModule_createContext, (JSGlobalObject * globalObject, JSObject* sandbox = asObject(contextArg); if (isContext(globalObject, sandbox)) { - if (auto* proxy = dynamicDowncast(sandbox)) { - if (auto* targetContext = dynamicDowncast(proxy->target())) { - if (targetContext->isNotContextified()) { - return JSValue::encode(targetContext->specialSandbox()); - } - } - } return JSValue::encode(sandbox); } @@ -1617,19 +1545,17 @@ JSC_DEFINE_HOST_FUNCTION(vmModule_createContext, (JSGlobalObject * globalObject, RETURN_IF_EXCEPTION(scope, {}); + if (notContextified) { + // A DONT_CONTEXTIFY context doesn't have a sandbox. Its global proxy is the context object. + return JSValue::encode(targetContext->globalThis()); + } + // Set sandbox as contextified object targetContext->setContextifiedObject(sandbox); // Store context in WeakMap for isContext checks zigGlobalObject->vmModuleContextMap()->set(vm, sandbox, targetContext); - if (notContextified) { - auto* specialSandbox = NodeVMSpecialSandbox::create(vm, targetContext); - RETURN_IF_EXCEPTION(scope, {}); - targetContext->setSpecialSandbox(specialSandbox); - return JSValue::encode(targetContext->specialSandbox()); - } - return JSValue::encode(sandbox); } @@ -1984,25 +1910,18 @@ bool CompileFunctionOptions::fromJS(JSC::JSGlobalObject* globalObject, JSC::VM& // The validators return false both for "absent" and for "threw". RETURN_IF_EXCEPTION(scope, false); - if (validateCachedData(globalObject, vm, scope, options, this->cachedData)) + if (validateCachedData(globalObject, vm, scope, options, this->cachedData)) { + this->cachedDataProvided = true; any = true; + } RETURN_IF_EXCEPTION(scope, false); JSValue parsingContextValue = options->getIfPropertyExists(globalObject, optionNames(vm).parsingContext(vm)); RETURN_IF_EXCEPTION(scope, {}); if (!parsingContextValue.isEmpty() && !parsingContextValue.isUndefined()) { - if (parsingContextValue.isNull() || !parsingContextValue.isObject()) - return ERR::INVALID_ARG_INSTANCE(scope, globalObject, "options.parsingContext"_s, "Context"_s, parsingContextValue); - - JSObject* context = asObject(parsingContextValue); - auto* zigGlobalObject = defaultGlobalObject(globalObject); - JSValue scopeValue = zigGlobalObject->vmModuleContextMap()->get(context); - - if (scopeValue.isUndefined()) - return ERR::INVALID_ARG_INSTANCE(scope, globalObject, "options.parsingContext"_s, "Context"_s, parsingContextValue); - - parsingContext = dynamicDowncast(scopeValue); + parsingContext = getGlobalObjectFromContext(globalObject, parsingContextValue, false); + RETURN_IF_EXCEPTION(scope, false); if (!parsingContext) return ERR::INVALID_ARG_INSTANCE(scope, globalObject, "options.parsingContext"_s, "Context"_s, parsingContextValue); diff --git a/src/jsc/bindings/NodeVM.h b/src/jsc/bindings/NodeVM.h index dde19f2730d4..fc1bd7a6959e 100644 --- a/src/jsc/bindings/NodeVM.h +++ b/src/jsc/bindings/NodeVM.h @@ -26,8 +26,10 @@ namespace NodeVM { RefPtr getBytecode(JSGlobalObject* globalObject, JSC::SourceCodeType, const JSC::SourceCode& source); bool extractCachedData(JSValue cachedDataValue, WTF::Vector& outCachedData); -// Copies `bytes`: a decoded code block reads the payload long after decodeCodeBlock() returns. -Ref createOwnedCachedBytecode(std::span bytes); +JSC::JSUint8Array* createCachedDataBuffer(JSGlobalObject* globalObject, std::span bytecode); +// Returns null unless `cachedData` is intact output from createCachedDataBuffer(). +// Copies the bytecode because a decoded code block keeps reading it after decodeCodeBlock() returns. +RefPtr unwrapCachedData(std::span cachedData); String stringifyAnonymousFunction(JSGlobalObject* globalObject, const ArgList& args, ThrowScope& scope, int* outOffset); JSC::EncodedJSValue createCachedData(JSGlobalObject* globalObject, const JSC::SourceCode& source); bool handleException(JSGlobalObject* globalObject, VM& vm, NakedPtr exception, ThrowScope& throwScope); @@ -77,6 +79,7 @@ class CompileFunctionOptions : public BaseVMOptions { JSGlobalObject* parsingContext = nullptr; JSValue contextExtensions {}; bool produceCachedData = false; + bool cachedDataProvided = false; using BaseVMOptions::BaseVMOptions; @@ -94,33 +97,6 @@ class NodeVMContextOptions final { bool ownMicrotaskQueue = false; }; -class NodeVMGlobalObject; - -class NodeVMSpecialSandbox final : public JSC::JSNonFinalObject { -public: - using Base = JSC::JSNonFinalObject; - - static constexpr unsigned StructureFlags = Base::StructureFlags | JSC::OverridesGetOwnPropertySlot; - - static NodeVMSpecialSandbox* create(VM& vm, NodeVMGlobalObject* globalObject); - - DECLARE_INFO; - DECLARE_VISIT_CHILDREN; - template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm); - static Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype); - - static bool getOwnPropertySlot(JSObject*, JSGlobalObject*, JSC::PropertyName, JSC::PropertySlot&); - - NodeVMGlobalObject* parentGlobal() const { return m_parentGlobal.get(); } - -private: - WriteBarrier m_parentGlobal; - - NodeVMSpecialSandbox(VM& vm, Structure* structure, NodeVMGlobalObject* globalObject); - - void finishCreation(VM&); -}; - // This class represents a sandboxed global object for vm contexts class NodeVMGlobalObject final : public Bun::GlobalScope { public: @@ -148,8 +124,6 @@ class NodeVMGlobalObject final : public Bun::GlobalScope { // Performs a microtask checkpoint on this context's own queue // (microtaskMode: "afterEvaluate" contexts only; no-op otherwise). void drainOwnMicrotasks(); - NodeVMSpecialSandbox* specialSandbox() const { return m_specialSandbox.get(); } - void setSpecialSandbox(NodeVMSpecialSandbox* sandbox) { m_specialSandbox.set(vm(), this, sandbox); } JSValue dynamicImportCallback() const { return m_dynamicImportCallback.get(); } // Override property access to delegate to contextified object @@ -164,8 +138,6 @@ class NodeVMGlobalObject final : public Bun::GlobalScope { private: // The contextified object that acts as the global proxy WriteBarrier m_sandbox; - // A special object used when the context is not contextified. - WriteBarrier m_specialSandbox; WriteBarrier m_dynamicImportCallback; NodeVMContextOptions m_contextOptions {}; diff --git a/src/jsc/bindings/NodeVMModule.cpp b/src/jsc/bindings/NodeVMModule.cpp index ef0440c50982..15417612d737 100644 --- a/src/jsc/bindings/NodeVMModule.cpp +++ b/src/jsc/bindings/NodeVMModule.cpp @@ -35,8 +35,16 @@ JSArray* NodeVMModuleRequest::toJS(JSGlobalObject* globalObject) const RETURN_IF_EXCEPTION(scope, {}); for (const auto& [key, value] : m_importAttributes) { - attributes->putDirect(globalObject->vm(), JSC::Identifier::fromString(globalObject->vm(), key), JSC::jsString(globalObject->vm(), value), - PropertyAttribute::ReadOnly | PropertyAttribute::DontDelete); + constexpr unsigned propertyAttributes = PropertyAttribute::ReadOnly | PropertyAttribute::DontDelete; + JSC::Identifier identifier = JSC::Identifier::fromString(vm, key); + JSValue attributeValue = JSC::jsString(vm, value); + // An attribute key can be any string literal, including an array index. + if (auto index = parseIndex(identifier)) { + attributes->putDirectIndex(globalObject, *index, attributeValue, propertyAttributes, PutDirectIndexLikePutDirect); + RETURN_IF_EXCEPTION(scope, {}); + } else { + attributes->putDirect(vm, identifier, attributeValue, propertyAttributes); + } } array->putDirectIndex(globalObject, 1, attributes); RETURN_IF_EXCEPTION(scope, {}); diff --git a/src/jsc/bindings/NodeVMScript.cpp b/src/jsc/bindings/NodeVMScript.cpp index f224d2d6da8f..9e679b5217e6 100644 --- a/src/jsc/bindings/NodeVMScript.cpp +++ b/src/jsc/bindings/NodeVMScript.cpp @@ -6,7 +6,6 @@ #include "JavaScriptCore/CodeCache.h" #include "JavaScriptCore/Completion.h" -#include "JavaScriptCore/JIT.h" #include "JavaScriptCore/JSWeakMap.h" #include "JavaScriptCore/JSWeakMapInlines.h" #include "JavaScriptCore/ProgramCodeBlock.h" @@ -67,8 +66,10 @@ bool ScriptOptions::fromJS(JSC::JSGlobalObject* globalObject, JSC::VM& vm, JSC:: any = true; RETURN_IF_EXCEPTION(scope, false); - if (validateCachedData(globalObject, vm, scope, options, this->cachedData)) + if (validateCachedData(globalObject, vm, scope, options, this->cachedData)) { + this->cachedDataProvided = true; any = true; + } RETURN_IF_EXCEPTION(scope, false); // Handle importModuleDynamically option @@ -172,37 +173,17 @@ constructScript(JSGlobalObject* globalObject, CallFrame* callFrame, JSValue newT WTF::Vector& cachedData = script->cachedData(); - if (!cachedData.isEmpty()) { - JSC::ProgramExecutable* executable = script->cachedExecutable(); - if (!executable) { - executable = script->createExecutable(); - } - ASSERT(executable); - - JSC::LexicallyScopedFeatures lexicallyScopedFeatures = globalObject->globalScopeExtension() ? JSC::TaintedByWithScopeLexicallyScopedFeature : JSC::NoLexicallyScopedFeatures; - JSC::SourceCodeKey key(script->source(), {}, JSC::SourceCodeType::ProgramType, lexicallyScopedFeatures, JSC::JSParserScriptMode::Classic, JSC::DerivedContextType::None, JSC::EvalContextType::None, false, {}, std::nullopt); - Ref cachedBytecode = NodeVM::createOwnedCachedBytecode(cachedData.span()); - JSC::UnlinkedProgramCodeBlock* unlinkedBlock = JSC::decodeCodeBlock(vm, key, WTF::move(cachedBytecode)); - - if (!unlinkedBlock) { - script->cachedDataRejected(TriState::True); - } else { - JSC::JSScope* jsScope = globalObject->globalScope(); - JSC::CodeBlock* codeBlock = nullptr; - { - // JSC::ProgramCodeBlock::create() requires GC to be deferred. - DeferGC deferGC(vm); - codeBlock = JSC::ProgramCodeBlock::create(vm, executable, unlinkedBlock, jsScope); - RETURN_IF_EXCEPTION(scope, {}); - } - JSC::CompilationResult compilationResult = JIT::compileSync(vm, codeBlock, JITCompilationEffort::JITCompilationCanFail); - if (compilationResult != JSC::CompilationResult::CompilationFailed) { - executable->installCode(codeBlock); - script->cachedDataRejected(TriState::False); - } else { - script->cachedDataRejected(TriState::True); - } + // Like Node, reject an empty cachedData. + if (script->options().cachedDataProvided) { + // Only record whether the bytecode decodes. Each run links the block compiled from source (unlinkedCodeBlockFor). + bool accepted = false; + if (RefPtr cachedBytecode = unwrapCachedData(cachedData.span())) { + JSC::LexicallyScopedFeatures lexicallyScopedFeatures = globalObject->globalScopeExtension() ? JSC::TaintedByWithScopeLexicallyScopedFeature : JSC::NoLexicallyScopedFeatures; + JSC::SourceCodeKey key(script->source(), {}, JSC::SourceCodeType::ProgramType, lexicallyScopedFeatures, JSC::JSParserScriptMode::Classic, JSC::DerivedContextType::None, JSC::EvalContextType::None, false, {}, std::nullopt); + accepted = !!JSC::decodeCodeBlock(vm, key, cachedBytecode.releaseNonNull()); } + script->cachedDataRejected(accepted ? TriState::False : TriState::True); + cachedData = {}; } else if (script->options().produceCachedData) script->cacheBytecode(); @@ -227,8 +208,7 @@ JSC::UnlinkedProgramCodeBlock* NodeVMScript::unlinkedCodeBlockFor(JSGlobalObject if (m_unlinkedCodeBlock && m_unlinkedCodeBlock->codeGenerationMode() == codeGenerationMode) return m_unlinkedCodeBlock.get(); - // The CodeCache records the parse on the executable it is given (that changes what the executable keys - // later lookups with, so m_cachedExecutable is not used for this); every run links its own anyway. + // The CodeCache records the parse on the executable passed to it, and every run links its own executable. JSC::UnlinkedProgramCodeBlock* block = vm.codeCache()->getUnlinkedProgramCodeBlock(vm, JSC::ProgramExecutable::create(globalObject, m_source), m_source, codeGenerationMode, error); if (block) m_unlinkedCodeBlock.set(vm, this, block); @@ -243,13 +223,6 @@ JSValue NodeVMScript::evaluate(JSGlobalObject* globalObject, NakedPtr bytes = m_cachedBytecode->span(); - m_cachedBytecodeBuffer.set(vm(), this, WebCore::createBuffer(globalObject(), bytes)); + m_cachedBytecodeBuffer.set(vm(), this, createCachedDataBuffer(globalObject(), m_cachedBytecode->span())); RETURN_IF_EXCEPTION(scope, nullptr); if (!m_cachedBytecodeBuffer) { return nullptr; @@ -289,7 +261,6 @@ void NodeVMScript::visitChildrenImpl(JSCell* cell, Visitor& visitor) NodeVMScript* thisObject = uncheckedDowncast(cell); ASSERT_GC_OBJECT_INHERITS(thisObject, info()); Base::visitChildren(thisObject, visitor); - visitor.append(thisObject->m_cachedExecutable); visitor.append(thisObject->m_cachedBytecodeBuffer); visitor.append(thisObject->m_unlinkedCodeBlock); NodeVMScriptFetcher::visitSource(visitor, thisObject->m_source); @@ -349,8 +320,10 @@ static JSC::EncodedJSValue runInContext(NodeVMGlobalObject* globalObject, NodeVM } } - // Set the contextified object before evaluating - globalObject->setContextifiedObject(contextifiedObject); + // Using a global's own proxy as its sandbox would recurse forever. + if (auto* proxy = dynamicDowncast(contextifiedObject); !proxy || proxy->target() != globalObject) { + globalObject->setContextifiedObject(contextifiedObject); + } NakedPtr exception; JSValue result {}; @@ -380,6 +353,12 @@ static JSC::EncodedJSValue runInContext(NodeVMGlobalObject* globalObject, NodeVM RELEASE_AND_RETURN(scope, JSValue::encode(result)); } +// In a bare call `this` is the caller's scope object. toThis() converts it to undefined, which is what a strict JS callee sees. +static EncodedJSValue throwThisIsNotAScript(JSGlobalObject* globalObject, ThrowScope& scope, JSValue thisValue) +{ + return ERR::INVALID_ARG_VALUE(scope, globalObject, "this"_s, thisValue.toThis(globalObject, ECMAMode::strict()), "must be a Script"_s); +} + JSC_DEFINE_HOST_FUNCTION(scriptRunInThisContext, (JSGlobalObject * globalObject, CallFrame* callFrame)) { VM& vm = JSC::getVM(globalObject); @@ -388,7 +367,7 @@ JSC_DEFINE_HOST_FUNCTION(scriptRunInThisContext, (JSGlobalObject * globalObject, JSValue thisValue = callFrame->thisValue(); auto* script = dynamicDowncast(thisValue); if (!script) [[unlikely]] { - return ERR::INVALID_ARG_VALUE(scope, globalObject, "this"_s, thisValue, "must be a Script"_s); + return throwThisIsNotAScript(globalObject, scope, thisValue); } JSValue optionsArg = callFrame->argument(0); @@ -429,7 +408,7 @@ JSC_DEFINE_CUSTOM_GETTER(scriptGetSourceMapURL, (JSGlobalObject * globalObject, JSValue thisValue = JSValue::decode(thisValueEncoded); auto* script = dynamicDowncast(thisValue); if (!script) [[unlikely]] { - return ERR::INVALID_ARG_VALUE(scope, globalObject, "this"_s, thisValue, "must be a Script"_s); + return throwThisIsNotAScript(globalObject, scope, thisValue); } // Populated by the compile in the constructor (a CodeCache hit copies it over too). @@ -448,7 +427,7 @@ JSC_DEFINE_CUSTOM_GETTER(scriptGetCachedData, (JSGlobalObject * globalObject, JS JSValue thisValue = JSValue::decode(thisValueEncoded); auto* script = dynamicDowncast(thisValue); if (!script) [[unlikely]] { - return ERR::INVALID_ARG_VALUE(scope, globalObject, "this"_s, thisValue, "must be a Script"_s); + return throwThisIsNotAScript(globalObject, scope, thisValue); } scope.assertNoExceptionExceptTermination(); @@ -465,7 +444,7 @@ JSC_DEFINE_CUSTOM_GETTER(scriptGetCachedDataProduced, (JSGlobalObject * globalOb JSValue thisValue = JSValue::decode(thisValueEncoded); auto* script = dynamicDowncast(thisValue); if (!script) [[unlikely]] { - return ERR::INVALID_ARG_VALUE(scope, globalObject, "this"_s, thisValue, "must be a Script"_s); + return throwThisIsNotAScript(globalObject, scope, thisValue); } scope.assertNoExceptionExceptTermination(); @@ -479,7 +458,7 @@ JSC_DEFINE_CUSTOM_GETTER(scriptGetCachedDataRejected, (JSGlobalObject * globalOb JSValue thisValue = JSValue::decode(thisValueEncoded); auto* script = dynamicDowncast(thisValue); if (!script) [[unlikely]] { - return ERR::INVALID_ARG_VALUE(scope, globalObject, "this"_s, thisValue, "must be a Script"_s); + return throwThisIsNotAScript(globalObject, scope, thisValue); } switch (script->cachedDataRejected()) { @@ -500,7 +479,7 @@ JSC_DEFINE_HOST_FUNCTION(scriptCreateCachedData, (JSGlobalObject * globalObject, JSValue thisValue = callFrame->thisValue(); auto* script = dynamicDowncast(thisValue); if (!script) [[unlikely]] { - return ERR::INVALID_ARG_VALUE(scope, globalObject, "this"_s, thisValue, "must be a Script"_s); + return throwThisIsNotAScript(globalObject, scope, thisValue); } const JSC::SourceCode& source = script->source(); @@ -515,7 +494,7 @@ JSC_DEFINE_HOST_FUNCTION(scriptRunInContext, (JSGlobalObject * globalObject, Cal JSValue thisValue = callFrame->thisValue(); auto* script = dynamicDowncast(thisValue); if (!script) [[unlikely]] { - return ERR::INVALID_ARG_VALUE(scope, globalObject, "this"_s, thisValue, "must be a Script"_s); + return throwThisIsNotAScript(globalObject, scope, thisValue); } ArgList args(callFrame); @@ -554,6 +533,13 @@ JSC_DEFINE_HOST_FUNCTION(scriptRunInNewContext, (JSGlobalObject * globalObject, getNodeVMContextOptions(globalObject, vm, scope, contextOptionsArg, contextOptions, optionNames(vm).contextCodeGeneration(vm), &importer); RETURN_IF_EXCEPTION(scope, {}); + // vm.ts runInNewContext() passes the DONT_CONTEXTIFY global created by createContext(). + if (auto* proxy = dynamicDowncast(contextObjectValue)) { + if (auto* existing = dynamicDowncast(proxy->target()); existing && existing->isNotContextified()) { + RELEASE_AND_RETURN(scope, runInContext(existing, script, proxy, contextOptionsArg)); + } + } + contextOptions.notContextified = notContextified; auto* zigGlobalObject = defaultGlobalObject(globalObject); @@ -564,10 +550,7 @@ JSC_DEFINE_HOST_FUNCTION(scriptRunInNewContext, (JSGlobalObject * globalObject, RETURN_IF_EXCEPTION(scope, {}); if (notContextified) { - auto* specialSandbox = NodeVMSpecialSandbox::create(vm, targetContext); - RETURN_IF_EXCEPTION(scope, {}); - targetContext->setSpecialSandbox(specialSandbox); - RELEASE_AND_RETURN(scope, runInContext(targetContext, script, targetContext->specialSandbox(), callFrame->argument(1))); + RELEASE_AND_RETURN(scope, runInContext(targetContext, script, targetContext->globalThis(), callFrame->argument(1))); } RELEASE_AND_RETURN(scope, runInContext(targetContext, script, context, callFrame->argument(1))); diff --git a/src/jsc/bindings/NodeVMScript.h b/src/jsc/bindings/NodeVMScript.h index 50a133f459fc..e83f30e6a1a8 100644 --- a/src/jsc/bindings/NodeVMScript.h +++ b/src/jsc/bindings/NodeVMScript.h @@ -13,6 +13,7 @@ class ScriptOptions : public BaseVMOptions { WTF::Vector cachedData; std::optional timeout = std::nullopt; bool produceCachedData = false; + bool cachedDataProvided = false; using BaseVMOptions::BaseVMOptions; @@ -62,7 +63,6 @@ class NodeVMScript final : public JSC::JSDestructibleObject { static JSObject* createPrototype(VM& vm, JSGlobalObject* globalObject); - JSC::ProgramExecutable* createExecutable(); void cacheBytecode(); JSC::JSUint8Array* getBytecodeBuffer(); @@ -75,7 +75,6 @@ class NodeVMScript final : public JSC::JSDestructibleObject { const JSC::SourceCode& source() const { return m_source; } const ScriptOptions& options() const { return m_options; } WTF::Vector& cachedData() { return m_options.cachedData; } - JSC::ProgramExecutable* cachedExecutable() const { return m_cachedExecutable.get(); } bool cachedDataProduced() const { return m_cachedDataProduced; } void cachedDataProduced(bool value) { m_cachedDataProduced = value; } TriState cachedDataRejected() const { return m_cachedDataRejected; } @@ -87,7 +86,6 @@ class NodeVMScript final : public JSC::JSDestructibleObject { JSC::SourceCode m_source; RefPtr m_cachedBytecode; JSC::WriteBarrier m_cachedBytecodeBuffer; - JSC::WriteBarrier m_cachedExecutable; // The compile every run links. Only recompiled (and replaced) when a global object with a different // CodeGenerationMode, i.e. a debugger attached, runs the Script. JSC::WriteBarrier m_unlinkedCodeBlock; diff --git a/src/jsc/bindings/NodeVMSourceTextModule.cpp b/src/jsc/bindings/NodeVMSourceTextModule.cpp index e71519f114c7..c8a666693d1f 100644 --- a/src/jsc/bindings/NodeVMSourceTextModule.cpp +++ b/src/jsc/bindings/NodeVMSourceTextModule.cpp @@ -113,7 +113,8 @@ NodeVMSourceTextModule* NodeVMSourceTextModule::create(VM& vm, JSGlobalObject* g WTF::move(sourceCode), moduleWrapper, initializeImportMeta); ptr->finishCreation(vm); - if (cachedData.isEmpty()) { + // Like Node, reject an empty cachedData. + if (cachedDataValue.isUndefined()) { return ptr; } @@ -127,11 +128,12 @@ NodeVMSourceTextModule* NodeVMSourceTextModule::create(VM& vm, JSGlobalObject* g // Decoding checks the format and the source key. Linking would need // the module's JSModuleEnvironment, which does not exist yet. - LexicallyScopedFeatures lexicallyScopedFeatures = StrictModeLexicallyScopedFeature; - SourceCodeKey key(ptr->sourceCode(), {}, SourceCodeType::ModuleType, lexicallyScopedFeatures, JSParserScriptMode::Module, DerivedContextType::None, EvalContextType::None, false, {}, std::nullopt); - Ref cachedBytecode = NodeVM::createOwnedCachedBytecode(cachedData.span()); - if (decodeCodeBlock(vm, key, WTF::move(cachedBytecode))) - return ptr; + if (RefPtr cachedBytecode = unwrapCachedData(cachedData.span())) { + LexicallyScopedFeatures lexicallyScopedFeatures = StrictModeLexicallyScopedFeature; + SourceCodeKey key(ptr->sourceCode(), {}, SourceCodeType::ModuleType, lexicallyScopedFeatures, JSParserScriptMode::Module, DerivedContextType::None, EvalContextType::None, false, {}, std::nullopt); + if (decodeCodeBlock(vm, key, cachedBytecode.releaseNonNull())) + return ptr; + } throwError(globalObject, scope, ErrorCode::ERR_VM_MODULE_CACHED_DATA_REJECTED, "cachedData buffer was rejected"_s); return nullptr; @@ -201,27 +203,23 @@ JSValue NodeVMSourceTextModule::createModuleRecord(JSGlobalObject* globalObject) const Identifier& attributesIdentifier = builtinNames.attributesPublicName(); const Identifier& hostDefinedImportTypeIdentifier = builtinNames.hostDefinedImportTypePublicName(); - WTF::Vector attributesNodes; - attributesNodes.reserveInitialCapacity(requests.size()); - + // JSC dedupes requests by specifier and type and keeps the first declaration's, so a declaration's position isn't its request's index. + ModuleMap attributesLists; for (StatementNode* statement = node->statements()->firstStatement(); statement; statement = statement->next()) { - // Assumption: module declarations occur here in the same order they occur in `requestedModules`. - if (statement->isModuleDeclarationNode()) { - ModuleDeclarationNode* moduleDeclaration = static_cast(statement); - if (moduleDeclaration->isImportDeclarationNode()) { - ImportDeclarationNode* importDeclaration = static_cast(moduleDeclaration); - ASSERT_WITH_MESSAGE(attributesNodes.size() < requests.size(), "More attributes nodes than requests"); - ASSERT_WITH_MESSAGE(importDeclaration->moduleName()->moduleName().string().string() == requests.at(attributesNodes.size()).m_specifier.string(), "Module name mismatch"); - attributesNodes.append(importDeclaration->attributesList()); - } else if (moduleDeclaration->hasAttributesList()) { - // Necessary to make the indices of `attributesNodes` and `requests` match up - attributesNodes.append(nullptr); + if (!statement->isModuleDeclarationNode() || !static_cast(statement)->isImportDeclarationNode()) + continue; + auto* importDeclaration = static_cast(statement); + ImportAttributesListNode* attributesList = importDeclaration->attributesList(); + auto type = ScriptFetchParameters::Type::JavaScript; + if (attributesList) { + for (auto [key, value] : attributesList->attributes()) { + if (*key == vm.propertyNames->type) + type = ScriptFetchParameters::parseType(value->impl()).value_or(type); } } + attributesLists.add(ModuleMapKey { importDeclaration->moduleName()->moduleName().impl(), type }, attributesList); } - ASSERT_WITH_MESSAGE(attributesNodes.size() >= requests.size(), "Attributes node count doesn't match request count (%zu < %zu)", attributesNodes.size(), requests.size()); - for (unsigned i = 0; i < requests.size(); ++i) { const auto& request = requests[i]; @@ -270,10 +268,11 @@ JSValue NodeVMSourceTextModule::createModuleRecord(JSGlobalObject* globalObject) } } - if (ImportAttributesListNode* attributesNode = attributesNodes.at(i)) { - for (auto [key, value] : attributesNode->attributes()) { + if (ImportAttributesListNode* attributesList = attributesLists.get(ModuleMapKey { request.m_specifier.impl(), request.m_attributes ? request.m_attributes->type() : ScriptFetchParameters::Type::JavaScript })) { + for (auto [key, value] : attributesList->attributes()) { attributeMap.set(key->string(), value->string()); - attributesObject->putDirect(vm, *key, JSC::jsString(vm, value->string())); + attributesObject->putDirectMayBeIndex(globalObject, *key, JSC::jsString(vm, value->string())); + RETURN_IF_EXCEPTION(scope, {}); } } @@ -503,8 +502,12 @@ JSUint8Array* NodeVMSourceTextModule::cachedData(JSGlobalObject* globalObject) if (!m_cachedBytecodeBuffer) { RefPtr cachedBytecode = bytecode(globalObject); RETURN_IF_EXCEPTION(scope, nullptr); - std::span bytes = cachedBytecode->span(); - JSUint8Array* buffer = WebCore::createBuffer(globalObject, bytes); + // getBytecode can return null without throwing (serialization failure). + if (!cachedBytecode) [[unlikely]] { + throwVMError(globalObject, scope, "createCachedData failed"_s); + return nullptr; + } + JSUint8Array* buffer = createCachedDataBuffer(globalObject, cachedBytecode->span()); RETURN_IF_EXCEPTION(scope, nullptr); m_cachedBytecodeBuffer.set(vm, this, buffer); } diff --git a/src/jsc/bindings/ObjectBindings.h b/src/jsc/bindings/ObjectBindings.h index 6eacd7b71c7c..0612955ddd86 100644 --- a/src/jsc/bindings/ObjectBindings.h +++ b/src/jsc/bindings/ObjectBindings.h @@ -26,6 +26,19 @@ ALWAYS_INLINE JSC::JSValue getIfPropertyExistsPrototypePollutionMitigation(JSC:: return getIfPropertyExistsPrototypePollutionMitigation(JSC::getVM(globalObject), globalObject, object, name); } +// Does [[DefineOwnProperty]] for a non-index data property and throws if the object rejects it. Plain objects use a direct store. +ALWAYS_INLINE bool defineOwnDataProperty(JSC::JSGlobalObject* globalObject, JSC::JSObject* object, JSC::PropertyName propertyName, JSC::JSValue value, unsigned attributes) +{ + auto& vm = JSC::getVM(globalObject); + JSC::Structure* structure = object->structure(); + if (object->type() == JSC::FinalObjectType && structure->isStructureExtensible() && !JSC::isValidOffset(structure->get(vm, propertyName))) [[likely]] { + object->putDirect(vm, propertyName, value, attributes); + return true; + } + JSC::PropertyDescriptor descriptor(value, attributes); + return object->methodTable()->defineOwnProperty(object, globalObject, propertyName, descriptor, true); +} + // Whether `getIteratorResult` runs `get(value)` on a result whose `done` is true. IteratorStepValue never does. enum class IteratorDoneValue : uint8_t { Read, diff --git a/src/jsc/bindings/ProcessBindingFs.cpp b/src/jsc/bindings/ProcessBindingFs.cpp index 1eb6e96afad0..5f04b1646345 100644 --- a/src/jsc/bindings/ProcessBindingFs.cpp +++ b/src/jsc/bindings/ProcessBindingFs.cpp @@ -111,28 +111,35 @@ PROCESS_BINDING_NOT_IMPLEMENTED(writeFileUtf8) PROCESS_BINDING_NOT_IMPLEMENTED(writeString) +// reifyAllStaticProperties calls PropertyCallback builders without checking for exceptions, so check create() here. +template +static JSValue statValuesArray(VM& vm, JSObject* binding, size_t length) +{ + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + auto* globalObject = binding->globalObject(); + auto* array = ArrayType::create(globalObject, globalObject->typedArrayStructureWithTypedArrayType(), length); + RETURN_IF_EXCEPTION(scope, {}); + return array; +} + static JSValue ProcessBindingFs_statValues(VM& vm, JSObject* object) { - auto* globalObject = object->globalObject(); - return JSC::JSFloat64Array::create(globalObject, globalObject->m_typedArrayFloat64.get(globalObject), 36); + return statValuesArray(vm, object, 36); } static JSValue ProcessBindingFs_bigintStatValues(VM& vm, JSObject* object) { - auto* globalObject = object->globalObject(); - return JSC::JSBigInt64Array::create(globalObject, globalObject->m_typedArrayBigInt64.get(globalObject), 36); + return statValuesArray(vm, object, 36); } static JSValue ProcessBindingFs_statFsValues(VM& vm, JSObject* object) { - auto* globalObject = object->globalObject(); - return JSC::JSFloat64Array::create(globalObject, globalObject->m_typedArrayFloat64.get(globalObject), 7); + return statValuesArray(vm, object, 7); } static JSValue ProcessBindingFs_bigintStatFsValues(VM& vm, JSObject* object) { - auto* globalObject = object->globalObject(); - return JSC::JSBigInt64Array::create(globalObject, globalObject->m_typedArrayBigInt64.get(globalObject), 7); + return statValuesArray(vm, object, 7); } /* Source for ProcessBindingFs.lut.h diff --git a/src/jsc/bindings/ProcessBindingHTTPParser.cpp b/src/jsc/bindings/ProcessBindingHTTPParser.cpp index 1f27e544bb3f..50ed4b49190d 100644 --- a/src/jsc/bindings/ProcessBindingHTTPParser.cpp +++ b/src/jsc/bindings/ProcessBindingHTTPParser.cpp @@ -13,7 +13,8 @@ static constexpr ASCIILiteral httpAllMethodNames[] = { HTTP_ALL_METHOD_MAP(METHO static JSValue methodNamesArray(VM& vm, JSObject* binding, std::span names) { - auto scope = DECLARE_THROW_SCOPE(vm); + // reifyAllStaticProperties calls PropertyCallback builders without checking for exceptions, so check here. + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); JSGlobalObject* globalObject = binding->globalObject(); JSArray* methods = constructEmptyArray(globalObject, nullptr, names.size()); RETURN_IF_EXCEPTION(scope, {}); diff --git a/src/jsc/bindings/StringBuilderBinding.cpp b/src/jsc/bindings/StringBuilderBinding.cpp index 69a4ded43eb5..e83e8de08559 100644 --- a/src/jsc/bindings/StringBuilderBinding.cpp +++ b/src/jsc/bindings/StringBuilderBinding.cpp @@ -42,6 +42,9 @@ extern "C" void StringBuilder__appendUsize(WTF::StringBuilder* builder, size_t n extern "C" void StringBuilder__appendString(WTF::StringBuilder* builder, const BunString* str) { + // Upconverting an 8-bit builder for a 16-bit string reads capacity(), which asserts !hasOverflowed(). + if (builder->hasOverflowed()) [[unlikely]] + return; str->appendToBuilder(*builder); } @@ -77,5 +80,8 @@ extern "C" JSC::EncodedJSValue StringBuilder__toString(WTF::StringBuilder* build extern "C" void StringBuilder__ensureUnusedCapacity(WTF::StringBuilder* builder, size_t additional) { + // length() asserts !hasOverflowed(). + if (builder->hasOverflowed()) [[unlikely]] + return; builder->reserveCapacity(builder->length() + additional); } diff --git a/src/jsc/bindings/StringSizeLimit.h b/src/jsc/bindings/StringSizeLimit.h new file mode 100644 index 000000000000..61b4ef585b01 --- /dev/null +++ b/src/jsc/bindings/StringSizeLimit.h @@ -0,0 +1,25 @@ +#pragma once + +#include "root.h" +#include + +extern "C" size_t Bun__stringSyntheticAllocationLimit; + +namespace Bun { + +// True if a string of this length can't be allocated. Tests can lower the bound with the synthetic limit. +inline bool exceedsStringLimit(size_t length) +{ + return length > Bun__stringSyntheticAllocationLimit || length > WTF::StringImpl::MaxLength; +} + +// A StringBuilder doubles its reserved capacity at the first 16-bit character, so cap it at half the max 16-bit string length. +inline unsigned cappedStringBuilderReserve(size_t length) +{ + constexpr size_t max16BitLength = (std::numeric_limits::max() - sizeof(WTF::StringImpl)) / sizeof(char16_t); + static_assert(WTF::StringImpl::isValidLength(max16BitLength)); + static_assert(!WTF::StringImpl::isValidLength(max16BitLength + 1)); + return static_cast(std::min(length, max16BitLength / 2)); +} + +} // namespace Bun diff --git a/src/jsc/bindings/URLSearchParams.cpp b/src/jsc/bindings/URLSearchParams.cpp index 9f7b1efb543c..c6499dc99f47 100644 --- a/src/jsc/bindings/URLSearchParams.cpp +++ b/src/jsc/bindings/URLSearchParams.cpp @@ -26,8 +26,10 @@ #include "DOMURL.h" #include +#include #include "helpers.h" #include "JSURLSearchParams.h" +#include "VectorSizeLimit.h" namespace WebCore { @@ -46,9 +48,49 @@ extern "C" void URLSearchParams__toString(WebCore::URLSearchParams* urlSearchPar callback(ctx, &slice); } -URLSearchParams::URLSearchParams(const String& init, DOMURL* associatedURL) +static bool canDecodeURLEncodedPart(StringView part) +{ + // There's nothing to decode, so the parser returns the part unchanged. + if (part.containsOnlyASCII() && part.find('%') == notFound) + return true; + size_t utf8Length = part.is8Bit() + ? simdutf::utf8_length_from_latin1(reinterpret_cast(part.span8().data()), part.length()) + : simdutf::utf8_length_from_utf16le(part.span16().data(), part.length()); + return utf8Length <= Bun::maxVectorSize(); +} + +static bool tryAppendPair(Vector>& pairs, KeyValuePair&& pair) +{ + return pairs.size() < Bun::maxVectorSize>() && pairs.tryAppend(WTF::move(pair)); +} + +ExceptionOr>> URLSearchParams::parseURLEncodedForm(StringView input) +{ + // A UTF-16 code unit is at most 3 UTF-8 bytes. + bool mayBeTooLong = static_cast(input.length()) * 3 > Bun::maxVectorSize(); + Vector> pairs; + for (StringView pair : input.split('&')) { + if (mayBeTooLong) [[unlikely]] { + size_t equalIndex = pair.find('='); + if (equalIndex == notFound ? !canDecodeURLEncodedPart(pair) : !canDecodeURLEncodedPart(pair.left(equalIndex)) || !canDecodeURLEncodedPart(pair.substring(equalIndex + 1))) + return Exception { OutOfMemoryError }; + } + if (auto nameAndValue = WTF::URLParser::parseQueryNameAndValue(pair)) { + if (!tryAppendPair(pairs, WTF::move(*nameAndValue))) [[unlikely]] + return Exception { OutOfMemoryError }; + } + } + return WTF::move(pairs); +} + +static ExceptionOr>> parseSearch(const String& search) +{ + return URLSearchParams::parseURLEncodedForm(search.startsWith('?') ? StringView(search).substring(1) : StringView(search)); +} + +URLSearchParams::URLSearchParams(Vector>&& pairs, DOMURL* associatedURL) : m_associatedURL(associatedURL) - , m_pairs(init.startsWith('?') ? WTF::URLParser::parseURLEncodedForm(StringView(init).substring(1)) : WTF::URLParser::parseURLEncodedForm(init)) + , m_pairs(WTF::move(pairs)) { } @@ -59,6 +101,14 @@ URLSearchParams::URLSearchParams(const Vector>& pai URLSearchParams::~URLSearchParams() = default; +ExceptionOr> URLSearchParams::create(const String& init, DOMURL* associatedURL) +{ + auto pairs = parseSearch(init); + if (pairs.hasException()) + return pairs.releaseException(); + return adoptRef(*new URLSearchParams(pairs.releaseReturnValue(), associatedURL)); +} + ExceptionOr> URLSearchParams::create(std::variant>, Vector>, String>&& variant) { auto visitor = WTF::makeVisitor([&](const Vector>& vector) -> ExceptionOr> { @@ -66,9 +116,10 @@ ExceptionOr> URLSearchParams::create(std::variant>& pairs) -> ExceptionOr> { return adoptRef(*new URLSearchParams(pairs)); }, [&](const String& string) -> ExceptionOr> { return adoptRef(*new URLSearchParams(string, nullptr)); }); + return adoptRef(*new URLSearchParams(WTF::move(pairs))); }, [&](const Vector>& pairs) -> ExceptionOr> { return adoptRef(*new URLSearchParams(pairs)); }, [&](const String& string) -> ExceptionOr> { return create(string, nullptr); }); return std::visit(visitor, variant); } @@ -99,7 +150,7 @@ void URLSearchParams::sort() needsSorting = false; } -void URLSearchParams::set(const String& name, const String& value) +ExceptionOr URLSearchParams::set(const String& name, const String& value) { for (auto& pair : m_pairs) { if (pair.key != name) @@ -117,18 +168,18 @@ void URLSearchParams::set(const String& name, const String& value) }); updateURL(); needsSorting = true; - return; + return {}; } - m_pairs.append({ name, value }); - needsSorting = true; - updateURL(); + return append(name, value); } -void URLSearchParams::append(const String& name, const String& value) +ExceptionOr URLSearchParams::append(const String& name, const String& value) { - m_pairs.append({ name, value }); + if (!tryAppendPair(m_pairs, { name, value })) [[unlikely]] + return Exception { OutOfMemoryError }; updateURL(); needsSorting = true; + return {}; } Vector URLSearchParams::getAll(const StringView name) const @@ -163,11 +214,15 @@ void URLSearchParams::updateURL() m_associatedURL->markSearchParamsDirty(); } -void URLSearchParams::updateFromAssociatedURL() +ExceptionOr URLSearchParams::updateFromAssociatedURL() { ASSERT(m_associatedURL); - String search = m_associatedURL->search(); - m_pairs = search.startsWith('?') ? WTF::URLParser::parseURLEncodedForm(StringView(search).substring(1)) : WTF::URLParser::parseURLEncodedForm(search); + m_pairs.clear(); + auto pairs = parseSearch(m_associatedURL->search()); + if (pairs.hasException()) + return pairs.releaseException(); + m_pairs = pairs.releaseReturnValue(); + return {}; } std::optional> URLSearchParams::Iterator::next() diff --git a/src/jsc/bindings/URLSearchParams.h b/src/jsc/bindings/URLSearchParams.h index 2bc00cb59087..4a1dede86f60 100644 --- a/src/jsc/bindings/URLSearchParams.h +++ b/src/jsc/bindings/URLSearchParams.h @@ -41,19 +41,19 @@ class URLSearchParams : public RefCounted { ~URLSearchParams(); static ExceptionOr> create(std::variant>, Vector>, String>&&); - static Ref create(const String& string, DOMURL* associatedURL) - { - return adoptRef(*new URLSearchParams(string, associatedURL)); - } + static ExceptionOr> create(const String& string, DOMURL* associatedURL); - void append(const String& name, const String& value); + // Use this instead of WTF::URLParser::parseURLEncodedForm, which aborts if a name, a value or the list of pairs is too big for a Vector. + static ExceptionOr>> parseURLEncodedForm(StringView); + + ExceptionOr append(const String& name, const String& value); void remove(const StringView name, const String& value = {}); String get(const StringView name) const; Vector getAll(const StringView name) const; bool has(const StringView name, const String& value = {}) const; - void set(const String& name, const String& value); + ExceptionOr set(const String& name, const String& value); String toString() const; - void updateFromAssociatedURL(); + ExceptionOr updateFromAssociatedURL(); void sort(); size_t size() const { return m_pairs.size(); } size_t memoryCost() const; @@ -72,7 +72,7 @@ class URLSearchParams : public RefCounted { private: const Vector>& pairs() const { return m_pairs; } - URLSearchParams(const String&, DOMURL*); + URLSearchParams(Vector>&&, DOMURL*); URLSearchParams(const Vector>&); void updateURL(); diff --git a/src/jsc/bindings/VectorSizeLimit.h b/src/jsc/bindings/VectorSizeLimit.h index 56d49a53b165..dd84b42cffcc 100644 --- a/src/jsc/bindings/VectorSizeLimit.h +++ b/src/jsc/bindings/VectorSizeLimit.h @@ -25,4 +25,17 @@ size_t maxDequeSize() return std::max(std::bit_floor(maxVectorSize()), 1) - 1; } +// Fallible append and reserve for a Vector whose size is controlled by script. The caller should throw if they return false. +template +bool tryAppendWithinLimit(WTF::Vector& vector, U&& value) +{ + return vector.size() < maxVectorSize() && vector.tryAppend(std::forward(value)); +} + +template +bool tryReserveCapacityWithinLimit(WTF::Vector& vector, size_t capacity) +{ + return capacity <= maxVectorSize() && vector.tryReserveCapacity(capacity); +} + } // namespace Bun diff --git a/src/jsc/bindings/ZigException.cpp b/src/jsc/bindings/ZigException.cpp index 30c6ffa62300..a3d6a337df2c 100644 --- a/src/jsc/bindings/ZigException.cpp +++ b/src/jsc/bindings/ZigException.cpp @@ -148,16 +148,17 @@ static void populateStackFramePosition(const JSC::StackFrame& stackFrame, BunStr if (flags == PopulateStackTraceFlags::OnlyPosition) return; - if (source_lines_count > 1 && source_lines != nullptr && sourceString.is8Bit()) { - // Search for the beginning of the line - unsigned int lineStart = location.byte_position; + if (source_lines_count > 1 && source_lines != nullptr && sourceString.is8Bit() && !sourceString.isEmpty()) { + unsigned int maxSearch = sourceString.length(); + + // Search for the beginning of the line. byte_position equals the source length when the expression ends at the end of the source. + unsigned int lineStart = std::min(location.byte_position, maxSearch - 1); while (lineStart > 0 && sourceString[lineStart] != '\n') { lineStart--; } // Search for the end of the line unsigned int lineEnd = location.byte_position; - unsigned int maxSearch = sourceString.length(); while (lineEnd < maxSearch && sourceString[lineEnd] != '\n') { lineEnd++; } diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 59e3dea3dd5a..0f94b55fcfaa 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -160,6 +160,7 @@ #include "streams/JSWritableStreamDefaultWriter.h" #include "libusockets.h" #include "ModuleLoader.h" +#include "DLHandleMap.h" #include "napi_external.h" #include "napi_handle_scope.h" #include "napi_type_tag.h" @@ -289,6 +290,12 @@ namespace Bun { JSC::JSObject* createLintHost(JSC::VM&, JSC::JSGlobalObject*); } +extern "C" void Bun__VirtualMachine__setSamplingProfilerDirectory(void* bunVM, const BunString* directory); + +// The value of BUN_JSC_samplingProfilerPath. It isn't passed to JSC::Options, so VM::VM doesn't register JSC's atexit +// reporter, which crashes if fopen fails. VirtualMachine::write_profiles writes the report instead. +static const char8_t* s_samplingProfilerDirectory = nullptr; + extern "C" void JSCInitialize(const char* envp[], size_t envc, void (*onCrash)(const char* ptr, size_t length), bool evalMode, bool oneShotStartup, bool shortLivedGlobals) { static std::once_flag jsc_init_flag; @@ -390,6 +397,10 @@ extern "C" void JSCInitialize(const char* envp[], size_t envc, void (*onCrash)(c } } } + if (const char8_t* directory = JSC::Options::samplingProfilerPath()) { + s_samplingProfilerDirectory = directory; + JSC::Options::samplingProfilerPath() = nullptr; + } // $vm, which an engine built with assertions has, evaluates strings and makes global objects // that are not Bun's, where eval is on. After the loop: BUN_JSC_useDollarVM does not bring it back. if (Bun::codeGenerationFromStrings() != Bun::CodeGenerationFromStrings::Allowed) [[unlikely]] @@ -401,6 +412,11 @@ extern "C" void JSCInitialize(const char* envp[], size_t envc, void (*onCrash)(c // After the loop: BUN_JSC_useWasm=1 does not bring it back, and BUN_JSC_useWasm=0 stays a valid name. JSC::Options::useWasm() = false; #endif + // This is the only assertOptionsAreCoherent() rule that BUN_JSC_* can break, and that assertion aborts. + if (JSC::Options::useWasm() && !JSC::Options::useWasmIPInt() && !(JSC::Options::useBBQJIT() && JSC::Options::useJIT())) [[unlikely]] { + constexpr auto message = "BUN_JSC_useWasmIPInt=0 (needs useJIT and useBBQJIT, or BUN_JSC_useWasm=0)"_s; + onCrash(message.characters(), message.length()); + } JSC::Options::assertOptionsAreCoherent(); }); // end JSC::initialize lambda @@ -603,6 +619,15 @@ extern "C" JSC::JSGlobalObject* Zig__GlobalObject__create(void* console_client, setDefaultGlobalObject(vm, globalObject); JSC::gcProtect(globalObject); + // BUN_JSC_useSamplingProfiler makes every VM sample, so every VM needs the report directory. + if (s_samplingProfilerDirectory) { + auto directory = WTF::String::fromUTF8(unsafeSpan(s_samplingProfilerDirectory)); + if (!directory.isEmpty()) { + auto directoryString = Bun::toString(directory); + Bun__VirtualMachine__setSamplingProfilerDirectory(Bun__getVM(), &directoryString); + } + } + #ifdef FUZZILLI_ENABLED Bun__REPRL__registerFuzzilliFunctions(static_cast(globalObject)); #endif @@ -2493,8 +2518,8 @@ void GlobalObject::finishCreation(VM& vm) { OBJECT_OFFSETOF(GlobalObject, m_JSBunRequestStructure), [](const LazyProperty::Initializer& init) { init.set(Bun::createJSBunRequestStructure(init.vm, static_cast(init.owner))); } }, - { OBJECT_OFFSETOF(GlobalObject, m_NapiHandleScopeImplStructure), [](const LazyProperty::Initializer& init) { - init.set(Bun::NapiHandleScopeImpl::createStructure(init.vm, init.owner)); + { OBJECT_OFFSETOF(GlobalObject, m_HandleScopeImplStructure), [](const LazyProperty::Initializer& init) { + init.set(Bun::HandleScopeImpl::createStructure(init.vm, init.owner)); } }, { OBJECT_OFFSETOF(GlobalObject, m_NapiTypeTagStructure), [](const LazyProperty::Initializer& init) { init.set(Bun::NapiTypeTag::createStructure(init.vm, init.owner)); @@ -2616,9 +2641,6 @@ void GlobalObject::finishCreation(VM& vm) auto* globalObject = static_cast(init.owner); init.set(toJS(init.owner, globalObject, globalObject->performance().get()).getObject()); } }, - { OBJECT_OFFSETOF(GlobalObject, m_processEnvObject), [](const LazyProperty::Initializer& init) { - init.set(Bun::createEnvironmentVariablesMap(static_cast(init.owner)).getObject()); - } }, { OBJECT_OFFSETOF(GlobalObject, m_requireFunctionUnbound), [](const LazyProperty::Initializer& init) { init.set( JSFunction::create( @@ -2679,37 +2701,6 @@ void GlobalObject::finishCreation(VM& vm) { OBJECT_OFFSETOF(GlobalObject, m_errorConstructorPrepareStackTraceInternalValue), [](const LazyProperty::Initializer& init) { init.set(JSFunction::create(init.vm, init.owner, 2, "ErrorPrepareStackTrace"_s, jsFunctionDefaultErrorPrepareStackTrace, ImplementationVisibility::Public)); } }, - - { OBJECT_OFFSETOF(GlobalObject, m_utilInspectFunction), [](const LazyProperty::Initializer& init) { - auto scope = DECLARE_THROW_SCOPE(init.vm); - JSValue nodeUtilValue = uncheckedDowncast(init.owner)->internalModuleRegistry()->requireId(init.owner, init.vm, Bun::InternalModuleRegistry::Field::NodeUtil); - RETURN_IF_EXCEPTION(scope, ); - RELEASE_ASSERT(nodeUtilValue.isObject()); - auto prop = nodeUtilValue.getObject()->getIfPropertyExists(init.owner, Identifier::fromString(init.vm, "inspect"_s)); - RETURN_IF_EXCEPTION(scope, ); - ASSERT(prop); - init.set(uncheckedDowncast(prop)); - } }, - { OBJECT_OFFSETOF(GlobalObject, m_utilInspectStylizeColorFunction), [](const LazyProperty::Initializer& init) { - auto scope = DECLARE_THROW_SCOPE(init.vm); - JSC::MarkedArgumentBuffer args; - args.append(uncheckedDowncast(init.owner)->utilInspectFunction()); - RETURN_IF_EXCEPTION(scope, ); - - JSC::JSFunction* getStylize = JSC::JSFunction::create(init.vm, init.owner, utilInspectGetStylizeWithColorCodeGenerator(init.vm), init.owner); - RETURN_IF_EXCEPTION(scope, ); - - JSC::CallData callData = JSC::getCallData(getStylize); - NakedPtr returnedException = nullptr; - auto result = JSC::profiledCall(init.owner, ProfilingReason::API, getStylize, callData, jsNull(), args, returnedException); - RETURN_IF_EXCEPTION(scope, ); - - if (returnedException) { - throwException(init.owner, scope, returnedException.get()); - } - RETURN_IF_EXCEPTION(scope, ); - init.set(uncheckedDowncast(result)); - } }, { OBJECT_OFFSETOF(GlobalObject, m_utilInspectStylizeNoColorFunction), [](const LazyProperty::Initializer& init) { init.set(JSC::JSFunction::create(init.vm, init.owner, utilInspectStylizeWithNoColorCodeGenerator(init.vm), init.owner)); } }, @@ -2759,6 +2750,10 @@ void GlobalObject::finishCreation(VM& vm) [](const Initializer& init) { init.set(JSWeakMap::create(init.vm, init.owner->weakMapStructure())); }); + m_moduleSourceValueMap.initLater( + [](const Initializer& init) { + init.set(JSWeakMap::create(init.vm, init.owner->weakMapStructure())); + }); m_moduleGraphFrameStructure.initLater( [](const Initializer& init) { init.set(Bun::createModuleGraphFrameStructure(init.vm, init.owner)); @@ -2943,6 +2938,77 @@ EncodedJSValue GlobalObject::assignToStream(JSValue stream, JSValue controller) return JSC::JSValue::encode(result); } +JSC::JSFunction* GlobalObject::utilInspectFunction() +{ + if (auto* inspect = m_utilInspectFunction.get()) + return inspect; + + auto& vm = this->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + JSValue inspectModule = internalModuleRegistry()->requireId(this, vm, Bun::InternalModuleRegistry::Field::InternalUtilInspect); + RETURN_IF_EXCEPTION(scope, nullptr); + RELEASE_ASSERT(inspectModule.isObject()); + JSValue inspectValue = asObject(inspectModule)->get(this, Identifier::fromString(vm, "inspect"_s)); + RETURN_IF_EXCEPTION(scope, nullptr); + auto* inspect = dynamicDowncast(inspectValue); + if (!inspect) [[unlikely]] { + throwTypeError(this, scope, "util.inspect is not a function"_s); + return nullptr; + } + m_utilInspectFunction.set(vm, this, inspect); + return inspect; +} + +JSC::JSFunction* GlobalObject::utilInspectStylizeColorFunction() +{ + if (auto* stylize = m_utilInspectStylizeColorFunction.get()) + return stylize; + + auto& vm = this->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + JSFunction* inspect = utilInspectFunction(); + RETURN_IF_EXCEPTION(scope, nullptr); + + JSC::MarkedArgumentBuffer args; + args.append(inspect); + JSFunction* getStylize = JSFunction::create(vm, this, utilInspectGetStylizeWithColorCodeGenerator(vm), this); + JSValue result = JSC::profiledCall(this, ProfilingReason::API, getStylize, JSC::getCallData(getStylize), jsNull(), args); + RETURN_IF_EXCEPTION(scope, nullptr); + auto* stylize = uncheckedDowncast(result); + m_utilInspectStylizeColorFunction.set(vm, this, stylize); + return stylize; +} + +JSC::JSObject* GlobalObject::lazyRequireCacheObject() +{ + if (JSObject* cache = m_lazyRequireCacheObject.get()) + return cache; + + auto& vm = this->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + JSValue result = Bun::createRequireCacheObject(this, requireMap()); + RETURN_IF_EXCEPTION(scope, nullptr); + JSObject* cache = asObject(result); + m_lazyRequireCacheObject.set(vm, this, cache); + return cache; +} + +JSC::JSObject* GlobalObject::processEnvObject() +{ + if (JSObject* env = m_processEnvObject.get()) + return env; + + auto& vm = this->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + JSObject* env = Bun::createEnvironmentVariablesMap(this); + RETURN_IF_EXCEPTION(scope, nullptr); + // The Windows builtin can run user code that reads process.env and builds it first. + if (JSObject* existing = m_processEnvObject.get()) + return existing; + m_processEnvObject.set(vm, this, env); + return env; +} + JSC::GCClient::IsoSubspace* GlobalObject::subspaceForImpl(JSC::VM& vm) { return WebCore::subspaceForImpl(vm, BUN_SUBSPACE_SLOTS(m_clientSubspaceForWorkerGlobalScope, m_subspaceForWorkerGlobalScope), @@ -2991,6 +3057,8 @@ void GlobalObject::addBuiltinGlobals(JSC::VM& vm) { BuiltinName::k_esmRegistryEvaluatedKeys, 1, functionEsmRegistryEvaluatedKeys }, { BuiltinName::k_esmRegistryHasEvaluated, 2, functionEsmRegistryHasEvaluated }, { BuiltinName::k_esmLoadSync, 2, functionEsmLoadSync }, + { BuiltinName::k_requireCommonJS, 5, Bun::jsFunctionRequireCommonJS }, + { BuiltinName::k_requireNativeModule, 1, Bun::jsFunctionRequireNativeModule }, { BuiltinName::k_makeErrorWithCode, 2, jsFunctionMakeErrorWithCode }, { BuiltinName::k_toClass, 1, jsFunctionToClass }, { BuiltinName::k_inherits, 1, jsFunctionInherits }, @@ -4550,7 +4618,7 @@ napi_env GlobalObject::makeNapiEnvForFFI() // collected, but its NapiEnvs may outlive it — GC-enqueued NapiFinalizerTasks // hold Ref and run on the event loop while loading the *next* file. // NapiEnv::m_globalObject is a raw pointer; Finalizer.run opens a -// NapiHandleScope through it, which writes m_currentNapiHandleScopeImpl on the +// NapiHandleScope through it, which writes m_currentHandleScopeImpl on the // dead old global and trips `ASSERT(isMarked(cell))` in // Heap::addToRememberedSet (release: the concurrent marker later visits it and // segfaults at offset 0x68/0xD0). Retarget every env to the new global and @@ -4665,6 +4733,7 @@ extern "C" void Bun__GlobalObject__clearExceptionsForExit(Zig::GlobalObject* glo static void destroyVM(JSC::VM& vm) { + auto identifier = vm.identifier(); vm.heap.collectNow(JSC::Sync, JSC::CollectionScope::Full); // Every JSLockHolder still on the native stack (process.exit() from inside a JS callback, // the worker thread's manual API lock) holds a RefPtr that will never destruct because @@ -4673,6 +4742,7 @@ static void destroyVM(JSC::VM& vm) for (uint32_t n = vm.refCount(); n > 1; --n) vm.derefSuppressingSaferCPPChecking(); vm.derefSuppressingSaferCPPChecking(); + Bun::DLHandleMap::singleton().vmDestroyed(identifier); } extern "C" void Zig__GlobalObject__prepareForDestruction(Zig::GlobalObject* globalObject) diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index 8aa44e960a7b..a829e8b3a72f 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -34,7 +34,7 @@ class JSBuiltinInternalFunctions; namespace Bun { class InternalModuleRegistry; -class NapiHandleScopeImpl; +class HandleScopeImpl; class JSNextTickQueue; class Process; class SecureContextCache; @@ -270,8 +270,9 @@ class GlobalObject : public Bun::GlobalScope { JSC::Structure* callSiteStructure() const { return m_callSiteStructure.getInitializedOnMainThread(this); } JSC::Structure* utilInspectOptionsStructure() const { return m_utilInspectOptionsStructure.getInitializedOnMainThread(this); } - JSC::JSFunction* utilInspectFunction() const { return m_utilInspectFunction.getInitializedOnMainThread(this); } - JSC::JSFunction* utilInspectStylizeColorFunction() const { return m_utilInspectStylizeColorFunction.getInitializedOnMainThread(this); } + // These two return nullptr with an exception pending if internal/util/inspect fails to load. + JSC::JSFunction* utilInspectFunction(); + JSC::JSFunction* utilInspectStylizeColorFunction(); JSC::JSFunction* utilInspectStylizeNoColorFunction() const { return m_utilInspectStylizeNoColorFunction.getInitializedOnMainThread(this); } JSC::JSFunction* wasmStreamingConsumeStreamFunction() const { return m_wasmStreamingConsumeStreamFunction.getInitializedOnMainThread(this); } @@ -285,7 +286,7 @@ class GlobalObject : public Bun::GlobalScope { JSObject* processBindingFs() const { return m_processBindingFs.getInitializedOnMainThread(this); } JSObject* processBindingHTTPParser() const { return m_processBindingHTTPParser.getInitializedOnMainThread(this); } - JSObject* lazyRequireCacheObject() const { return m_lazyRequireCacheObject.getInitializedOnMainThread(this); } + JSObject* lazyRequireCacheObject(); Bun::JSCommonJSExtensions* lazyRequireExtensionsObject() const { return m_lazyRequireExtensionsObject.getInitializedOnMainThread(this); } JSC::JSFunction* modulePrototypeUnderscoreCompileFunction() const { return m_modulePrototypeUnderscoreCompileFunction.getInitializedOnMainThread(this); } JSC::JSFunction* requireESMFromHijackedExtension() const { return m_commonJSRequireESMFromHijackedExtensionFunction.getInitializedOnMainThread(this); } @@ -304,6 +305,8 @@ class GlobalObject : public Bun::GlobalScope { Structure* AsyncContextFrameStructure() const { return m_asyncBoundFunctionStructure.getInitializedOnMainThread(this); } JSWeakMap* vmModuleContextMap() const { return m_vmModuleContextMap.getInitializedOnMainThread(this); } + // Maps a JSSourceCode to the value its synthetic module exports (ObjectModule.cpp). + JSWeakMap* moduleSourceValueMap() const { return m_moduleSourceValueMap.getInitializedOnMainThread(this); } // Made with the first Bun.ModuleGraph (ModuleGraph.cpp). bool hasModuleGraphs() const { return !!m_moduleGraphs; } @@ -312,7 +315,7 @@ class GlobalObject : public Bun::GlobalScope { Structure* NapiExternalStructure() const { return m_NapiExternalStructure.getInitializedOnMainThread(this); } Structure* NapiPrototypeStructure() const { return m_NapiPrototypeStructure.getInitializedOnMainThread(this); } - Structure* NapiHandleScopeImplStructure() const { return m_NapiHandleScopeImplStructure.getInitializedOnMainThread(this); } + Structure* HandleScopeImplStructure() const { return m_HandleScopeImplStructure.getInitializedOnMainThread(this); } Structure* NapiTypeTagStructure() const { return m_NapiTypeTagStructure.getInitializedOnMainThread(this); } Structure* NativePromiseContextStructure() const { return m_NativePromiseContextStructure.getInitializedOnMainThread(this); } @@ -333,7 +336,8 @@ class GlobalObject : public Bun::GlobalScope { void forbidExecution(); Bun::Process* processObject() const { return m_processObject.getInitializedOnMainThread(this); } - JSC::JSObject* processEnvObject() const { return m_processEnvObject.getInitializedOnMainThread(this); } + // Returns null with an exception pending if building it throws. A failure isn't cached. + JSC::JSObject* processEnvObject(); JSC::JSObject* bunObject() const { return m_bunObject.getInitializedOnMainThread(this); } uint8_t drainMicrotasks(); @@ -449,7 +453,6 @@ class GlobalObject : public Bun::GlobalScope { using LazyPropertyOfGlobalObject = LazyProperty; using ThenablesArray = std::array, promiseFunctionsSize + 1>; - using NapiModuleAndExports = std::array, 2>; // Native module default-export cache so require(id) === (await import(id)).default. // Visited via FOR_EACH_GLOBALOBJECT_GC_MEMBER's std::array overload. using NativeModuleDefaultsArray = std::array, NativeModuleDefaultSlotCount>; @@ -520,13 +523,10 @@ class GlobalObject : public Bun::GlobalScope { /* Error.prepareStackTrace */ \ V(public, WriteBarrier, m_errorConstructorPrepareStackTraceValue) \ \ - /* When a napi module initializes on dlopen, we need to know what the value is */ \ - V(public, NapiModuleAndExports, m_pendingNapiModuleAndExports) \ - \ - /* The handle scope where all new NAPI values will be created. You must not pass any napi_values */ \ - /* back to a NAPI function without putting them in the handle scope, as the NAPI function may */ \ - /* move them off the stack which will cause them to get collected if not in the handle scope. */ \ - V(public, JSC::WriteBarrier, m_currentNapiHandleScopeImpl) \ + /* The innermost open handle scope (Node-API or V8), where new napi_values and V8 handles are */ \ + /* created. You must not pass any napi_values back to a NAPI function without putting them in the */ \ + /* handle scope, as the NAPI function may move them off the stack, and they would then be collected. */ \ + V(public, JSC::WriteBarrier, m_currentHandleScopeImpl) \ \ /* Supports getEnvironmentData() and setEnvironmentData(), and is cloned into newly-created */ \ /* Workers. Initialized in createNodeWorkerThreadsBinding. */ \ @@ -554,7 +554,7 @@ class GlobalObject : public Bun::GlobalScope { \ V(public, Bun::JSMockModule, mockModule) \ \ - V(public, LazyPropertyOfGlobalObject, m_processEnvObject) \ + V(public, WriteBarrier, m_processEnvObject) \ \ V(public, LazyPropertyOfGlobalObject, m_JSS3FileStructure) \ V(public, LazyPropertyOfGlobalObject, m_S3ErrorStructure) \ @@ -625,9 +625,9 @@ class GlobalObject : public Bun::GlobalScope { V(private, LazyPropertyOfGlobalObject, m_pendingVirtualModuleResultStructure) \ V(private, LazyPropertyOfGlobalObject, m_JSSocketHandlersStructure) \ V(private, LazyPropertyOfGlobalObject, m_nativeMicrotaskTrampoline) \ - V(private, LazyPropertyOfGlobalObject, m_utilInspectFunction) \ + V(private, WriteBarrier, m_utilInspectFunction) \ V(private, LazyPropertyOfGlobalObject, m_utilInspectOptionsStructure) \ - V(private, LazyPropertyOfGlobalObject, m_utilInspectStylizeColorFunction) \ + V(private, WriteBarrier, m_utilInspectStylizeColorFunction) \ V(private, LazyPropertyOfGlobalObject, m_utilInspectStylizeNoColorFunction) \ V(private, LazyPropertyOfGlobalObject, m_wasmStreamingConsumeStreamFunction) \ V(private, WebCore::JSStreamsRuntime, m_streamsRuntime) \ @@ -641,7 +641,8 @@ class GlobalObject : public Bun::GlobalScope { V(private, LazyPropertyOfGlobalObject, m_JSBufferSubclassStructure) \ V(private, LazyPropertyOfGlobalObject, m_JSResizableOrGrowableSharedBufferSubclassStructure) \ V(private, LazyPropertyOfGlobalObject, m_vmModuleContextMap) \ - V(public, LazyPropertyOfGlobalObject, m_lazyRequireCacheObject) \ + V(private, LazyPropertyOfGlobalObject, m_moduleSourceValueMap) \ + V(private, WriteBarrier, m_lazyRequireCacheObject) \ V(public, LazyPropertyOfGlobalObject, m_lazyRequireExtensionsObject) \ V(private, LazyPropertyOfGlobalObject, m_lazyTestModuleObject) \ V(public, LazyPropertyOfGlobalObject, m_testMatcherUtilsObject) \ @@ -672,7 +673,7 @@ class GlobalObject : public Bun::GlobalScope { \ V(private, LazyPropertyOfGlobalObject, m_NapiExternalStructure) \ V(private, LazyPropertyOfGlobalObject, m_NapiPrototypeStructure) \ - V(private, LazyPropertyOfGlobalObject, m_NapiHandleScopeImplStructure) \ + V(private, LazyPropertyOfGlobalObject, m_HandleScopeImplStructure) \ V(private, LazyPropertyOfGlobalObject, m_NapiTypeTagStructure) \ V(private, LazyPropertyOfGlobalObject, m_NativePromiseContextStructure) \ \ @@ -716,18 +717,10 @@ class GlobalObject : public Bun::GlobalScope { WTF::String m_moduleWrapperStart; WTF::String m_moduleWrapperEnd; - // This is the result of dlopen()ing a napi module. - // We will add it to the resulting napi value. - void* m_pendingNapiModuleDlopenHandle = nullptr; - // Store ALL napi module structs to defer calling nm_register_func until after dlopen completes // A single .node file can register multiple modules during static constructors WTF::Vector m_pendingNapiModules; - // Temporary storage for current NAPI module being executed - // Used by executePendingNapiModule to execute one module at a time - std::optional m_pendingNapiModule = {}; - // Store ALL V8 C++ module pointers to defer execution until after dlopen completes // A single .node file can register multiple V8 modules during static constructors WTF::Vector m_pendingV8Modules; diff --git a/src/jsc/bindings/ZigSourceProvider.cpp b/src/jsc/bindings/ZigSourceProvider.cpp index cdc5d0277c71..ed9bfc3cb50f 100644 --- a/src/jsc/bindings/ZigSourceProvider.cpp +++ b/src/jsc/bindings/ZigSourceProvider.cpp @@ -267,6 +267,8 @@ static bool generateCachedByteCodeFromSourceCode(const BunString* sourceProvider JSC::UnlinkedCodeBlock* unlinkedCodeBlock = generateUnlinkedCodeForBytecodeCache(vm, sourceProviderURL, inputSourceCode, isModule, depth, optimize, sourceCode, key); if (!unlinkedCodeBlock) return false; + if (!externalStrings && Bun::hasStringTooLongForBytecodeCache(vm, sourceCode)) + return false; RefPtr cachedBytecode = JSC::encodeCodeBlock(vm, key, unlinkedCodeBlock, externalStrings, JSC::BytecodeCacheUpdatable::No); if (!cachedBytecode) @@ -412,6 +414,22 @@ extern "C" BunString ZigSourceProvider__getSourceSlice(SourceProvider* provider) namespace Bun { +bool hasStringTooLongForBytecodeCache(JSC::VM& vm, const JSC::SourceCode& source) +{ + constexpr unsigned limit = 1u << 27; + if (static_cast(source.length()) < limit) + return false; + auto& provider = *source.provider(); + if (provider.sourceURLDirective().length() >= limit || provider.sourceMappingURLDirective().length() >= limit) + return true; + // Every other string in the unlinked code is a parser identifier, so it's in the atom table. + for (auto& atom : vm.atomStringTable()->table()) { + if (atom->length() >= limit) + return true; + } + return false; +} + // bun:internal-for-testing: whether the source of a function knows where its lines start. Before anything asked for a // position in it, that means they came with its code: from the parse, or out of the bytecode. JSC_DEFINE_HOST_FUNCTION(jsSourceHasLineStarts, (JSC::JSGlobalObject*, JSC::CallFrame* callFrame)) diff --git a/src/jsc/bindings/ZigSourceProvider.h b/src/jsc/bindings/ZigSourceProvider.h index f6bef30fe954..870b45013e6d 100644 --- a/src/jsc/bindings/ZigSourceProvider.h +++ b/src/jsc/bindings/ZigSourceProvider.h @@ -6,6 +6,7 @@ namespace JSC { class Structure; class Identifier; +class SourceCode; class SourceCodeKey; class SourceProvider; } // namespace JSC @@ -77,6 +78,10 @@ class SourceProvider final : public JSC::SourceProvider { namespace Bun { +// JSC's bytecode encoder aborts on a string of 2^27 or more characters unless it encodes against a string table. +// Call this after `source`'s unlinked code is generated. +bool hasStringTooLongForBytecodeCache(JSC::VM&, const JSC::SourceCode& source); + // Bytecode that is a section of the executable: not owned, and there for the life of the process. `entryOffset` is where // the module's cache entry starts in `bytes` when they are the one payload of a link. inline Ref embeddedBytecode(std::span bytes, uint32_t entryOffset) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 2449f96c55b7..2266cc3cfd7d 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -43,6 +43,7 @@ #include "JavaScriptCore/ExceptionHelpers.h" #include "JavaScriptCore/ExceptionScope.h" #include "JavaScriptCore/FunctionConstructor.h" +#include "JavaScriptCore/HeapProfiler.h" #include "JavaScriptCore/HeapSnapshotBuilder.h" #include "JavaScriptCore/Identifier.h" #include "JavaScriptCore/IteratorOperations.h" @@ -2438,10 +2439,11 @@ bool WebCore__FetchHeaders__fastHas_(WebCore::FetchHeaders* arg0, unsigned char return arg0->fastHas(static_cast(HTTPHeaderName1)); } -void WebCore__FetchHeaders__copyTo(WebCore::FetchHeaders* headers, StringPointer* names, StringPointer* values, unsigned char* buf) +// `names` and `values` each have room for `count` entries, and `buf` has room for `buf_len` bytes. +void WebCore__FetchHeaders__copyTo(WebCore::FetchHeaders* headers, StringPointer* names, StringPointer* values, uint32_t count, unsigned char* buf, uint32_t buf_len) { auto iter = headers->createIterator(false); - unsigned int i = 0; + uint32_t i = 0; while (auto* entry = iter.nextEntry()) { const auto& name = *entry->key; @@ -2450,6 +2452,11 @@ void WebCore__FetchHeaders__copyTo(WebCore::FetchHeaders* headers, StringPointer ASSERT_WITH_MESSAGE(name.length(), "Header name must not be empty"); ASSERT_WITH_MESSAGE(name.containsOnlyASCII(), "Header name must be ASCII. This should already be validated before calling this function."); + // Each header writes one entry to `names` and `values`, and one byte per code unit to `buf`. + RELEASE_ASSERT(count > 0); + RELEASE_ASSERT(name.length() <= buf_len - i && value.length() <= buf_len - i - name.length()); + count--; + if (name.is8Bit()) { const auto nameSpan = name.span8(); memcpy(&buf[i], nameSpan.data(), nameSpan.size()); @@ -2485,7 +2492,8 @@ void WebCore__FetchHeaders__copyTo(WebCore::FetchHeaders* headers, StringPointer values++; } } -void WebCore__FetchHeaders__count(WebCore::FetchHeaders* headers, uint32_t* count, uint32_t* buf_len) +// Returns false if the total length of the names and values overflows a StringPointer's uint32_t fields. +bool WebCore__FetchHeaders__count(WebCore::FetchHeaders* headers, uint32_t* count, uint32_t* buf_len) { uint32_t entries = 0; size_t bytes = 0; @@ -2505,8 +2513,11 @@ void WebCore__FetchHeaders__count(WebCore::FetchHeaders* headers, uint32_t* coun for (auto& cookie : map.getSetCookieHeaders()) add(WTF::httpHeaderNameStringImpl(HTTPHeaderName::SetCookie), cookie); + if (bytes > std::numeric_limits::max()) + return false; *count = entries; - *buf_len = bytes; + *buf_len = static_cast(bytes); + return true; } typedef struct ZigSliceString { @@ -3334,12 +3345,13 @@ JSC::JSObject* JSC__JSCell__toObject(JSC::JSCell* cell, JSC::JSGlobalObject* glo // Throws (and returns empty) when resolving a rope runs out of memory. // `JSString::view`: a substring rope is viewed in place; other ropes resolve // (and can throw on OOM). The characters belong to `str` (or its base), which -// the caller keeps alive. -BunString JSC__JSString__view(JSC::JSString* str, JSC::JSGlobalObject* global) +// the caller keeps alive through `*owner`. +BunString JSC__JSString__view(JSC::JSString* str, JSC::JSGlobalObject* global, const JSC::JSString** owner) { auto scope = DECLARE_THROW_SCOPE(JSC::getVM(global)); auto view = str->view(global); RETURN_IF_EXCEPTION(scope, BunStringEmpty); + *owner = uncheckedDowncast(view.owner); return Bun::toStringView(view.data); } @@ -3488,8 +3500,9 @@ bool JSC__JSValue__asArrayBuffer( // time and, because ArrayBuffers are only reclaimed by full collections, // turns every threadpool fs/zlib/crypto op over a fresh Buffer into full-GC // pressure. Such a view cannot be detached without JS first touching -// `.buffer`; if it does so mid-op the new ArrayBuffer is unpinned and a -// `transfer()` moves (does not free) the storage — the same window Node has. +// `.buffer`. Known gap: if JS does that mid-op, the new ArrayBuffer is +// unpinned, so `transfer(0)` frees the storage at once and a same-length +// `transfer()` frees it when the new owner is collected. // The caller keeps the returned kind and only calls unpin for `Pinned`; a // held view is kept alive by the caller's own root, and nothing here needs // undoing for it. @@ -3508,7 +3521,7 @@ static PinKind pinStorage(JSC::JSValue value) return PinKind::Held; buf = view->possiblySharedBuffer(); } - if (!buf) + if (!buf || buf->isDetached()) return PinKind::None; if (!buf->isShared()) buf->pin(); @@ -3518,7 +3531,7 @@ CPP_DECL uint8_t JSC__JSValue__pinArrayBuffer(JSC::EncodedJSValue v) { return static_cast(pinStorage(JSC::JSValue::decode(v))); } -// Only for a value `pinStorage` answered `Pinned` for: that buffer still exists (pinned buffers are not detached). +// Only call this for a value that `pinStorage` returned `Pinned` for. That buffer still exists because pinned buffers can't be detached. CPP_DECL void JSC__JSValue__unpinArrayBuffer(JSC::EncodedJSValue v) { // Reached from finalizers during GC sweep, where classInfo() (and so any @@ -3542,6 +3555,51 @@ CPP_DECL void JSC__JSValue__unpinArrayBuffer(JSC::EncodedJSValue v) buf->unpin(); } +// Returns the ArrayBuffer that `value` already has, or null. Never materializes one. +static ALWAYS_INLINE JSC::ArrayBuffer* existingArrayBuffer(JSC::JSValue value) +{ + if (auto* view = dynamicDowncast(value)) + return view->hasArrayBuffer() ? view->possiblySharedBuffer() : nullptr; + if (auto* jb = dynamicDowncast(value)) + return jb->impl(); + return nullptr; +} + +CPP_DECL bool JSC__JSValue__isNonSharedWasmMemory(JSC::EncodedJSValue v) +{ + auto* buf = existingArrayBuffer(JSC::JSValue::decode(v)); + return buf && buf->isWasmMemory() && !buf->isShared(); +} + +// See `ArrayBuffer::can_be_freed_while_pinned` in array_buffer.rs. +CPP_DECL bool JSC__JSValue__canBeFreedWhilePinned(JSC::EncodedJSValue v) +{ + auto* buf = existingArrayBuffer(JSC::JSValue::decode(v)); + return buf && !buf->isShared() && (buf->isResizableOrGrowableShared() || buf->isWasmMemory()); +} + +// Returns `v`'s current byte range, or null and 0 if it's detached. +CPP_DECL void JSC__JSValue__arrayBufferExtent(JSC::EncodedJSValue v, uint8_t** out_ptr, size_t* out_len) +{ + auto value = JSC::JSValue::decode(v); + if (auto* view = dynamicDowncast(value)) { + if (!view->isDetached()) { + *out_ptr = static_cast(view->vector()); + *out_len = view->byteLength(); + return; + } + } else if (auto* jb = dynamicDowncast(value)) { + auto* buf = jb->impl(); + if (buf && !buf->isDetached()) { + *out_ptr = static_cast(buf->data()); + *out_len = buf->byteLength(); + return; + } + } + *out_ptr = nullptr; + *out_len = 0; +} + // Borrow `v`'s byte storage for off-thread reading. Splits out only the // `FastTypedArray` case from `pinArrayBuffer`, because that's the one mode // where `possiblySharedBuffer()` actually COPIES data @@ -4012,14 +4070,20 @@ JSC::EncodedJSValue JSC__JSGlobalObject__generateHeapSnapshot(JSC::JSGlobalObjec Bun__Feature__heap_snapshot += 1; - JSC::HeapSnapshotBuilder snapshotBuilder(vm.ensureHeapProfiler()); + auto& heapProfiler = vm.ensureHeapProfiler(); + heapProfiler.clearSnapshots(); + + JSC::HeapSnapshotBuilder snapshotBuilder(heapProfiler); snapshotBuilder.buildSnapshot(); WTF::String jsonString = snapshotBuilder.json(); + if (jsonString.isNull()) [[unlikely]] { + JSC::throwOutOfMemoryError(globalObject, scope); + return {}; + } + JSC::JSValue result = JSONParseWithException(globalObject, jsonString); RETURN_IF_EXCEPTION(scope, {}); - JSC::EncodedJSValue result = JSC::JSValue::encode(JSONParse(globalObject, jsonString)); - scope.releaseAssertNoException(); - return result; + return JSC::JSValue::encode(result); } // One load. always_inline so ThinLTO importers and the inliner never leave @@ -4109,8 +4173,7 @@ extern "C" [[ZIG_EXPORT(check_slow)]] void JSC__JSValue__putMayBeIndex(JSC::Enco auto& vm = JSC::getVM(globalObject); ThrowScope scope = DECLARE_THROW_SCOPE(vm); - WTF::String keyStr = key->tag == BunStringTag::Empty ? WTF::emptyString() : key->toWTFString(); - JSC::Identifier identifier = JSC::Identifier::fromString(vm, keyStr); + JSC::Identifier identifier = Bun::toIdentifier(vm, *key); JSC::JSObject* object = JSC::JSValue::decode(target).asCell()->getObject(); object->putDirectMayBeIndex(globalObject, JSC::PropertyName(identifier), JSC::JSValue::decode(value)); @@ -4427,8 +4490,7 @@ extern "C" JSC::EncodedJSValue JSC__JSValue__getOwn(JSC::EncodedJSValue JSValue0 VM& vm = globalObject->vm(); auto scope = DECLARE_THROW_SCOPE(vm); JSValue value = JSC::JSValue::decode(JSValue0); - WTF::String propertyNameString = propertyName->tag == BunStringTag::Empty ? WTF::emptyString() : propertyName->toWTFString(BunString::ZeroCopy); - auto identifier = JSC::Identifier::fromString(vm, propertyNameString); + auto identifier = Bun::toIdentifier(vm, *propertyName); auto property = JSC::PropertyName(identifier); PropertySlot slot(value, PropertySlot::InternalMethodType::GetOwnProperty); bool hasSlot = value.getOwnPropertySlot(globalObject, property, slot); @@ -4656,8 +4718,8 @@ JSC::JSObject* JSC__JSValue__toObject(JSC::EncodedJSValue JSValue0, JSC::JSGloba return value.toStringOrNull(arg1); } -/// `toStringOrNull` + `JSString::view` in one call. -JSC::JSString* JSC__JSValue__toJSStringView(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* global, BunString* view) +/// `toStringOrNull` + `JSString::view` in one call. `*owner` is the cell that owns the characters. +JSC::JSString* JSC__JSValue__toJSStringView(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* global, BunString* view, const JSC::JSString** owner) { auto scope = DECLARE_THROW_SCOPE(JSC::getVM(global)); auto* str = JSC::JSValue::decode(JSValue0).toStringOrNull(global); @@ -4665,6 +4727,7 @@ JSC::JSString* JSC__JSValue__toJSStringView(JSC::EncodedJSValue JSValue0, JSC::J auto data = str->view(global); RETURN_IF_EXCEPTION(scope, nullptr); *view = Bun::toStringView(data.data); + *owner = uncheckedDowncast(data.owner); return str; } @@ -5441,7 +5504,11 @@ static void JSC__JSValue__forEachPropertyImpl(JSC::EncodedJSValue JSValue0, JSC: // Ignore exceptions from getters. if (scope.exception()) [[unlikely]] { - (void)scope.tryClearException(); + if (!scope.tryClearException()) + return; + // A CustomValue getter that throws (e.g. Bun.$) didn't produce a value. Skip it, like a lazy builder that throws above. + if (slot.attributes() & PropertyAttribute::CustomValue) + continue; propertyValue = jsUndefined(); } @@ -6130,15 +6197,28 @@ extern "C" int JSC__JSValue__DateNowISOString(JSC::JSGlobalObject* globalObject, #pragma mark - WebCore::DOMFormData -CPP_DECL void WebCore__DOMFormData__append(WebCore::DOMFormData* arg0, const EncodedSlice* arg1, const EncodedSlice* arg2) +// Both append functions throw and return false on failure. +CPP_DECL bool WebCore__DOMFormData__append(WebCore::DOMFormData* arg0, JSC::JSGlobalObject* globalObject, const EncodedSlice* arg1, const EncodedSlice* arg2) { - arg0->append(toStringCopy(*arg1), toStringCopy(*arg2)); + auto throwScope = DECLARE_THROW_SCOPE(globalObject->vm()); + auto result = arg0->append(toStringCopy(*arg1), toStringCopy(*arg2)); + if (result.hasException()) [[unlikely]] { + WebCore::propagateException(*globalObject, throwScope, result.releaseException()); + return false; + } + return true; } -CPP_DECL void WebCore__DOMFormData__appendBlob(WebCore::DOMFormData* arg0, JSC::JSGlobalObject* arg1, const EncodedSlice* arg2, void* blobValueInner, const EncodedSlice* fileName) +CPP_DECL bool WebCore__DOMFormData__appendBlob(WebCore::DOMFormData* arg0, JSC::JSGlobalObject* arg1, const EncodedSlice* arg2, void* blobValueInner, const EncodedSlice* fileName) { + auto throwScope = DECLARE_THROW_SCOPE(arg1->vm()); RefPtr blob = WebCore::Blob::create(blobValueInner); - arg0->append(toStringCopy(*arg2), blob, toStringCopy(*fileName)); + auto result = arg0->append(toStringCopy(*arg2), blob, toStringCopy(*fileName)); + if (result.hasException()) [[unlikely]] { + WebCore::propagateException(*arg1, throwScope, result.releaseException()); + return false; + } + return true; } CPP_DECL size_t WebCore__DOMFormData__count(WebCore::DOMFormData* arg0) { @@ -6167,8 +6247,13 @@ CPP_DECL JSC::EncodedJSValue WebCore__DOMFormData__createFromURLQuery(JSC::JSGlo auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); return Bun::ERR::STRING_TOO_LONG(scope, globalObject); } - auto formData = DOMFormData::create(globalObject->scriptExecutionContext(), WTF::move(str)); - return JSValue::encode(toJSNewlyCreated(arg0, globalObject, WTF::move(formData))); + auto formData = DOMFormData::create(globalObject->scriptExecutionContext(), str); + if (formData.hasException()) [[unlikely]] { + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + WebCore::propagateException(*globalObject, scope, formData.releaseException()); + return {}; + } + return JSValue::encode(toJSNewlyCreated(arg0, globalObject, formData.releaseReturnValue())); } CPP_DECL JSC::EncodedJSValue WebCore__DOMFormData__create(JSC::JSGlobalObject* arg0) @@ -6779,6 +6864,7 @@ extern "C" JSC::EncodedJSValue Bun__REPL__formatValue( // Get the util.inspect function from the global object auto* bunGlobal = uncheckedDowncast(globalObject); JSC::JSValue inspectFn = bunGlobal->utilInspectFunction(); + RETURN_IF_EXCEPTION(scope, JSC::JSValue::encode(JSC::jsUndefined())); if (!inspectFn || !inspectFn.isCallable()) { // Fallback to toString if util.inspect is not available diff --git a/src/jsc/bindings/bun-spawn.cpp b/src/jsc/bindings/bun-spawn.cpp index 747bc57a20e3..74caf0341111 100644 --- a/src/jsc/bindings/bun-spawn.cpp +++ b/src/jsc/bindings/bun-spawn.cpp @@ -260,6 +260,10 @@ extern "C" ssize_t posix_spawn_bun( #endif sigfillset(&blockall); +#if OS(LINUX) + // The kernel kills the process if a seccomp trap fires while SIGSYS is blocked. + sigdelset(&blockall, SIGSYS); +#endif sigprocmask(SIG_SETMASK, &blockall, &oldmask); #if !OS(ANDROID) pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &cs); @@ -309,6 +313,11 @@ extern "C" ssize_t posix_spawn_bun( struct sigaction sa = { 0 }; sa.sa_handler = SIG_DFL; for (int i = 0; i < NSIG; i++) { +#if OS(LINUX) + // close_range() below still needs the handler that turns a seccomp trap into ENOSYS. + if (i == SIGSYS) + continue; +#endif sigaction(i, &sa, 0); } @@ -457,13 +466,17 @@ extern "C" ssize_t posix_spawn_bun( } #endif - sigprocmask(SIG_SETMASK, &childmask, 0); if (!envp) envp = environ; // Close all fds > current_max_fd, preferring cloexec if available closeRangeOrLoop(current_max_fd + 1, INT_MAX, true); + // The caller's mask may block SIGSYS, so apply it after close_range(). + sigprocmask(SIG_SETMASK, &childmask, 0); +#if OS(LINUX) + sigaction(SIGSYS, &sa, 0); +#endif if (execve(path, argv, envp) == -1) { return childFailed(); } diff --git a/src/jsc/bindings/c-bindings.cpp b/src/jsc/bindings/c-bindings.cpp index f44e7eaa17dd..398c3c5880d8 100644 --- a/src/jsc/bindings/c-bindings.cpp +++ b/src/jsc/bindings/c-bindings.cpp @@ -1,6 +1,7 @@ // when we don't want to use @cInclude, we can just stick wrapper functions here #include "root.h" #include +#include #if !OS(WINDOWS) #include @@ -32,6 +33,23 @@ #endif // !OS(WINDOWS) #include +// Runs before Output and the Windows env block are initialized, so it uses the CRT. +extern "C" [[noreturn]] void bun_abort_missing_simd() +{ +#if CPU(X86_64) + fputs("error: this CPU is missing SSE4.2 support, which Bun requires.\n" + " If this is a VM, enable host CPU passthrough (e.g. -cpu host for QEMU/KVM).\n", + stderr); +#else + fputs("error: this CPU is missing SIMD support that Bun requires.\n", stderr); +#endif + if (const char* forced = getenv("SIMDUTF_FORCE_IMPLEMENTATION")) { + fprintf(stderr, " note: SIMDUTF_FORCE_IMPLEMENTATION is set to \"%s\"\n", forced); + } + fflush(stderr); + exit(134); +} + // Error condition is encoded as max int32_t. // The only error in this function is ESRCH (no process found) extern "C" int32_t get_process_priority(int32_t pid) @@ -274,6 +292,72 @@ extern "C" ssize_t bun_close_range(unsigned int start, unsigned int end, unsigne { return syscall(__NR_close_range, start, end, flags); } + +#if CPU(X86_64) || CPU(ARM64) +#include +#include + +#ifndef SYS_SECCOMP +#define SYS_SECCOMP 1 +#endif + +extern "C" void Bun__onPosixSignal(int signalNumber); + +static std::atomic forwardSIGSYSToJS { false }; + +// A SECCOMP_RET_TRAP policy (Android) reports a blocked syscall with SIGSYS instead of an +// errno. Turn it into the ENOSYS return that the callers (close_range, pidfd_open, ...) handle. +static void onSIGSYS(int sig, siginfo_t* info, void* context) +{ + if (info->si_code == SYS_SECCOMP && context) { + ucontext_t* uc = static_cast(context); +#if CPU(X86_64) + uc->uc_mcontext.gregs[REG_RAX] = -ENOSYS; +#else + uc->uc_mcontext.regs[0] = static_cast(-ENOSYS); +#endif + return; + } + + // Otherwise it was sent with kill(2). + if (forwardSIGSYSToJS.load(std::memory_order_relaxed)) { + Bun__onPosixSignal(sig); + return; + } + signal(sig, SIG_DFL); + raise(sig); +} + +static void installSIGSYSHandler() +{ + struct sigaction sa; + memset(&sa, 0, sizeof(sa)); + sa.sa_sigaction = onSIGSYS; + // SA_RESTART matches installForwardSignalHandler, for a SIGSYS sent with kill(2). + sa.sa_flags = SA_SIGINFO | SA_ONSTACK | SA_RESTART; + sigemptyset(&sa.sa_mask); + sigaction(SIGSYS, &sa, nullptr); +} + +// BunProcess.cpp routes process.on("SIGSYS") through onSIGSYS instead of replacing it. +// Returns false on targets without onSIGSYS, where the caller installs its own handler. +extern "C" bool Bun__forwardSIGSYSToJS(bool enabled) +{ + forwardSIGSYSToJS.store(enabled, std::memory_order_relaxed); + if (enabled) + installSIGSYSHandler(); + return true; +} +#else +static void installSIGSYSHandler() +{ +} + +extern "C" bool Bun__forwardSIGSYSToJS(bool) +{ + return false; +} +#endif // CPU(X86_64) || CPU(ARM64) #else // OS(FREEBSD) // FreeBSD 12.2+ libc has close_range; 14.0+ supports CLOSE_RANGE_CLOEXEC // (same value 1<<2 as Linux). Passing flags through means execveZ-failure @@ -322,7 +406,7 @@ extern "C" void on_before_reload_process_posix() } // Reset caught dispositions so a SIGTERM arriving between here and execve isn't queued-then- - // lost. Inherited SIG_IGN is left alone. SIGSEGV/SIGBUS/RT/sigThreadSuspendResume are left + // lost. Inherited SIG_IGN is left alone. SIGSEGV/SIGBUS/SIGSYS/RT/sigThreadSuspendResume are left // for execve to reset atomically — resetting them here races JSC's sampler/GC threads fatally. struct sigaction sa {}; sa.sa_handler = SIG_DFL; @@ -331,7 +415,7 @@ extern "C" void on_before_reload_process_posix() if (s == SIGKILL || s == SIGSTOP || s == SIGSEGV || s == SIGBUS) continue; #if OS(LINUX) - if (s == g_wtfConfig.sigThreadSuspendResume) + if (s == g_wtfConfig.sigThreadSuspendResume || s == SIGSYS) continue; #endif #ifdef SIGRTMIN @@ -633,11 +717,13 @@ extern "C" void bun_restore_stdio() } #if !OS(WINDOWS) +// Implemented by bun_core::Global::raise_default_action. +extern "C" [[noreturn]] void Bun__raiseDefaultAction(int sig); + extern "C" void onExitSignal(int sig) { bun_restore_stdio(); - signal(sig, SIG_DFL); - raise(sig); + Bun__raiseDefaultAction(sig); } #endif @@ -686,6 +772,25 @@ extern "C" int __cxa_atexit(void (*)(void*), void*, void*); extern "C" struct mach_header __dso_handle; #endif +#if OS(LINUX) || OS(DARWIN) || OS(FREEBSD) +static NEVER_INLINE void openDevNullIfStdioIsClosed(int fd) +{ + // Don't use isatty() here. It also fails with EBADF on an open O_PATH descriptor. + if (fcntl(fd, F_GETFD) != -1 || errno != EBADF) + return; + + bun_is_stdio_null[fd] = 1; + int devNullFd; + do { + devNullFd = open("/dev/null", O_RDWR | O_CLOEXEC, 0); + } while (devNullFd < 0 && errno == EINTR); + + // Every lower fd is open, so open() must return `fd`. + if (devNullFd != fd) [[unlikely]] + abort(); +} +#endif + extern "C" void bun_initialize_process() { // Disable printf() buffering. We buffer it ourselves. @@ -693,6 +798,9 @@ extern "C" void bun_initialize_process() setvbuf(stderr, nullptr, _IONBF, 0); #if OS(LINUX) + // A seccomp policy may trap bun_close_range. + installSIGSYSHandler(); + // Prevent leaking inherited file descriptors on Linux // This is less of an issue for macOS due to posix_spawn // This is best effort, not all linux kernels support close_range or CLOSE_RANGE_CLOEXEC @@ -704,39 +812,13 @@ extern "C" void bun_initialize_process() #if OS(LINUX) || OS(DARWIN) || OS(FREEBSD) - int devNullFd_ = -1; bool anyTTYs = false; - const auto setDevNullFd = [&](int target_fd) -> void { - bun_is_stdio_null[target_fd] = 1; - if (devNullFd_ == -1) { - do { - devNullFd_ = open("/dev/null", O_RDWR | O_CLOEXEC, 0); - } while (devNullFd_ < 0 and errno == EINTR); - }; - - if (devNullFd_ == target_fd) { - devNullFd_ = -1; - return; - } - - ASSERT(devNullFd_ != -1); - int err; - do { - err = dup2(devNullFd_, target_fd); - } while (err < 0 && errno == EINTR); - - if (err != 0) [[unlikely]] { - abort(); - } - }; - for (int fd = 0; fd < 3; fd++) { int result = isatty(fd); if (result == 0) { if (errno == EBADF) [[unlikely]] { - // the fd is invalid, let's make sure it's always valid - setDevNullFd(fd); + openDevNullIfStdioIsClosed(fd); } } else { bun_stdio_tty[fd] = 1; @@ -752,11 +834,6 @@ extern "C" void bun_initialize_process() } } - ASSERT(devNullFd_ == -1 || devNullFd_ > 2); - if (devNullFd_ > 2) { - close(devNullFd_); - } - // Restore TTY state on exit if (anyTTYs) { struct sigaction sa; @@ -1017,13 +1094,19 @@ extern "C" int ffi_fileno(FILE* file) #if OS(LINUX) || OS(DARWIN) || OS(FREEBSD) #include #include +#include -// Note: We only ever use bun.spawnSync on the main thread. -extern "C" int64_t Bun__currentSyncPID = 0; -static int Bun__pendingSignalToSend = 0; +// The handler below can run on any thread while the spawning thread stores the pid. +extern "C" std::atomic Bun__currentSyncPID { 0 }; // `AtomicI64` on the Rust side. +static_assert(std::atomic::is_always_lock_free); +static std::atomic Bun__pendingSignalToSend { 0 }; static struct sigaction previous_actions[NSIG]; +// Bun.openInEditor runs spawnSync on detached threads, so register and unregister can overlap. +static WTF::Lock signalForwardingLock; +static int signalForwardingDepth = 0; -// npm's signal list minus SIGIOT/SIGPOLL (aliases of SIGABRT/SIGIO; listing both would overwrite previous_actions[N]). +// npm's signal list minus SIGIOT/SIGPOLL (aliases of SIGABRT/SIGIO; listing both would overwrite previous_actions[N]) +// and minus SIGSYS, which the kernel raises for the process's own syscall. // https://github.com/npm/cli/blob/fefd509992a05c2dfddbe7bc46931c42f1da69d7/workspaces/arborist/lib/signals.js#L26-L57 #define FOR_EACH_POSIX_SIGNAL(M) \ M(SIGABRT); \ @@ -1036,7 +1119,6 @@ static struct sigaction previous_actions[NSIG]; M(SIGXFSZ); \ M(SIGUSR2); \ M(SIGTRAP); \ - M(SIGSYS); \ M(SIGQUIT); \ M(SIGIO); @@ -1058,45 +1140,40 @@ static struct sigaction previous_actions[NSIG]; FOR_EACH_LINUX_ONLY_SIGNAL(M) #endif -static void Bun__forwardSignalFromParentToChildAndRestorePreviousAction(pid_t pid, int sig) -{ - sigset_t restore_mask; - sigset_t mask; - sigemptyset(&mask); - sigaddset(&mask, sig); - sigemptyset(&restore_mask); - sigaddset(&restore_mask, sig); - pthread_sigmask(SIG_BLOCK, &mask, &restore_mask); - kill(pid, sig); - pthread_sigmask(SIG_UNBLOCK, &restore_mask, nullptr); -} - +// The spawning thread stores the pid, then reads the pending signal. The handler stores the signal, then loads +// the pid. At least one of them sees the other's store, and the exchange ensures only one of them sends the signal. extern "C" void Bun__sendPendingSignalIfNecessary() { - int sig = Bun__pendingSignalToSend; - Bun__pendingSignalToSend = 0; - int pid = Bun__currentSyncPID; + int sig = Bun__pendingSignalToSend.exchange(0); + int64_t pid = Bun__currentSyncPID.load(); if (sig == 0 || pid == 0) return; - Bun__forwardSignalFromParentToChildAndRestorePreviousAction(pid, sig); + kill(pid, sig); } extern "C" void Bun__registerSignalsForForwarding() { - Bun__pendingSignalToSend = 0; + WTF::Locker locker { signalForwardingLock }; + if (signalForwardingDepth++ != 0) + return; + + Bun__pendingSignalToSend.store(0); struct sigaction sa; memset(&sa, 0, sizeof(sa)); sigemptyset(&sa.sa_mask); // Not SA_RESETHAND: a nested runner sees the signal more than once and must not die on a repeat delivery. sa.sa_flags = 0; sa.sa_handler = [](int sig) { - if (Bun__currentSyncPID == 0) { - Bun__pendingSignalToSend = sig; - return; + int64_t pid = Bun__currentSyncPID.load(); + if (pid == 0) { + Bun__pendingSignalToSend.store(sig); + pid = Bun__currentSyncPID.load(); + if (pid == 0 || Bun__pendingSignalToSend.exchange(0) == 0) + return; } - Bun__forwardSignalFromParentToChildAndRestorePreviousAction(Bun__currentSyncPID, sig); + kill(pid, sig); }; #define REGISTER_SIGNAL(SIG) \ @@ -1110,7 +1187,11 @@ extern "C" void Bun__registerSignalsForForwarding() extern "C" void Bun__unregisterSignalsForForwarding() { - Bun__currentSyncPID = 0; + Bun__currentSyncPID.store(0); + + WTF::Locker locker { signalForwardingLock }; + if (--signalForwardingDepth != 0) + return; #define UNREGISTER_SIGNAL(SIG) \ if (sigaction(SIG, &previous_actions[SIG], NULL) == -1) { \ @@ -1182,10 +1263,18 @@ struct BlobHeader { #if OS(DARWIN) +#include +#include + extern "C" BlobHeader __attribute__((section("__BUN,__bun"))) BUN_COMPILED = { 0, 0 }; extern "C" uint64_t* Bun__getStandaloneModuleGraphMachoLength() { + // A corrupt file can claim any length, so check it against the section size from the load command. + unsigned long sectionSize = 0; + uint8_t* sectionData = getsectiondata(&_mh_execute_header, "__BUN", "__bun", §ionSize); + if (!sectionData || sectionSize < sizeof(uint64_t)) return nullptr; + if (BUN_COMPILED.size > sectionSize - sizeof(uint64_t)) return nullptr; return &BUN_COMPILED.size; } @@ -1228,7 +1317,15 @@ static bool initializePESection() if (memcmp(sectionHeader->Name, ".bun\0\0\0\0", 8) == 0) { // Found the .bun section // Section format: 8 bytes size (uint64_t) + data + // A corrupt file can claim any length, but only VirtualSize bytes are mapped. + uint64_t sectionSize = sectionHeader->Misc.VirtualSize; + if (sectionSize < sizeof(uint64_t)) return false; + BYTE* sectionData = (BYTE*)hModule + sectionHeader->VirtualAddress; + uint64_t embeddedLength; + memcpy(&embeddedLength, sectionData, sizeof(embeddedLength)); + if (embeddedLength > sectionSize - sizeof(uint64_t)) return false; + pe_section_size = (uint64_t*)sectionData; pe_section_data = sectionData + sizeof(uint64_t); // Skip size (8) return true; diff --git a/src/jsc/bindings/ffi.cpp b/src/jsc/bindings/ffi.cpp index 2f4b6f561cee..e8e07f46df18 100644 --- a/src/jsc/bindings/ffi.cpp +++ b/src/jsc/bindings/ffi.cpp @@ -5,6 +5,8 @@ typedef struct FFIFields { uint32_t JSArrayBufferView__offsetOfByteOffset; uint32_t JSArrayBufferView__offsetOfVector; uint32_t JSCell__offsetOfType; + // An index into the CallFrame's 8-byte slots, not a byte offset. + uint32_t CallFrame__argumentCountIncludingThisSlot; } FFIFields; extern "C" FFIFields Bun__FFI__offsets = { 0 }; @@ -14,4 +16,5 @@ extern "C" void Bun__FFI__ensureOffsetsAreLoaded() Bun__FFI__offsets.JSArrayBufferView__offsetOfByteOffset = JSC::JSArrayBufferView::offsetOfByteOffset(); Bun__FFI__offsets.JSArrayBufferView__offsetOfVector = JSC::JSArrayBufferView::offsetOfVector(); Bun__FFI__offsets.JSCell__offsetOfType = JSC::JSCell::typeInfoTypeOffset(); + Bun__FFI__offsets.CallFrame__argumentCountIncludingThisSlot = static_cast(JSC::CallFrameSlot::argumentCountIncludingThis); } diff --git a/src/jsc/bindings/headers-handwritten.h b/src/jsc/bindings/headers-handwritten.h index 8ef784f8f1f1..fccc54915676 100644 --- a/src/jsc/bindings/headers-handwritten.h +++ b/src/jsc/bindings/headers-handwritten.h @@ -349,6 +349,8 @@ extern "C" JSC::EncodedJSValue BunString__toJS(JSC::JSGlobalObject*, const BunSt namespace Bun { JSC::JSString* toJS(JSC::JSGlobalObject*, BunString); +// Returns the empty identifier for a zero-length string with any tag. +JSC::Identifier toIdentifier(JSC::VM&, const BunString&); BunString toString(WTF::String& wtfString); BunString toString(const WTF::String& wtfString); BunString toString(WTF::StringImpl* wtfString); @@ -424,6 +426,14 @@ extern "C" void EncodedSlice__freeGlobal(const unsigned char* ptr, size_t len); extern "C" size_t Bun__encoding__writeLatin1(const Latin1Character* ptr, size_t len, unsigned char* to, size_t other_len, Encoding encoding); extern "C" size_t Bun__encoding__writeUTF16(const char16_t* ptr, size_t len, unsigned char* to, size_t other_len, Encoding encoding); +// Mirrors `EncodeIntoResult` in bun_core. `written` can be exactly 2^32 when it fills a max-size Uint8Array. +typedef struct TextEncoderEncodeIntoResult { + size_t read; + size_t written; +} TextEncoderEncodeIntoResult; +extern "C" TextEncoderEncodeIntoResult TextEncoder__encodeInto8(const Latin1Character* stringPtr, size_t stringLen, void* ptr, size_t len); +extern "C" TextEncoderEncodeIntoResult TextEncoder__encodeInto16(const char16_t* stringPtr, size_t stringLen, void* ptr, size_t len); + extern "C" size_t Bun__encoding__byteLengthLatin1AsUTF8(const Latin1Character* ptr, size_t len); extern "C" size_t Bun__encoding__byteLengthUTF16AsUTF8(const char16_t* ptr, size_t len); diff --git a/src/jsc/bindings/headers.h b/src/jsc/bindings/headers.h index d43863a88af2..d6d4b82bb96b 100644 --- a/src/jsc/bindings/headers.h +++ b/src/jsc/bindings/headers.h @@ -54,8 +54,8 @@ CPP_DECL BunString WebCore__DOMURL__fileSystemPath(WebCore::DOMURL* arg0, int* e #pragma mark - WebCore::DOMFormData -CPP_DECL void WebCore__DOMFormData__append(WebCore::DOMFormData* arg0, const EncodedSlice* arg1, const EncodedSlice* arg2); -CPP_DECL void WebCore__DOMFormData__appendBlob(WebCore::DOMFormData* arg0, JSC::JSGlobalObject* arg1, const EncodedSlice* arg2, void* arg3, const EncodedSlice* arg4); +CPP_DECL bool WebCore__DOMFormData__append(WebCore::DOMFormData* arg0, JSC::JSGlobalObject* arg1, const EncodedSlice* arg2, const EncodedSlice* arg3); +CPP_DECL bool WebCore__DOMFormData__appendBlob(WebCore::DOMFormData* arg0, JSC::JSGlobalObject* arg1, const EncodedSlice* arg2, void* arg3, const EncodedSlice* arg4); CPP_DECL size_t WebCore__DOMFormData__count(WebCore::DOMFormData* arg0); CPP_DECL JSC::EncodedJSValue WebCore__DOMFormData__create(JSC::JSGlobalObject* arg0); CPP_DECL JSC::EncodedJSValue WebCore__DOMFormData__createFromURLQuery(JSC::JSGlobalObject* arg0, const EncodedSlice* arg1); @@ -65,8 +65,8 @@ CPP_DECL WebCore::DOMFormData* _fromJS(JSC::EncodedJSValue JSValue0); CPP_DECL WebCore::FetchHeaders* WebCore__FetchHeaders__cast_(JSC::EncodedJSValue JSValue0, JSC::VM* arg1); CPP_DECL WebCore::FetchHeaders* WebCore__FetchHeaders__cloneThis(WebCore::FetchHeaders* arg0, JSC::JSGlobalObject* arg1); -CPP_DECL void WebCore__FetchHeaders__copyTo(WebCore::FetchHeaders* arg0, StringPointer* arg1, StringPointer* arg2, unsigned char* arg3); -CPP_DECL void WebCore__FetchHeaders__count(WebCore::FetchHeaders* arg0, uint32_t* arg1, uint32_t* arg2); +CPP_DECL void WebCore__FetchHeaders__copyTo(WebCore::FetchHeaders* arg0, StringPointer* arg1, StringPointer* arg2, uint32_t arg3, unsigned char* arg4, uint32_t arg5); +CPP_DECL bool WebCore__FetchHeaders__count(WebCore::FetchHeaders* arg0, uint32_t* arg1, uint32_t* arg2); CPP_DECL WebCore::FetchHeaders* WebCore__FetchHeaders__createEmpty(); CPP_DECL WebCore::FetchHeaders* WebCore__FetchHeaders__createFromJS(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSValue1); CPP_DECL WebCore::FetchHeaders* WebCore__FetchHeaders__createFromPicoHeaders_(const void* arg0); @@ -96,7 +96,7 @@ CPP_DECL bool JSC__JSString__is8Bit(const JSC::JSString* arg0); CPP_DECL void JSC__JSString__iterator(JSC::JSString* arg0, JSC::JSGlobalObject* arg1, void* arg2); CPP_DECL size_t JSC__JSString__length(const JSC::JSString* arg0); CPP_DECL JSC::JSObject* JSC__JSString__toObject(JSC::JSString* arg0, JSC::JSGlobalObject* arg1); -CPP_DECL BunString JSC__JSString__view(JSC::JSString* arg0, JSC::JSGlobalObject* arg1); +CPP_DECL BunString JSC__JSString__view(JSC::JSString* arg0, JSC::JSGlobalObject* arg1, const JSC::JSString** owner); #pragma mark - JSC::JSModuleLoader @@ -249,7 +249,7 @@ CPP_DECL bool JSC__JSValue__toMatch(JSC::EncodedJSValue JSValue0, JSC::JSGlobalO CPP_DECL JSC::JSObject* JSC__JSValue__toObject(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* arg1); CPP_DECL JSC::JSString* JSC__JSValue__toString(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* arg1); CPP_DECL JSC::JSString* JSC__JSValue__toStringOrNull(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* arg1); -CPP_DECL JSC::JSString* JSC__JSValue__toJSStringView(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* global, BunString* view); +CPP_DECL JSC::JSString* JSC__JSValue__toJSStringView(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* global, BunString* view, const JSC::JSString** owner); CPP_DECL uint64_t JSC__JSValue__toUInt64NoTruncate(JSC::EncodedJSValue JSValue0); CPP_DECL void JSC__JSValue__toZigException(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* arg1, ZigException* arg2); @@ -642,18 +642,18 @@ CPP_DECL ZigException ZigException__fromException(JSC::Exception* arg0); #ifdef __cplusplus -extern "C" SYSV_ABI void Bun__ConsoleObject__count(void* arg0, JSC::JSGlobalObject* arg1, const unsigned char* arg2, size_t arg3); -extern "C" SYSV_ABI void Bun__ConsoleObject__countReset(void* arg0, JSC::JSGlobalObject* arg1, const unsigned char* arg2, size_t arg3); +extern "C" SYSV_ABI void Bun__ConsoleObject__count(void* arg0, JSC::JSGlobalObject* arg1, const BunString* arg2); +extern "C" SYSV_ABI void Bun__ConsoleObject__countReset(void* arg0, JSC::JSGlobalObject* arg1, const BunString* arg2); extern "C" SYSV_ABI void Bun__ConsoleObject__messageWithTypeAndLevel(void* arg0, uint32_t MessageType1, uint32_t MessageLevel2, JSC::JSGlobalObject* arg3, JSC::EncodedJSValue* arg4, size_t arg5); extern "C" SYSV_ABI void Bun__ConsoleObject__profile(void* arg0, JSC::JSGlobalObject* arg1, const unsigned char* arg2, size_t arg3); extern "C" SYSV_ABI void Bun__ConsoleObject__profileEnd(void* arg0, JSC::JSGlobalObject* arg1, const unsigned char* arg2, size_t arg3); extern "C" SYSV_ABI void Bun__ConsoleObject__record(void* arg0, JSC::JSGlobalObject* arg1, ScriptArguments* arg2); extern "C" SYSV_ABI void Bun__ConsoleObject__recordEnd(void* arg0, JSC::JSGlobalObject* arg1, ScriptArguments* arg2); extern "C" SYSV_ABI void Bun__ConsoleObject__screenshot(void* arg0, JSC::JSGlobalObject* arg1, ScriptArguments* arg2); -extern "C" SYSV_ABI void Bun__ConsoleObject__takeHeapSnapshot(void* arg0, JSC::JSGlobalObject* arg1, const unsigned char* arg2, size_t arg3); -extern "C" SYSV_ABI void Bun__ConsoleObject__time(void* arg0, JSC::JSGlobalObject* arg1, const unsigned char* arg2, size_t arg3); -extern "C" SYSV_ABI void Bun__ConsoleObject__timeEnd(void* arg0, JSC::JSGlobalObject* arg1, const unsigned char* arg2, size_t arg3); -extern "C" SYSV_ABI void Bun__ConsoleObject__timeLog(void* arg0, JSC::JSGlobalObject* arg1, const unsigned char* arg2, size_t arg3, JSC::EncodedJSValue* arg4, size_t arg5); +extern "C" SYSV_ABI void Bun__ConsoleObject__takeHeapSnapshot(void* arg0, JSC::JSGlobalObject* arg1); +extern "C" SYSV_ABI void Bun__ConsoleObject__time(void* arg0, JSC::JSGlobalObject* arg1, const BunString* arg2); +extern "C" SYSV_ABI void Bun__ConsoleObject__timeEnd(void* arg0, JSC::JSGlobalObject* arg1, const BunString* arg2); +extern "C" SYSV_ABI void Bun__ConsoleObject__timeLog(void* arg0, JSC::JSGlobalObject* arg1, const BunString* arg2, JSC::EncodedJSValue* arg3, size_t arg4); extern "C" SYSV_ABI void Bun__ConsoleObject__timeStamp(void* arg0, JSC::JSGlobalObject* arg1, ScriptArguments* arg2); #endif @@ -666,7 +666,6 @@ extern "C" SYSV_ABI void Bun__ConsoleObject__timeStamp(void* arg0, JSC::JSGlobal ZIG_DECL JSC::EncodedJSValue Bun__Timer__clearImmediate(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSValue1); ZIG_DECL JSC::EncodedJSValue Bun__Timer__clearInterval(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSValue1); ZIG_DECL JSC::EncodedJSValue Bun__Timer__clearTimeout(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue JSValue1); -ZIG_DECL int32_t Bun__Timer__getNextID(); ZIG_DECL JSC::EncodedJSValue Bun__Timer__setInterval(JSC::JSGlobalObject* globalThis, JSC::EncodedJSValue callback, JSC::EncodedJSValue arguments, JSC::EncodedJSValue countdown); ZIG_DECL JSC::EncodedJSValue Bun__Timer__setTimeout(JSC::JSGlobalObject* globalThis, JSC::EncodedJSValue callback, JSC::EncodedJSValue arguments, JSC::EncodedJSValue countdown); ZIG_DECL JSC::EncodedJSValue Bun__Timer__sleep(JSC::JSGlobalObject* globalThis, JSC::EncodedJSValue promise, JSC::EncodedJSValue countdown); diff --git a/src/jsc/bindings/helpers.h b/src/jsc/bindings/helpers.h index c12eedab5bae..6810bed0cdd5 100644 --- a/src/jsc/bindings/helpers.h +++ b/src/jsc/bindings/helpers.h @@ -72,6 +72,8 @@ static void free_global_string(void* str, void* ptr, unsigned len) EncodedSlice__freeGlobal(reinterpret_cast(ptr), len); } +static WTF::String tryConvertValidUTF8ToString(std::span bytes); + static WTF::String convertUTF8ToString(std::span bytes) { // fromUTF8ReplacingInvalidSequences CRASH()es past a ~2^30-byte input @@ -82,6 +84,13 @@ static WTF::String convertUTF8ToString(std::span bytes) const char* data = reinterpret_cast(bytes.data()); if (!simdutf::validate_utf8(data, bytes.size())) [[unlikely]] return {}; + return tryConvertValidUTF8ToString(bytes); +} + +// `bytes` must be valid UTF-8. Returns null if the allocation fails or the result exceeds the max string length. +static WTF::String tryConvertValidUTF8ToString(std::span bytes) +{ + const char* data = reinterpret_cast(bytes.data()); size_t utf16Length = simdutf::utf16_length_from_utf8(data, bytes.size()); if (utf16Length > WTF::String::MaxLength) [[unlikely]] return {}; @@ -269,14 +278,6 @@ static EncodedSlice toEncodedSlice(const WTF::StringView& str) str.length() }; } -static EncodedSlice toEncodedSlice(JSC::JSString* str, JSC::JSGlobalObject* global) -{ - if (str->isSubstring()) { - return toEncodedSlice(str->view(global)); - } - return toEncodedSlice(str->value(global)); -} - static void throwException(JSC::ThrowScope& scope, JSC::EncodedJSValue err, JSC::JSGlobalObject* global) { scope.throwException(global, diff --git a/src/jsc/bindings/libuv/generate_uv_posix_stubs.ts b/src/jsc/bindings/libuv/generate_uv_posix_stubs.ts index 43bcb3015108..8b819bda03b0 100644 --- a/src/jsc/bindings/libuv/generate_uv_posix_stubs.ts +++ b/src/jsc/bindings/libuv/generate_uv_posix_stubs.ts @@ -290,7 +290,7 @@ const final_contents = `// GENERATED CODE - DO NOT MODIFY BY HAND #include "uv-posix-polyfills.h" -#if OS(LINUX) || OS(DARWIN) +#if OS(LINUX) || OS(DARWIN) || OS(FREEBSD) ${parts.map(([stub, _]) => stub).join("\n\n")} #endif diff --git a/src/jsc/bindings/libuv/generate_uv_posix_stubs_constants.ts b/src/jsc/bindings/libuv/generate_uv_posix_stubs_constants.ts index b28f652ed91c..242303babc66 100644 --- a/src/jsc/bindings/libuv/generate_uv_posix_stubs_constants.ts +++ b/src/jsc/bindings/libuv/generate_uv_posix_stubs_constants.ts @@ -14,8 +14,9 @@ export const test_skipped = [ export const symbols = [ "uv_accept", - "uv_async_init", - "uv_async_send", + // Defined in src/runtime/napi/uv_posix.rs + // "uv_async_init", + // "uv_async_send", "uv_available_parallelism", "uv_backend_fd", "uv_backend_timeout", @@ -23,13 +24,16 @@ export const symbols = [ "uv_barrier_init", "uv_barrier_wait", "uv_buf_init", - "uv_cancel", + // Defined in src/runtime/napi/uv_posix.rs + // "uv_cancel", "uv_chdir", - "uv_check_init", - "uv_check_start", - "uv_check_stop", + // Defined in src/runtime/napi/uv_posix.rs + // "uv_check_init", + // "uv_check_start", + // "uv_check_stop", "uv_clock_gettime", - "uv_close", + // Defined in src/runtime/napi/uv_posix.rs + // "uv_close", "uv_cond_broadcast", "uv_cond_destroy", "uv_cond_init", @@ -38,8 +42,10 @@ export const symbols = [ "uv_cond_wait", "uv_cpu_info", "uv_cpumask_size", - "uv_cwd", - "uv_default_loop", + // Defined in uv-posix-polyfills.c + // "uv_cwd", + // Defined in src/runtime/napi/uv_posix.rs + // "uv_default_loop", "uv_disable_stdio_inheritance", "uv_dlclose", "uv_dlerror", @@ -107,7 +113,8 @@ export const symbols = [ "uv_get_available_memory", "uv_get_constrained_memory", "uv_get_free_memory", - "uv_get_osfhandle", + // Defined in uv-posix-polyfills.c + // "uv_get_osfhandle", "uv_get_process_title", "uv_get_total_memory", "uv_getaddrinfo", @@ -116,18 +123,21 @@ export const symbols = [ "uv_getrusage_thread", "uv_gettimeofday", "uv_guess_handle", - "uv_handle_get_data", - "uv_handle_get_loop", - "uv_handle_get_type", - "uv_handle_set_data", - "uv_handle_size", - "uv_handle_type_name", - "uv_has_ref", - // Defined in uv-posix-polyfills.cpp + // Defined in uv-posix-polyfills.c + // "uv_handle_get_data", + // "uv_handle_get_loop", + // "uv_handle_get_type", + // "uv_handle_set_data", + // "uv_handle_size", + // "uv_handle_type_name", + // Defined in src/runtime/napi/uv_posix.rs + // "uv_has_ref", + // Defined in uv-posix-polyfills.c // "uv_hrtime", - "uv_idle_init", - "uv_idle_start", - "uv_idle_stop", + // Defined in src/runtime/napi/uv_posix.rs + // "uv_idle_init", + // "uv_idle_start", + // "uv_idle_stop", "uv_if_indextoiid", "uv_if_indextoname", "uv_inet_ntop", @@ -138,8 +148,9 @@ export const symbols = [ "uv_ip6_addr", "uv_ip6_name", "uv_ip_name", - "uv_is_active", - "uv_is_closing", + // Defined in src/runtime/napi/uv_posix.rs + // "uv_is_active", + // "uv_is_closing", "uv_is_readable", "uv_is_writable", "uv_key_create", @@ -155,24 +166,27 @@ export const symbols = [ "uv_loop_configure", "uv_loop_delete", "uv_loop_fork", - "uv_loop_get_data", + // Defined in uv-posix-polyfills.c + // "uv_loop_get_data", "uv_loop_init", "uv_loop_new", - "uv_loop_set_data", + // Defined in uv-posix-polyfills.c + // "uv_loop_set_data", "uv_loop_size", "uv_metrics_idle_time", "uv_metrics_info", - // Defined in uv-posix-polyfills.cpp + // Defined in uv-posix-polyfills.c // "uv_mutex_destroy", // "uv_mutex_init", // "uv_mutex_init_recursive", // "uv_mutex_lock", // "uv_mutex_trylock", // "uv_mutex_unlock", - "uv_now", - // Defined in uv-posix-polyfills.cpp + // Defined in src/runtime/napi/uv_posix.rs + // "uv_now", + // Defined in uv-posix-polyfills.c // "uv_once", - "uv_open_osfhandle", + // "uv_open_osfhandle", "uv_os_environ", "uv_os_free_environ", "uv_os_free_group", @@ -182,9 +196,8 @@ export const symbols = [ "uv_os_get_passwd2", "uv_os_getenv", "uv_os_gethostname", - // Defined in uv-posix-polyfills.cpp + // Defined in uv-posix-polyfills.c // "uv_os_getpid", - // Defined in uv-posix-polyfills.cpp // "uv_os_getppid", "uv_os_getpriority", "uv_os_homedir", @@ -210,25 +223,29 @@ export const symbols = [ "uv_poll_init_socket", "uv_poll_start", "uv_poll_stop", - "uv_prepare_init", - "uv_prepare_start", - "uv_prepare_stop", + // Defined in src/runtime/napi/uv_posix.rs + // "uv_prepare_init", + // "uv_prepare_start", + // "uv_prepare_stop", "uv_print_active_handles", "uv_print_all_handles", "uv_process_get_pid", "uv_process_kill", - "uv_queue_work", + // Defined in src/runtime/napi/uv_posix.rs + // "uv_queue_work", "uv_random", "uv_read_start", "uv_read_stop", "uv_recv_buffer_size", - "uv_ref", + // Defined in src/runtime/napi/uv_posix.rs + // "uv_ref", "uv_replace_allocator", - "uv_req_get_data", - "uv_req_get_type", - "uv_req_set_data", - "uv_req_size", - "uv_req_type_name", + // Defined in uv-posix-polyfills.c + // "uv_req_get_data", + // "uv_req_get_type", + // "uv_req_set_data", + // "uv_req_size", + // "uv_req_type_name", "uv_resident_set_memory", "uv_run", "uv_rwlock_destroy", @@ -284,13 +301,14 @@ export const symbols = [ "uv_thread_setaffinity", "uv_thread_setname", "uv_thread_setpriority", - "uv_timer_again", - "uv_timer_get_due_in", - "uv_timer_get_repeat", - "uv_timer_init", - "uv_timer_set_repeat", - "uv_timer_start", - "uv_timer_stop", + // Defined in src/runtime/napi/uv_posix.rs + // "uv_timer_again", + // "uv_timer_get_due_in", + // "uv_timer_get_repeat", + // "uv_timer_init", + // "uv_timer_set_repeat", + // "uv_timer_start", + // "uv_timer_stop", "uv_translate_sys_error", "uv_try_write", "uv_try_write2", @@ -323,13 +341,15 @@ export const symbols = [ "uv_udp_try_send", "uv_udp_try_send2", "uv_udp_using_recvmmsg", - "uv_unref", - "uv_update_time", + // Defined in src/runtime/napi/uv_posix.rs + // "uv_unref", + // "uv_update_time", "uv_uptime", "uv_utf16_length_as_wtf8", "uv_utf16_to_wtf8", - "uv_version", - "uv_version_string", + // Defined in uv-posix-polyfills.c + // "uv_version", + // "uv_version_string", "uv_walk", "uv_write", "uv_write2", diff --git a/src/jsc/bindings/napi.cpp b/src/jsc/bindings/napi.cpp index 7134dfd34ed0..edc9ab3ce867 100644 --- a/src/jsc/bindings/napi.cpp +++ b/src/jsc/bindings/napi.cpp @@ -74,7 +74,6 @@ #include "wtf/NakedPtr.h" #include #include -#include "JSCommonJSModule.h" #include "wtf/text/ASCIIFastPath.h" #include "JavaScriptCore/WeakInlines.h" #include @@ -743,70 +742,49 @@ extern "C" napi_status napi_get_named_property(napi_env env, napi_value object, } extern "C" size_t Bun__napi_module_register_count; -void Napi::executePendingNapiModule(Zig::GlobalObject* globalObject) +void Napi::executePendingNapiModule(Zig::GlobalObject* globalObject, const napi_module& mod, void* dlopenHandle, JSC::JSObject* object) { JSC::VM& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - ASSERT(globalObject->m_pendingNapiModule); + if (!mod.nm_register_func) { + JSValue errorInstance = createError(globalObject, makeString("Module has no declared entry point."_s)); + JSC::throwException(globalObject, scope, errorInstance); + return; + } - auto& mod = *globalObject->m_pendingNapiModule; Ref env = globalObject->makeNapiEnv(mod); auto keyStr = WTF::String::fromUTF8(mod.nm_modname); - JSValue pendingNapiModule = globalObject->m_pendingNapiModuleAndExports[0].get(); - JSObject* object = (pendingNapiModule && pendingNapiModule.isObject()) ? pendingNapiModule.getObject() - : nullptr; - - JSC::Strong strongExportsObject; - - if (!object) { - auto* exportsObject = JSC::constructEmptyObject(globalObject); - RETURN_IF_EXCEPTION(scope, void()); - object = Bun::JSCommonJSModule::create(globalObject, keyStr, exportsObject, false, jsUndefined()); - RETURN_IF_EXCEPTION(scope, void()); - strongExportsObject = { vm, exportsObject }; - } else { - JSValue exportsObject = object->get(globalObject, WebCore::builtinNames(vm).exportsPublicName()); - RETURN_IF_EXCEPTION(scope, void()); + JSValue exportsObject = object->get(globalObject, WebCore::builtinNames(vm).exportsPublicName()); + RETURN_IF_EXCEPTION(scope, void()); - // Convert exports to object, matching Node.js behavior. - // This throws for null/undefined and creates wrapper objects for primitives. - JSObject* exports = exportsObject.toObject(globalObject); - RETURN_IF_EXCEPTION(scope, void()); + // Like Node, convert exports to an object. null and undefined throw, and a primitive gets a wrapper object. + JSObject* exports = exportsObject.toObject(globalObject); + RETURN_IF_EXCEPTION(scope, void()); - ASSERT(exports); - strongExportsObject = { vm, exports }; - } + ASSERT(exports); + JSC::Strong strongExportsObject = { vm, exports }; JSC::Strong strongObject = { vm, object }; Bun::NapiHandleScope handleScope(globalObject); - JSValue resultValue; - - if (mod.nm_register_func) { - resultValue = toJS(mod.nm_register_func(env.ptr(), toNapi(object, globalObject))); - } else { - JSValue errorInstance = createError(globalObject, makeString("Module has no declared entry point."_s)); - globalObject->m_pendingNapiModuleAndExports[0].set(vm, globalObject, errorInstance); - return; - } - + JSValue resultValue = toJS(mod.nm_register_func(env.ptr(), toNapi(object, globalObject))); RETURN_IF_EXCEPTION(scope, void()); if (resultValue.isEmpty()) { JSValue errorInstance = createError(globalObject, makeString("Node-API module \""_s, keyStr, "\" returned an error"_s)); - globalObject->m_pendingNapiModuleAndExports[0].set(vm, globalObject, errorInstance); + JSC::throwException(globalObject, scope, errorInstance); return; } if (!resultValue.isObject()) { JSValue errorInstance = createError(globalObject, makeString("Expected Node-API module \""_s, keyStr, "\" to return an exports object"_s)); - globalObject->m_pendingNapiModuleAndExports[0].set(vm, globalObject, errorInstance); + JSC::throwException(globalObject, scope, errorInstance); return; } - auto* meta = new Bun::NapiModuleMeta(globalObject->m_pendingNapiModuleDlopenHandle); + auto* meta = new Bun::NapiModuleMeta(dlopenHandle); // TODO: think about the finalizer here Bun::NapiExternal* napi_external = Bun::NapiExternal::create(vm, globalObject->NapiExternalStructure(), meta, nullptr, nullptr, env.ptr()); @@ -814,8 +792,6 @@ void Napi::executePendingNapiModule(Zig::GlobalObject* globalObject) bool success = resultValue.getObject()->putDirect(vm, WebCore::builtinNames(vm).napiDlopenHandlePrivateName(), napi_external, JSC::PropertyAttribute::DontDelete | JSC::PropertyAttribute::ReadOnly); ASSERT(success); - globalObject->m_pendingNapiModuleDlopenHandle = nullptr; - // https://github.com/nodejs/node/blob/2eff28fb7a93d3f672f80b582f664a7c701569fb/src/node_api.cc#L734-L742 // https://github.com/oven-sh/bun/issues/1288 if (strongExportsObject && strongExportsObject.get() != resultValue) { @@ -823,26 +799,21 @@ void Napi::executePendingNapiModule(Zig::GlobalObject* globalObject) strongObject->put(strongObject.get(), globalObject, WebCore::builtinNames(vm).exportsPublicName(), resultValue, slot); RETURN_IF_EXCEPTION(scope, void()); } - - globalObject->m_pendingNapiModuleAndExports[1].set(vm, globalObject, object); } extern "C" void napi_module_register(napi_module* mod) { Zig::GlobalObject* globalObject = defaultGlobalObject(); - JSC::VM& vm = JSC::getVM(globalObject); // Increment this one even if the module is invalid so that functionDlopen // knows that napi_module_register was attempted globalObject->napiModuleRegisterCallCount++; // Append to vector to accumulate ALL module registrations during dlopen + // This runs inside dlopen() and can't throw, so queue a module without an entry point too and let executePendingNapiModule throw. + globalObject->m_pendingNapiModules.append(mod ? *mod : napi_module {}); if (mod && mod->nm_register_func) { - globalObject->m_pendingNapiModules.append(*mod); // Increment the counter to signal that a module registered itself Bun__napi_module_register_count++; - } else { - JSValue errorInstance = createError(globalObject, makeString("Module has no declared entry point."_s)); - globalObject->m_pendingNapiModuleAndExports[0].set(vm, globalObject, errorInstance); } } @@ -1392,7 +1363,10 @@ extern "C" napi_status napi_get_and_clear_last_exception(napi_env env, if (scope.exception()) [[unlikely]] { *result = toNapi(JSValue(scope.exception()->value()), globalObject); } else if (std::optional pending = env->pendingException()) { - *result = toNapi(pending.value(), globalObject); + JSValue value = pending.value(); + if (auto* latched = dynamicDowncast(value)) [[unlikely]] + value = latched->vm().isTerminationException(latched) ? JSC::jsNull() : latched->value(); + *result = toNapi(value, globalObject); env->clearPendingException(); } else { *result = toNapi(JSC::jsUndefined(), globalObject); @@ -1738,6 +1712,25 @@ extern "C" JS_EXPORT napi_status node_api_create_sharedarraybuffer(napi_env env, NAPI_RETURN_SUCCESS(env); } +// Node 15 through 21 had the same limit and error: https://github.com/nodejs/node/blob/v20.18.0/src/node_buffer.cc#L457-L461 +static NEVER_INLINE void throwBufferTooLarge(napi_env env) +{ + Zig::GlobalObject* globalObject = toJS(env); + JSC::VM& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + static_assert(MAX_ARRAY_BUFFER_SIZE == 0x100000000ull); + scope.throwException(globalObject, createErrorWithCode(vm, globalObject, "ERR_BUFFER_TOO_LARGE"_s, "Cannot create a Buffer larger than 0x100000000 bytes"_s, JSC::ErrorType::Error)); +} + +// Node also runs finalize_cb on this failure. Bun doesn't, because node-addon-api would then free its finalizer data twice. +#define NAPI_RETURN_IF_BUFFER_TOO_LARGE(_env, _length) \ + do { \ + if ((_length) > MAX_ARRAY_BUFFER_SIZE) [[unlikely]] { \ + throwBufferTooLarge(_env); \ + NAPI_RETURN_STATUS_IF_EXCEPTION(_env, napi_generic_failure); \ + } \ + } while (0) + // SharedArrayBuffer backing stores can outlive the creating napi_env (they // may be posted to other agents), so Node-API specifies a finalizer with no // env parameter. This destructor mirrors NapiExternalBufferDestructor but @@ -1775,6 +1768,7 @@ extern "C" JS_EXPORT napi_status node_api_create_external_sharedarraybuffer(napi NAPI_CHECK_ENV_NOT_IN_GC(env); NAPI_CHECK_ARG(env, result); NAPI_RETURN_EARLY_IF_FALSE(env, !env->hasPendingException(), napi_pending_exception); + NAPI_RETURN_IF_BUFFER_TOO_LARGE(env, byte_length); Zig::GlobalObject* globalObject = toJS(env); JSC::VM& vm = JSC::getVM(globalObject); @@ -2439,6 +2433,8 @@ extern "C" napi_status napi_create_external_buffer(napi_env env, size_t length, { NAPI_PREAMBLE(env); NAPI_CHECK_ARG(env, result); + // Like Node, check this before the NULL-data branch: https://github.com/nodejs/node/blob/v26.3.0/src/node_buffer.cc#L478-L484 + NAPI_RETURN_IF_BUFFER_TOO_LARGE(env, length); Zig::GlobalObject* globalObject = toJS(env); JSC::VM& vm = JSC::getVM(globalObject); @@ -2490,6 +2486,7 @@ extern "C" napi_status napi_create_external_arraybuffer(napi_env env, void* exte { NAPI_PREAMBLE(env); NAPI_CHECK_ARG(env, result); + NAPI_RETURN_IF_BUFFER_TOO_LARGE(env, byte_length); Zig::GlobalObject* globalObject = toJS(env); JSC::VM& vm = JSC::getVM(globalObject); @@ -3429,6 +3426,16 @@ extern "C" bool NapiEnv__hasPendingException(napi_env env) return scope.exception() != nullptr; } +// The wrapper that bun:ffi cc() generates calls this after the compiled function returns. +extern "C" bool NapiEnv__throwPendingException(napi_env env) +{ + if (env->throwPendingException()) { + return true; + } + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(env->vm()); + return scope.exception() != nullptr; +} + extern "C" uint32_t Bun__napi_get_version(napi_env env) { return env->napiModule().nm_version; diff --git a/src/jsc/bindings/napi.h b/src/jsc/bindings/napi.h index 024d45d4b318..44380ab2aea4 100644 --- a/src/jsc/bindings/napi.h +++ b/src/jsc/bindings/napi.h @@ -468,7 +468,7 @@ struct NapiEnv : public WTF::RefCounted { // gcUnprotect()s the previous one. NapiEnv outlives its owning global — // GC-enqueued NapiFinalizerTasks hold a Ref and run on the event // loop *after* the swap. Finalizer.run opens a NapiHandleScope via - // env->globalObject(), which would write m_currentNapiHandleScopeImpl on + // env->globalObject(), which would write m_currentHandleScopeImpl on // the now-dead old global and trip a write barrier on an unmarked cell // (debug: `ASSERT(isMarked(cell))` in Heap::addToRememberedSet; release: // segfault when the marker later walks it). The isolation swap calls this @@ -700,8 +700,8 @@ class NapiRefSelfDeletingWeakHandleOwner final : public JSC::WeakHandleOwner { }; // If a module registered itself by calling napi_module_register in a static constructor, run this -// to run the module's entrypoint. -void executePendingNapiModule(Zig::GlobalObject* globalObject); +// to run the module's entrypoint with moduleObject as `module`. Throws on failure. +void executePendingNapiModule(Zig::GlobalObject* globalObject, const napi_module& mod, void* dlopenHandle, JSC::JSObject* moduleObject); } @@ -721,7 +721,7 @@ static inline Zig::GlobalObject* toJS(napi_env val) static inline napi_value toNapi(JSC::JSValue val, Zig::GlobalObject* globalObject) { if (val.isCell()) { - if (auto* scope = globalObject->m_currentNapiHandleScopeImpl.get()) { + if (auto* scope = globalObject->m_currentHandleScopeImpl.get()) { scope->append(val); } } diff --git a/src/jsc/bindings/napi_handle_scope.cpp b/src/jsc/bindings/napi_handle_scope.cpp index 7c4be7ec19a6..ef0b5f63b3e2 100644 --- a/src/jsc/bindings/napi_handle_scope.cpp +++ b/src/jsc/bindings/napi_handle_scope.cpp @@ -2,21 +2,22 @@ #include "napi.h" #include "ZigGlobalObject.h" +#include "v8/shim/HandleScopeBuffer.h" namespace Bun { // for CREATE_METHOD_TABLE namespace JSCastingHelpers = JSC::JSCastingHelpers; -const JSC::ClassInfo NapiHandleScopeImpl::s_info = { - "NapiHandleScopeImpl"_s, +const JSC::ClassInfo HandleScopeImpl::s_info = { + "HandleScopeImpl"_s, nullptr, nullptr, nullptr, - CREATE_METHOD_TABLE(NapiHandleScopeImpl) + CREATE_METHOD_TABLE(HandleScopeImpl) }; -NapiHandleScopeImpl::NapiHandleScopeImpl(JSC::VM& vm, JSC::Structure* structure, NapiHandleScopeImpl* parent, bool escapable) +HandleScopeImpl::HandleScopeImpl(JSC::VM& vm, JSC::Structure* structure, HandleScopeImpl* parent, bool escapable) : Base(vm, structure) , m_parent(parent) , m_escapeSlot(nullptr) @@ -26,21 +27,23 @@ NapiHandleScopeImpl::NapiHandleScopeImpl(JSC::VM& vm, JSC::Structure* structure, } } -NapiHandleScopeImpl* NapiHandleScopeImpl::create(JSC::VM& vm, +HandleScopeImpl::~HandleScopeImpl() = default; + +HandleScopeImpl* HandleScopeImpl::create(JSC::VM& vm, JSC::Structure* structure, - NapiHandleScopeImpl* parent, + HandleScopeImpl* parent, bool escapable) { - NapiHandleScopeImpl* buffer = new (NotNull, JSC::allocateCell(vm)) - NapiHandleScopeImpl(vm, structure, parent, escapable); + HandleScopeImpl* buffer = new (NotNull, JSC::allocateCell(vm)) + HandleScopeImpl(vm, structure, parent, escapable); buffer->finishCreation(vm); return buffer; } template -void NapiHandleScopeImpl::visitChildrenImpl(JSCell* cell, Visitor& visitor) +void HandleScopeImpl::visitChildrenImpl(JSCell* cell, Visitor& visitor) { - NapiHandleScopeImpl* thisObject = uncheckedDowncast(cell); + HandleScopeImpl* thisObject = uncheckedDowncast(cell); ASSERT_GC_OBJECT_INHERITS(thisObject, info()); Base::visitChildren(thisObject, visitor); @@ -50,20 +53,24 @@ void NapiHandleScopeImpl::visitChildrenImpl(JSCell* cell, Visitor& visitor) visitor.append(handle); } + if (auto* v8Handles = thisObject->m_v8Handles.get()) { + v8Handles->visitHandles(visitor); + } + if (thisObject->m_parent) { visitor.appendUnbarriered(thisObject->m_parent); } } -DEFINE_VISIT_CHILDREN(NapiHandleScopeImpl); +DEFINE_VISIT_CHILDREN(HandleScopeImpl); -void NapiHandleScopeImpl::append(JSC::JSValue val) +void HandleScopeImpl::append(JSC::JSValue val) { WTF::Locker locker { cellLock() }; m_storage.append(Slot(vm(), this, val)); } -bool NapiHandleScopeImpl::escape(JSC::JSValue val) +bool HandleScopeImpl::escape(JSC::JSValue val) { if (!m_escapeSlot) { return false; @@ -74,25 +81,44 @@ bool NapiHandleScopeImpl::escape(JSC::JSValue val) return true; } -NapiHandleScopeImpl::Slot* NapiHandleScopeImpl::reserveSlot() +HandleScopeImpl::Slot* HandleScopeImpl::reserveSlot() { WTF::Locker locker { cellLock() }; m_storage.append(Slot()); return &m_storage.last(); } -void NapiHandleScopeImpl::releaseHandles() +v8::shim::HandleScopeBuffer& HandleScopeImpl::ensureV8Handles(v8::Isolate* isolate) +{ + if (m_v8Handles) { + return *m_v8Handles; + } + auto v8Handles = makeUnique(this, isolate); + WTF::Locker locker { cellLock() }; + m_v8Handles = WTF::move(v8Handles); + return *m_v8Handles; +} + +void HandleScopeImpl::releaseHandles() { // Match V8: closing a scope releases its handles immediately. Otherwise a // closed scope cell that stays live for any reason (e.g. a conservative-scan // pin) keeps marking every value it ever held, plus its whole parent chain. - WTF::Locker locker { cellLock() }; - m_storage.clear(); - m_escapeSlot = nullptr; - m_parent = nullptr; + if (m_v8Handles) { + // close() can move handles into m_parent, so call it before dropping the parent edge. + m_v8Handles->close(m_parent); + } + std::unique_ptr v8Handles; + { + WTF::Locker locker { cellLock() }; + v8Handles = WTF::move(m_v8Handles); + m_storage.clear(); + m_escapeSlot = nullptr; + m_parent = nullptr; + } } -NapiHandleScopeImpl* NapiHandleScope::open(Zig::GlobalObject* globalObject, bool escapable) +HandleScopeImpl* HandleScopeImpl::open(Zig::GlobalObject* globalObject, bool escapable) { auto& vm = JSC::getVM(globalObject); // Do not create a new handle scope while a finalizer is in progress @@ -107,66 +133,76 @@ NapiHandleScopeImpl* NapiHandleScope::open(Zig::GlobalObject* globalObject, bool return nullptr; } - auto* impl = NapiHandleScopeImpl::create(vm, - globalObject->NapiHandleScopeImplStructure(), - globalObject->m_currentNapiHandleScopeImpl.get(), + auto* impl = HandleScopeImpl::create(vm, + globalObject->HandleScopeImplStructure(), + globalObject->m_currentHandleScopeImpl.get(), escapable); - globalObject->m_currentNapiHandleScopeImpl.set(vm, globalObject, impl); + globalObject->m_currentHandleScopeImpl.set(vm, globalObject, impl); return impl; } -void NapiHandleScope::close(Zig::GlobalObject* globalObject, NapiHandleScopeImpl* current) +void HandleScopeImpl::close(Zig::GlobalObject* globalObject, HandleScopeImpl* current) { NAPI_LOG_CURRENT_FUNCTION; // napi handle scopes may be null pointers if created inside a finalizer if (!current) { return; } - RELEASE_ASSERT_WITH_MESSAGE(current == globalObject->m_currentNapiHandleScopeImpl.get(), - "Unbalanced napi_handle_scope opens and closes"); + RELEASE_ASSERT_WITH_MESSAGE(current == globalObject->m_currentHandleScopeImpl.get(), + "Unbalanced handle scope opens and closes"); if (auto* parent = current->parent()) { - globalObject->m_currentNapiHandleScopeImpl.set(globalObject->vm(), globalObject, parent); + globalObject->m_currentHandleScopeImpl.set(globalObject->vm(), globalObject, parent); } else { - globalObject->m_currentNapiHandleScopeImpl.clear(); + globalObject->m_currentHandleScopeImpl.clear(); } current->releaseHandles(); } NapiHandleScope::NapiHandleScope(Zig::GlobalObject* globalObject) - : m_globalObject(globalObject) - , m_impl(NapiHandleScope::open(globalObject, false)) + : m_impl(HandleScopeImpl::open(globalObject, false)) + , m_globalObject(globalObject) { } NapiHandleScope::~NapiHandleScope() { - NapiHandleScope::close(m_globalObject, m_impl); + HandleScopeImpl::close(m_globalObject, m_impl); +} + +extern "C" HandleScopeImpl* NapiHandleScope__open(napi_env env, bool escapable) +{ + return HandleScopeImpl::open(env->globalObject(), escapable); +} + +extern "C" void NapiHandleScope__close(napi_env env, HandleScopeImpl* current) +{ + return HandleScopeImpl::close(env->globalObject(), current); } -extern "C" NapiHandleScopeImpl* NapiHandleScope__open(napi_env env, bool escapable) +extern "C" HandleScopeImpl* HandleScopeImpl__open(Zig::GlobalObject* globalObject) { - return NapiHandleScope::open(env->globalObject(), escapable); + return HandleScopeImpl::open(globalObject, false); } -extern "C" void NapiHandleScope__close(napi_env env, NapiHandleScopeImpl* current) +extern "C" void HandleScopeImpl__close(Zig::GlobalObject* globalObject, HandleScopeImpl* current) { - return NapiHandleScope::close(env->globalObject(), current); + return HandleScopeImpl::close(globalObject, current); } extern "C" void NapiHandleScope__append(napi_env env, JSC::EncodedJSValue value) { // Match toNapi() in napi.h: non-cell values need no rooting, and the // current handle scope is null when a finalizer runs immediately during - // sweep (NapiHandleScope::open returns nullptr while the mutator is + // sweep (HandleScopeImpl::open returns nullptr while the mutator is // sweeping). JSC::JSValue v = JSC::JSValue::decode(value); if (!v.isCell()) return; - if (auto* scope = env->globalObject()->m_currentNapiHandleScopeImpl.get()) + if (auto* scope = env->globalObject()->m_currentHandleScopeImpl.get()) scope->append(v); } -extern "C" bool NapiHandleScope__escape(NapiHandleScopeImpl* handleScope, JSC::EncodedJSValue value) +extern "C" bool NapiHandleScope__escape(HandleScopeImpl* handleScope, JSC::EncodedJSValue value) { return handleScope->escape(JSC::JSValue::decode(value)); } diff --git a/src/jsc/bindings/napi_handle_scope.h b/src/jsc/bindings/napi_handle_scope.h index d89313f0cf57..29b71a4cd9bd 100644 --- a/src/jsc/bindings/napi_handle_scope.h +++ b/src/jsc/bindings/napi_handle_scope.h @@ -5,22 +5,28 @@ typedef struct NapiEnv* napi_env; +namespace v8 { +class Isolate; +namespace shim { +class HandleScopeBuffer; +} +} + namespace Bun { -// An array of write barriers (so that newly-added objects are not lost by GC) to JSValues. Unlike -// the V8 version, pointer stability is not required (because napi_values don't point into this -// structure) so we can use a regular WTF::Vector -// -// Don't use this directly, use NapiHandleScope. Most NAPI functions won't even need to use that as -// a handle scope is created before calling a native function. -class NapiHandleScopeImpl : public JSC::JSCell { +// An open handle scope, shared by Node-API and the V8 API. GlobalObject::m_currentHandleScopeImpl +// is the innermost one. It keeps alive the napi_values appended to it (a napi_value is the JSValue +// itself, so plain write barriers are enough) and the V8 handles created inside it (in a +// HandleScopeBuffer, attached on first use). NapiHandleScope, napi_open_handle_scope and +// v8::HandleScope all wrap open() and close(). +class HandleScopeImpl : public JSC::JSCell { public: using Base = JSC::JSCell; - static NapiHandleScopeImpl* create( + static HandleScopeImpl* create( JSC::VM& vm, JSC::Structure* structure, - NapiHandleScopeImpl* parent, + HandleScopeImpl* parent, bool escapable = false); static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject) @@ -33,8 +39,8 @@ class NapiHandleScopeImpl : public JSC::JSCell { { if constexpr (mode == JSC::SubspaceAccess::Concurrently) return nullptr; - return WebCore::subspaceForImpl(vm, BUN_SUBSPACE_SLOTS(m_clientSubspaceForNapiHandleScopeImpl, m_subspaceForNapiHandleScopeImpl), - [](auto& server) -> JSC::HeapCellType& { return server.m_heapCellTypeForNapiHandleScopeImpl; }); + return WebCore::subspaceForImpl(vm, BUN_SUBSPACE_SLOTS(m_clientSubspaceForHandleScopeImpl, m_subspaceForHandleScopeImpl), + [](auto& server) -> JSC::HeapCellType& { return server.m_heapCellTypeForHandleScopeImpl; }); } DECLARE_INFO; @@ -43,62 +49,65 @@ class NapiHandleScopeImpl : public JSC::JSCell { static constexpr JSC::DestructionMode needsDestruction = JSC::DestructionMode::NeedsDestruction; static void destroy(JSC::JSCell* cell) { - static_cast(cell)->~NapiHandleScopeImpl(); + static_cast(cell)->~HandleScopeImpl(); } - ~NapiHandleScopeImpl() = default; + ~HandleScopeImpl(); + + // Returns null while the GC is sweeping (see the definition). close() accepts null. + static HandleScopeImpl* open(Zig::GlobalObject* globalObject, bool escapable); + // `current` must be the innermost open scope. + static void close(Zig::GlobalObject* globalObject, HandleScopeImpl* current); - // Store val in the handle scope + // Keeps a napi_value alive until this scope closes. void append(JSC::JSValue val); - NapiHandleScopeImpl* parent() const { return m_parent; } + HandleScopeImpl* parent() const { return m_parent; } // Returns false if this handle scope is not escapable or if it is but escape() has already // been called bool escape(JSC::JSValue val); - // Drop all handles (and the parent edge) when the scope is closed, like V8 does. - void releaseHandles(); + + // Returns null until the first V8 call inside this scope attaches a buffer. + v8::shim::HandleScopeBuffer* v8Handles() const { return m_v8Handles.get(); } + v8::shim::HandleScopeBuffer& ensureV8Handles(v8::Isolate* isolate); private: using Slot = JSC::WriteBarrier; - NapiHandleScopeImpl* m_parent; + HandleScopeImpl* m_parent; WTF::Vector m_storage; Slot* m_escapeSlot; + std::unique_ptr m_v8Handles; Slot* reserveSlot(); + void releaseHandles(); - NapiHandleScopeImpl(JSC::VM& vm, JSC::Structure* structure, NapiHandleScopeImpl* parent, bool escapable); + HandleScopeImpl(JSC::VM& vm, JSC::Structure* structure, HandleScopeImpl* parent, bool escapable); }; -// Wrapper class used to open a new handle scope and close it when this instance goes out of scope +// Opens a scope for the duration of a C++ block. Bun opens one around every Node-API callback it makes. class NapiHandleScope { public: NapiHandleScope(Zig::GlobalObject* globalObject); ~NapiHandleScope(); - // Create a new handle scope in the given environment - static NapiHandleScopeImpl* open(Zig::GlobalObject* globalObject, bool escapable); - - // Closes the most recently created handle scope in the given environment and restores the old one. - // Asserts that `current` is the active handle scope. - static void close(Zig::GlobalObject* globalObject, NapiHandleScopeImpl* current); - private: - NapiHandleScopeImpl* m_impl; + HandleScopeImpl* m_impl; Zig::GlobalObject* m_globalObject; }; -// Create a new handle scope in the given environment -extern "C" NapiHandleScopeImpl* NapiHandleScope__open(napi_env env, bool escapable); +// Used by napi_open_handle_scope and friends (napi_body.rs) and by bun:ffi calls. +extern "C" HandleScopeImpl* NapiHandleScope__open(napi_env env, bool escapable); +extern "C" void NapiHandleScope__close(napi_env env, HandleScopeImpl* current); -// Pop the most recently created handle scope in the given environment and restore the old one. -// Asserts that `current` is the active handle scope. -extern "C" void NapiHandleScope__close(napi_env env, NapiHandleScopeImpl* current); +// Used around libuv callbacks (uv_posix.rs), which have no napi_env. +extern "C" HandleScopeImpl* HandleScopeImpl__open(Zig::GlobalObject* globalObject); +extern "C" void HandleScopeImpl__close(Zig::GlobalObject* globalObject, HandleScopeImpl* current); -// Store a value in the active handle scope in the given environment +// Store a value in the innermost open scope extern "C" void NapiHandleScope__append(napi_env env, JSC::EncodedJSValue value); // Put a value from the current handle scope into its escape slot reserved in the outer handle // scope. Returns false if the current handle scope is not escapable or if escape has already been // called on it. -extern "C" bool NapiHandleScope__escape(NapiHandleScopeImpl* handle_scope, JSC::EncodedJSValue value); +extern "C" bool NapiHandleScope__escape(HandleScopeImpl* handle_scope, JSC::EncodedJSValue value); } // namespace Bun diff --git a/src/jsc/bindings/ncrypto.cpp b/src/jsc/bindings/ncrypto.cpp index 7a019159d030..e79668a7aa95 100644 --- a/src/jsc/bindings/ncrypto.cpp +++ b/src/jsc/bindings/ncrypto.cpp @@ -1632,14 +1632,19 @@ const EVP_MD* getDigestByName(const WTF::StringView name) } } - auto nameUtf8 = name.utf8(); - return EVP_get_digestbyname(nameUtf8.legacyCStringPointer()); + // utf8() asserts on failure. A name too long to convert isn't a digest. + auto nameUtf8 = name.tryGetUTF8(); + if (!nameUtf8) [[unlikely]] + return nullptr; + return EVP_get_digestbyname(nameUtf8->legacyCStringPointer()); } const EVP_CIPHER* getCipherByName(const WTF::StringView name) { - auto nameUtf8 = name.utf8(); - return EVP_get_cipherbyname(nameUtf8.legacyCStringPointer()); + auto nameUtf8 = name.tryGetUTF8(); + if (!nameUtf8) [[unlikely]] + return nullptr; + return EVP_get_cipherbyname(nameUtf8->legacyCStringPointer()); } bool checkHkdfLength(const Digest& md, size_t length) @@ -2432,8 +2437,7 @@ const Cipher Cipher::FromName(WTF::StringView name) if (remain == "256"_s) return Cipher::AES_256_CBC(); } - auto nameUtf8 = name.utf8(); - return Cipher(EVP_get_cipherbyname(nameUtf8.legacyCStringPointer())); + return Cipher(getCipherByName(name)); } const Cipher Cipher::FromNid(int nid) @@ -3412,11 +3416,14 @@ DataPointer Cipher::recover(const EVPKeyPointer& key, // ============================================================================ -int Ec::GetCurveIdFromName(const char* name) +int Ec::GetCurveIdFromName(WTF::StringView name) { - int nid = EC_curve_nist2nid(name); + auto nameUtf8 = name.tryGetUTF8(); + if (!nameUtf8) [[unlikely]] + return NID_undef; + int nid = EC_curve_nist2nid(nameUtf8->legacyCStringPointer()); if (nid == NID_undef) { - nid = OBJ_sn2nid(name); + nid = OBJ_sn2nid(nameUtf8->legacyCStringPointer()); } return nid; } diff --git a/src/jsc/bindings/ncrypto.h b/src/jsc/bindings/ncrypto.h index fcd41c546b0f..1753d1a2c9e7 100644 --- a/src/jsc/bindings/ncrypto.h +++ b/src/jsc/bindings/ncrypto.h @@ -419,7 +419,7 @@ class Rsa final { class Ec final { public: - static int GetCurveIdFromName(const char* name); + static int GetCurveIdFromName(WTF::StringView name); }; // A managed pointer to a buffer of data. When destroyed the underlying diff --git a/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp b/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp index 67990a061c7f..2384a68e7e20 100644 --- a/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp +++ b/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp @@ -145,7 +145,8 @@ static void upgradeToTunnelModeImpl(us_socket_t* socket, bool afterBody) void JSNodeHTTPServerSocket::upgradeToTunnelMode(bool afterBody, WebCore::JSNodeHTTPResponse* response) { - if (!socket || us_socket_is_closed(socket)) { + /* Once a WebSocket adopts the connection, its ext block is no longer an HttpResponseData. */ + if (!socket || upgraded || us_socket_is_closed(socket)) { return; } /* Like Node's http server connections (allowHalfOpen: true): the peer @@ -313,7 +314,8 @@ static std::string& responseTrailersFor(us_socket_t* socket) void JSNodeHTTPServerSocket::setResponseTrailers(WTF::StringView trailers) { - if (!socket || us_socket_is_closed(socket) || trailers.isEmpty()) { + /* Once a WebSocket adopts the connection, its ext block is no longer an HttpResponseData. */ + if (!socket || upgraded || us_socket_is_closed(socket) || trailers.isEmpty()) { return; } // Node writes trailers with encoding 'latin1' (lib/_http_outgoing.js); @@ -472,7 +474,7 @@ bool JSNodeHTTPServerSocket::isRequestTimedOut(uint64_t headersTimeoutMs, uint64 bool JSNodeHTTPServerSocket::isAuthorized() const { // is secure means that tls was established successfully - if (!is_ssl || !socket) + if (!is_ssl || upgraded || !socket) return false; // Check if the handshake callback has fired. If so, use the isAuthorized flag @@ -872,11 +874,8 @@ template static bool writeBehindResponse(us_socket_t* socket, const char* data, size_t length) { auto* asyncSocket = reinterpret_cast*>(socket); - while (length > 0) { - const int chunk = static_cast(std::min(length, static_cast(INT_MAX))); - asyncSocket->write(data, chunk); - data += chunk; - length -= chunk; + if (length > 0) { + asyncSocket->write(data, length); } return asyncSocket->getBufferedAmount() > 0; } diff --git a/src/jsc/bindings/node/crypto/CryptoGenEcKeyPair.cpp b/src/jsc/bindings/node/crypto/CryptoGenEcKeyPair.cpp index 25da4d7cb256..1323b5ce31f9 100644 --- a/src/jsc/bindings/node/crypto/CryptoGenEcKeyPair.cpp +++ b/src/jsc/bindings/node/crypto/CryptoGenEcKeyPair.cpp @@ -115,9 +115,7 @@ std::optional EcKeyPairJobCtx::fromJS(JSGlobalObject* globalObj GCOwnedDataScope namedCurveView = namedCurveString->view(globalObject); RETURN_IF_EXCEPTION(scope, std::nullopt); - UTF8CString curveName = namedCurveView->utf8(); - - int curveNid = ncrypto::Ec::GetCurveIdFromName(curveName.legacyCStringPointer()); + int curveNid = ncrypto::Ec::GetCurveIdFromName(namedCurveView); if (curveNid == NID_undef) { ERR::CRYPTO_INVALID_CURVE(scope, globalObject); return std::nullopt; diff --git a/src/jsc/bindings/node/crypto/CryptoHkdf.cpp b/src/jsc/bindings/node/crypto/CryptoHkdf.cpp index 4f4b42fa92b6..77db983374b1 100644 --- a/src/jsc/bindings/node/crypto/CryptoHkdf.cpp +++ b/src/jsc/bindings/node/crypto/CryptoHkdf.cpp @@ -139,46 +139,45 @@ KeyObject prepareKey(JSGlobalObject* globalObject, ThrowScope& scope, JSValue ke RETURN_IF_EXCEPTION(scope, {}); auto* view = dynamicDowncast(buffer); - Vector copy; - copy.append(view->span()); - return KeyObject::create(WTF::move(copy)); + auto copy = copyArgumentBytes(globalObject, scope, view->span(), "key"_s); + if (!copy) return {}; + return KeyObject::create(WTF::move(*copy)); } // Handle ArrayBuffer types if (auto* view = dynamicDowncast(key)) { - Vector copy; - copy.append(view->span()); - return KeyObject::create(WTF::move(copy)); + auto copy = copyArgumentBytes(globalObject, scope, view->span(), "key"_s); + if (!copy) return {}; + return KeyObject::create(WTF::move(*copy)); } if (auto* buf = dynamicDowncast(key)) { - auto* impl = buf->impl(); - Vector copy; - copy.append(impl->span()); - return KeyObject::create(WTF::move(copy)); + auto copy = copyArgumentBytes(globalObject, scope, buf->impl()->span(), "key"_s); + if (!copy) return {}; + return KeyObject::create(WTF::move(*copy)); } ERR::INVALID_ARG_TYPE(scope, globalObject, "ikm"_s, "string or an instance of SecretKeyObject, ArrayBuffer, TypedArray, DataView, or Buffer"_s, key); return {}; } -void copyBufferOrString(JSGlobalObject* lexicalGlobalObject, ThrowScope& scope, JSValue value, const WTF::ASCIILiteral& name, WTF::Vector& buffer) +std::optional> copyBufferOrString(JSGlobalObject* lexicalGlobalObject, ThrowScope& scope, JSValue value, ASCIILiteral name) { if (value.isString()) { JSString* str = value.toString(lexicalGlobalObject); - RETURN_IF_EXCEPTION(scope, ); + RETURN_IF_EXCEPTION(scope, std::nullopt); GCOwnedDataScope view = str->view(lexicalGlobalObject); - RETURN_IF_EXCEPTION(scope, ); + RETURN_IF_EXCEPTION(scope, std::nullopt); auto utf8 = UTF8View::tryCreate(lexicalGlobalObject, scope, view); - RETURN_IF_EXCEPTION(scope, ); - buffer.append(utf8->span()); - } else if (auto* view = dynamicDowncast(value)) { - buffer.append(view->span()); - } else if (auto* buf = dynamicDowncast(value)) { - buffer.append(buf->impl()->span()); - } else { - ERR::INVALID_ARG_TYPE(scope, lexicalGlobalObject, name, "string, ArrayBuffer, TypedArray, Buffer"_s, value); + RETURN_IF_EXCEPTION(scope, std::nullopt); + return copyArgumentBytes(lexicalGlobalObject, scope, utf8->bytes(), name); } + if (auto* view = dynamicDowncast(value)) + return copyArgumentBytes(lexicalGlobalObject, scope, view->span(), name); + if (auto* buf = dynamicDowncast(value)) + return copyArgumentBytes(lexicalGlobalObject, scope, buf->impl()->span(), name); + ERR::INVALID_ARG_TYPE(scope, lexicalGlobalObject, name, "string, ArrayBuffer, TypedArray, Buffer"_s, value); + return std::nullopt; } std::optional HkdfJobCtx::fromJS(JSGlobalObject* lexicalGlobalObject, CallFrame* callFrame, ThrowScope& scope, Mode mode) @@ -197,20 +196,18 @@ std::optional HkdfJobCtx::fromJS(JSGlobalObject* lexicalGlobalObject KeyObject keyObject = prepareKey(lexicalGlobalObject, scope, keyValue); RETURN_IF_EXCEPTION(scope, std::nullopt); - WTF::Vector salt; - copyBufferOrString(lexicalGlobalObject, scope, saltValue, "salt"_s, salt); - RETURN_IF_EXCEPTION(scope, std::nullopt); + auto salt = copyBufferOrString(lexicalGlobalObject, scope, saltValue, "salt"_s); + if (!salt) return std::nullopt; - WTF::Vector info; - copyBufferOrString(lexicalGlobalObject, scope, infoValue, "info"_s, info); - RETURN_IF_EXCEPTION(scope, std::nullopt); + auto info = copyBufferOrString(lexicalGlobalObject, scope, infoValue, "info"_s); + if (!info) return std::nullopt; int32_t length = 0; V::validateInteger(scope, lexicalGlobalObject, lengthValue, "length"_s, jsNumber(0), jsNumber(Bun::Buffer::kMaxLength), &length); RETURN_IF_EXCEPTION(scope, std::nullopt); - if (info.size() > 1024) { - ERR::OUT_OF_RANGE(scope, lexicalGlobalObject, "info"_s, "must not contain more than 1024 bytes"_s, jsNumber(info.size())); + if (info->size() > 1024) { + ERR::OUT_OF_RANGE(scope, lexicalGlobalObject, "info"_s, "must not contain more than 1024 bytes"_s, jsNumber(info->size())); return std::nullopt; } @@ -227,7 +224,7 @@ std::optional HkdfJobCtx::fromJS(JSGlobalObject* lexicalGlobalObject return std::nullopt; } - return HkdfJobCtx(hash, length, WTF::move(keyObject), WTF::move(info), WTF::move(salt)); + return HkdfJobCtx(hash, length, WTF::move(keyObject), WTF::move(*info), WTF::move(*salt)); } JSC_DEFINE_HOST_FUNCTION(jsHkdf, (JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) diff --git a/src/jsc/bindings/node/crypto/CryptoSignJob.cpp b/src/jsc/bindings/node/crypto/CryptoSignJob.cpp index 7a6ac9f48af7..b5491224c813 100644 --- a/src/jsc/bindings/node/crypto/CryptoSignJob.cpp +++ b/src/jsc/bindings/node/crypto/CryptoSignJob.cpp @@ -286,8 +286,8 @@ std::optional SignJobCtx::fromJS(JSGlobalObject* globalObject, Throw auto dataView = getArrayBufferOrView2(globalObject, scope, dataValue, "data"_s, jsUndefined()); RETURN_IF_EXCEPTION(scope, {}); - Vector data; - data.append(std::span { dataView->data(), dataView->size() }); + auto data = copyArgumentBytes(globalObject, scope, std::span { dataView->data(), dataView->size() }, "data"_s); + if (!data) return std::nullopt; if (mode == Mode::Sign) { if (keyValue.pureToBoolean() == TriState::False) { @@ -321,7 +321,9 @@ std::optional SignJobCtx::fromJS(JSGlobalObject* globalObject, Throw if (mode == Mode::Verify) { auto signatureView = getArrayBufferOrView2(globalObject, scope, signatureValue, "signature"_s, jsUndefined(), true); RETURN_IF_EXCEPTION(scope, {}); - signatureData.append(std::span { signatureView->data(), signatureView->size() }); + auto copy = copyArgumentBytes(globalObject, scope, std::span { signatureView->data(), signatureView->size() }, "signature"_s); + if (!copy) return std::nullopt; + signatureData = WTF::move(*copy); } auto prepareResult = mode == Mode::Verify @@ -432,7 +434,7 @@ std::optional SignJobCtx::fromJS(JSGlobalObject* globalObject, Throw return SignJobCtx( mode, keyObject.data(), - WTF::move(data), + WTF::move(*data), digest, padding, pssSaltLength, @@ -444,7 +446,7 @@ std::optional SignJobCtx::fromJS(JSGlobalObject* globalObject, Throw return SignJobCtx( mode, keyObject.data(), - WTF::move(data), + WTF::move(*data), digest, padding, pssSaltLength, diff --git a/src/jsc/bindings/node/crypto/CryptoUtil.cpp b/src/jsc/bindings/node/crypto/CryptoUtil.cpp index e464a01dfeac..905b6ebdcc54 100644 --- a/src/jsc/bindings/node/crypto/CryptoUtil.cpp +++ b/src/jsc/bindings/node/crypto/CryptoUtil.cpp @@ -87,8 +87,10 @@ struct Hasher; extern "C" Hasher* Bun__CryptoHasherExtern__getByName(Zig::GlobalObject* globalObject, const char* name, size_t nameLen); Hasher* getByName(Zig::GlobalObject* globalObject, const StringView& name) { - auto utf8 = name.utf8(); - auto bytes = byteCast(utf8.span()); + auto utf8 = name.tryGetUTF8(); + if (!utf8) [[unlikely]] + return nullptr; + auto bytes = byteCast(utf8->span()); return Bun__CryptoHasherExtern__getByName(globalObject, bytes.data(), bytes.size()); } @@ -712,6 +714,16 @@ std::optional> getBuffer(JSC::JSValue maybeBuffer) return std::nullopt; } +std::optional> copyArgumentBytes(JSGlobalObject* globalObject, ThrowScope& scope, std::span bytes, ASCIILiteral argName) +{ + static_assert(WTF::isValidCapacityForVector(INT32_MAX)); + if (bytes.size() > INT32_MAX) [[unlikely]] { + throwError(globalObject, scope, ErrorCode::ERR_OUT_OF_RANGE, makeString(argName, " is too big"_s)); + return std::nullopt; + } + return Vector(bytes); +} + bool isStringOrBuffer(JSValue value) { if (value.isString()) { @@ -919,6 +931,11 @@ void parseKeyEncoding(JSGlobalObject* globalObject, ThrowScope& scope, JSObject* if (!passphraseValue.isUndefined()) { JSArrayBufferView* passphraseView = getArrayBufferOrView(globalObject, scope, passphraseValue, "key.passphrase"_s, encodingValue); RETURN_IF_EXCEPTION(scope, ); + // BoringSSL's length parameter is an `int`. + if (passphraseView->byteLength() > INT_MAX) { + ERR::OUT_OF_RANGE(scope, globalObject, "passphrase is too big"_s); + return; + } config.passphrase = DataPointer::FromSpan(passphraseView->span()); } diff --git a/src/jsc/bindings/node/crypto/CryptoUtil.h b/src/jsc/bindings/node/crypto/CryptoUtil.h index 74b77ad01d74..6f10d00b3669 100644 --- a/src/jsc/bindings/node/crypto/CryptoUtil.h +++ b/src/jsc/bindings/node/crypto/CryptoUtil.h @@ -63,6 +63,8 @@ JSC::JSArrayBufferView* getArrayBufferOrView(JSGlobalObject* globalObject, Throw JSC::JSArrayBufferView* getArrayBufferOrView(JSGlobalObject* globalObject, ThrowScope& scope, JSValue value, ASCIILiteral argName, BufferEncodingType encoding); bool isKeyValidForCurve(const EC_GROUP* group, const ncrypto::BignumPointer& privateKey); std::optional> getBuffer(JSC::JSValue maybeBuffer); +// Like Node, throws ERR_OUT_OF_RANGE " is too big" if `bytes` is longer than INT32_MAX. +std::optional> copyArgumentBytes(JSGlobalObject* globalObject, ThrowScope& scope, std::span bytes, ASCIILiteral argName); // For output encoding void parsePublicKeyEncoding(JSGlobalObject*, ThrowScope&, JSObject* enc, JSValue keyTypeValue, WTF::StringView objName, ncrypto::EVPKeyPointer::PublicKeyEncodingConfig&); diff --git a/src/jsc/bindings/node/crypto/JSCipher.cpp b/src/jsc/bindings/node/crypto/JSCipher.cpp index 716828c4d471..64096bc6b0ae 100644 --- a/src/jsc/bindings/node/crypto/JSCipher.cpp +++ b/src/jsc/bindings/node/crypto/JSCipher.cpp @@ -115,7 +115,8 @@ JSValue rsaFunction(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* ca // detach a borrowed label buffer, so copy the bytes instead of holding a view. auto view = getArrayBufferOrView2(lexicalGlobalObject, scope, oaepLabelValue, "options.oaepLabel"_s, encodingValue); RETURN_IF_EXCEPTION(scope, {}); - oaepLabel = WTF::Vector(std::span { view->data(), view->size() }); + oaepLabel = copyArgumentBytes(lexicalGlobalObject, scope, std::span { view->data(), view->size() }, "oaepLabel"_s); + if (!oaepLabel) return {}; } } diff --git a/src/jsc/bindings/node/crypto/JSECDHConstructor.cpp b/src/jsc/bindings/node/crypto/JSECDHConstructor.cpp index b16390fd4cc1..2a3fe7d1fb40 100644 --- a/src/jsc/bindings/node/crypto/JSECDHConstructor.cpp +++ b/src/jsc/bindings/node/crypto/JSECDHConstructor.cpp @@ -43,6 +43,15 @@ JSC_DEFINE_HOST_FUNCTION(callECDH, (JSC::JSGlobalObject * lexicalGlobalObject, J return JSValue::encode(result); } +// utf8() asserts on failure. A name too long to convert isn't a curve. +static int curveNidFromShortName(const WTF::String& name) +{ + auto nameUtf8 = name.tryGetUTF8(); + if (!nameUtf8) [[unlikely]] + return NID_undef; + return OBJ_sn2nid(nameUtf8->legacyCStringPointer()); +} + JSC_DEFINE_HOST_FUNCTION(constructECDH, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { JSC::VM& vm = globalObject->vm(); @@ -59,9 +68,7 @@ JSC_DEFINE_HOST_FUNCTION(constructECDH, (JSC::JSGlobalObject * globalObject, JSC WTF::String curveString = curveValue.toWTFString(globalObject); RETURN_IF_EXCEPTION(scope, {}); - auto curve = curveString.utf8(); - - int nid = OBJ_sn2nid(curve.legacyCStringPointer()); + int nid = curveNidFromShortName(curveString); if (nid == NID_undef) { return Bun::ERR::CRYPTO_INVALID_CURVE(scope, globalObject); } @@ -99,7 +106,7 @@ JSC_DEFINE_HOST_FUNCTION(jsECDHConvertKey, (JSC::JSGlobalObject * lexicalGlobalO auto buffer = keyView->span(); - int nid = OBJ_sn2nid(curveName.utf8().legacyCStringPointer()); + int nid = curveNidFromShortName(curveName); if (nid == NID_undef) return Bun::ERR::CRYPTO_INVALID_CURVE(scope, lexicalGlobalObject); diff --git a/src/jsc/bindings/node/crypto/KeyObject.cpp b/src/jsc/bindings/node/crypto/KeyObject.cpp index c0abf2bf9c7d..1379ca1e61f0 100644 --- a/src/jsc/bindings/node/crypto/KeyObject.cpp +++ b/src/jsc/bindings/node/crypto/KeyObject.cpp @@ -1387,8 +1387,7 @@ __attribute__((minsize)) KeyObject KeyObject::getKeyObjectHandleFromJwk(JSGlobal MarkPopErrorOnReturn markPopError; - auto crvUtf8 = crvView->utf8(); - int nid = Ec::GetCurveIdFromName(crvUtf8.legacyCStringPointer()); + int nid = Ec::GetCurveIdFromName(crvView); if (nid == NID_undef) { ERR::CRYPTO_INVALID_CURVE(scope, globalObject); return {}; @@ -1412,9 +1411,10 @@ __attribute__((minsize)) KeyObject KeyObject::getKeyObjectHandleFromJwk(JSGlobal if (keyType != CryptoKeyType::Public) { auto* dBuf = decodeJwkString(globalObject, scope, dView, "key.d"_s); + RETURN_IF_EXCEPTION(scope, {}); auto dBufSpan = dBuf->span(); BignumPointer dBn = BignumPointer(dBufSpan.data(), dBufSpan.size()); - if (!ec.setPrivateKey(dBn)) { + if (!ec.setPrivateKey(dBn) || !ec.checkKey()) { ERR::CRYPTO_INVALID_JWK(scope, globalObject, "Invalid JWK EC key"_s); return {}; } @@ -1538,8 +1538,7 @@ KeyObject KeyObject::getKeyObjectHandleFromRaw(JSGlobalObject* globalObject, Thr } WTF::String curveStr = namedCurveValue.toWTFString(globalObject); RETURN_IF_EXCEPTION(scope, {}); - auto curveUtf8 = curveStr.utf8(); - int curveNid = ncrypto::Ec::GetCurveIdFromName(curveUtf8.legacyCStringPointer()); + int curveNid = ncrypto::Ec::GetCurveIdFromName(curveStr); if (curveNid == NID_undef) { ERR::CRYPTO_INVALID_CURVE(scope, globalObject); return {}; @@ -2131,24 +2130,23 @@ KeyObject KeyObject::prepareSecretKey(JSGlobalObject* globalObject, ThrowScope& return {}; } - Vector copy; - copy.append(view->span()); - return create(WTF::move(copy)); + auto copy = copyArgumentBytes(globalObject, scope, view->span(), "key"_s); + if (!copy) return {}; + return create(WTF::move(*copy)); } // TODO(dylan-conway): avoid copying by keeping the buffer alive if (auto* view = dynamicDowncast(keyValue)) { - Vector copy; - copy.append(view->span()); - return create(WTF::move(copy)); + auto copy = copyArgumentBytes(globalObject, scope, view->span(), "key"_s); + if (!copy) return {}; + return create(WTF::move(*copy)); } // TODO(dylan-conway): avoid copying by keeping the buffer alive if (auto* arrayBuffer = dynamicDowncast(keyValue)) { - auto* impl = arrayBuffer->impl(); - Vector copy; - copy.append(impl->span()); - return create(WTF::move(copy)); + auto copy = copyArgumentBytes(globalObject, scope, arrayBuffer->impl()->span(), "key"_s); + if (!copy) return {}; + return create(WTF::move(*copy)); } if (bufferOnly) { diff --git a/src/jsc/bindings/node/http/JSConnectionsList.cpp b/src/jsc/bindings/node/http/JSConnectionsList.cpp index 296b1437aa41..ac5d5b6a43d2 100644 --- a/src/jsc/bindings/node/http/JSConnectionsList.cpp +++ b/src/jsc/bindings/node/http/JSConnectionsList.cpp @@ -93,7 +93,8 @@ JSArray* JSConnectionsList::idle(JSGlobalObject* globalObject) while (iter->next(globalObject, item)) { RETURN_IF_EXCEPTION(scope, nullptr); JSHTTPParser* parser = dynamicDowncast(item); - if (!parser) { + // close() frees the impl but doesn't remove the parser from the set. + if (!parser || !parser->impl()) { continue; } @@ -153,7 +154,7 @@ JSArray* JSConnectionsList::expired(JSGlobalObject* globalObject, uint64_t heade while (iter->next(globalObject, item)) { RETURN_IF_EXCEPTION(scope, nullptr); JSHTTPParser* parser = dynamicDowncast(item); - if (!parser) { + if (!parser || !parser->impl()) { continue; } diff --git a/src/jsc/bindings/sliceAnsi.cpp b/src/jsc/bindings/sliceAnsi.cpp index f8b95809c912..450e09609300 100644 --- a/src/jsc/bindings/sliceAnsi.cpp +++ b/src/jsc/bindings/sliceAnsi.cpp @@ -1,10 +1,12 @@ #include "root.h" #include "sliceAnsi.h" #include "ANSIHelpers.h" +#include "StringSizeLimit.h" #include "VectorSizeLimit.h" #include #include +#include #include // Native exports (implemented in stringWidth.cpp) for visible width and grapheme break @@ -531,9 +533,9 @@ static const Char* parseEscapeSequence(const Char* start, const Char* end) return nullptr; } -// Parse hyperlink: ESC]8;...;url TERMINATOR +// Parse hyperlink: ESC]8;...;url TERMINATOR. `code` is a view of the whole sequence in the input. template -static const Char* parseHyperlink(const Char* start, const Char* end, bool& isOpen, StringBuilder& codeBuilder, String& closePrefix, String& terminator) +static const Char* parseHyperlink(const Char* start, const Char* end, bool& isOpen, std::span& code, String& closePrefix, String& terminator) { const Char* it = start; bool isEscOsc = false; @@ -569,8 +571,7 @@ static const Char* parseHyperlink(const Char* start, const Char* end, bool& isOp if (*p == 0x07) { // BEL terminator isOpen = (p > uriStart); // empty URI = close - for (const Char* q = start; q <= p; ++q) - codeBuilder.append(static_cast(*q)); + code = std::span { start, static_cast(p + 1 - start) }; if (isEscOsc) { closePrefix = "\x1b]8;;"_s; } else { @@ -588,8 +589,7 @@ static const Char* parseHyperlink(const Char* start, const Char* end, bool& isOp if (*p == 0x1b && p + 1 < end && *(p + 1) == '\\') { // ESC\ terminator (ST) isOpen = (p > uriStart); - for (const Char* q = start; q <= p + 1; ++q) - codeBuilder.append(static_cast(*q)); + code = std::span { start, static_cast(p + 2 - start) }; if (isEscOsc) { closePrefix = "\x1b]8;;"_s; } else { @@ -604,8 +604,7 @@ static const Char* parseHyperlink(const Char* start, const Char* end, bool& isOp if (*p == 0x9c) { // C1 ST terminator isOpen = (p > uriStart); - for (const Char* q = start; q <= p; ++q) - codeBuilder.append(static_cast(*q)); + code = std::span { start, static_cast(p + 1 - start) }; if (isEscOsc) { closePrefix = "\x1b]8;;"_s; } else { @@ -706,13 +705,13 @@ static const Char* parseControlString(const Char* start, const Char* end) // Try to parse an ANSI sequence at position. Returns type and end pointer. template -static const Char* tryParseAnsi(const Char* start, const Char* end, TokenType& type, bool& isSgr, bool& isCanonicalSgr, bool& isHyperlinkOpen, StringBuilder& hyperlinkCodeBuilder, String& hyperlinkClosePrefix, String& hyperlinkTerminator) +static const Char* tryParseAnsi(const Char* start, const Char* end, TokenType& type, bool& isSgr, bool& isCanonicalSgr, bool& isHyperlinkOpen, std::span& hyperlinkCode, String& hyperlinkClosePrefix, String& hyperlinkTerminator) { Char c = *start; // Try hyperlink first (for ESC and C1 OSC) if (c == 0x1b || c == 0x9d) { - const Char* hlEnd = parseHyperlink(start, end, isHyperlinkOpen, hyperlinkCodeBuilder, hyperlinkClosePrefix, hyperlinkTerminator); + const Char* hlEnd = parseHyperlink(start, end, isHyperlinkOpen, hyperlinkCode, hyperlinkClosePrefix, hyperlinkTerminator); if (hlEnd) { type = TokenType::Hyperlink; return hlEnd; @@ -804,7 +803,7 @@ static size_t computeTotalWidth(std::span input, size_t asciiPrefix, if (ANSI::isEscapeCharacter(*p) || *p == 0x9c) { TokenType type; bool a, b, c; - StringBuilder d; + std::span d; String e, f; if (const Char* after = tryParseAnsi(p, dataEnd, type, a, b, c, d, e, f)) { p = after; @@ -873,12 +872,15 @@ static std::optional emitSliceStreaming( const Char* const dataEnd = data + input.size(); const bool endUnbounded = (end == SIZE_MAX); - StringBuilder result; - result.reserveCapacity(input.size()); + // Only the input and the ellipsis can put a 16-bit character into the result. + const bool canUpconvert = sizeof(Char) == 2 || !ellipsis.is8Bit(); + StringBuilder result { OverflowPolicy::RecordOverflow }; + result.reserveCapacity(canUpconvert ? cappedStringBuilderReserve(input.size()) : static_cast(input.size())); SgrStyleState activeStyles; bool activeHyperlink = false; - String activeHyperlinkClosePrefix, activeHyperlinkTerminator, activeHyperlinkCode; + String activeHyperlinkClosePrefix, activeHyperlinkTerminator; + std::span activeHyperlinkCode; // Column where the NEXT new cluster starts. Correct at all breaks. size_t position = 0; @@ -901,7 +903,7 @@ static std::optional emitSliceStreaming( }; Vector pending; // Captured hyperlink parse state per pending Hyperlink entry (indexed separately). - Vector, 2> pendingHl; // code, closePrefix, terminator + Vector, String, String>, 2> pendingHl; // code, closePrefix, terminator auto flushPending = [&](bool filterCloseOnly) { size_t hlIdx = 0; @@ -1232,7 +1234,7 @@ static std::optional emitSliceStreaming( if (ANSI::isEscapeCharacter(*p) || *p == 0x9c) { TokenType type = TokenType::Character; bool isSgr = false, isCanon = false, hlOpen = false; - StringBuilder hlCode; + std::span hlCode; String hlCP, hlTerm; const Char* after = tryParseAnsi(p, dataEnd, type, isSgr, isCanon, hlOpen, hlCode, hlCP, hlTerm); if (after) { @@ -1244,7 +1246,7 @@ static std::optional emitSliceStreaming( case TokenType::Hyperlink: if (hlOpen) { activeHyperlink = true; - activeHyperlinkCode = hlCode.toString(); + activeHyperlinkCode = hlCode; activeHyperlinkClosePrefix = hlCP; activeHyperlinkTerminator = hlTerm; } else @@ -1256,7 +1258,7 @@ static std::optional emitSliceStreaming( } else { if (pending.size() >= Bun::maxVectorSize() || !pending.tryAppend(Pending { p, after, type, hlOpen })) [[unlikely]] return std::nullopt; - if (type == TokenType::Hyperlink && !pendingHl.tryAppend(std::make_tuple(hlCode.toString(), hlCP, hlTerm))) [[unlikely]] + if (type == TokenType::Hyperlink && !pendingHl.tryAppend(std::make_tuple(hlCode, hlCP, hlTerm))) [[unlikely]] return std::nullopt; } p = after; @@ -1338,6 +1340,8 @@ walkDone:; } if (needEndEllipsis) result.append(ellipsis); activeStyles.emitCloseCodes(result); + if (result.hasOverflowed() || exceedsStringLimit(result.length())) [[unlikely]] + return std::nullopt; return result.toString(); } @@ -1399,9 +1403,10 @@ static std::optional sliceAnsiImpl(std::span input, dou if (doEnd) en -= ellipsisWidth; if (!doStart && !doEnd) return ellipsis.toString(); StringView content(std::span { data + st, en - st }); - if (doStart && doEnd) return makeString(ellipsis, content, ellipsis); - if (doStart) return makeString(ellipsis, content); - return makeString(content, ellipsis); + auto joined = tryMakeString(doStart ? ellipsis : StringView(), content, doEnd ? ellipsis : StringView()); + if (joined.isNull() || exceedsStringLimit(joined.length())) [[unlikely]] + return std::nullopt; + return joined; } return WTF::String(std::span { data + st, en - st }); } diff --git a/src/jsc/bindings/sqlite/JSSQLStatement.cpp b/src/jsc/bindings/sqlite/JSSQLStatement.cpp index 981a3564b0ef..837c15e18508 100644 --- a/src/jsc/bindings/sqlite/JSSQLStatement.cpp +++ b/src/jsc/bindings/sqlite/JSSQLStatement.cpp @@ -156,7 +156,9 @@ static constexpr int MAX_SQLITE_PREPARE_FLAG = SQLITE_PREPARE_PERSISTENT | SQLIT if ((connection)->inCallback) [[unlikely]] { \ throwTypeError(lexicalGlobalObject, scope, "This database connection is busy executing a query"_s); \ return {}; \ - } + } \ + if (!(connection)->orphans.isEmpty()) [[unlikely]] \ + (connection)->finalizeOrphans(lexicalGlobalObject); #define CHECK_PREPARED \ if (castedThis->stmt == nullptr) [[unlikely]] { \ @@ -186,6 +188,10 @@ class VersionSqlite3 final : public WTF::RefCounted { // The Bun.ModuleGraph context whose script opened it (0: none): closed when that graph is disposed. const WebCore::ScriptExecutionContextIdentifier graphContext; WTF::HashSet statements; + // Statements that no JSSQLStatement owns anymore but that aren't finalized yet. The GC moves a statement here when + // finalizing it would run JS (Bun::sqliteCommitsWhenFinalized). The next call on the connection finalizes it, or + // closeIfDrained() does if the connection is closed and its last statement is gone. + WTF::Vector orphans; // db.handle const uint64_t handleNumber; const size_t deserializedSize; @@ -195,6 +201,8 @@ class VersionSqlite3 final : public WTF::RefCounted { // SQLite is calling script of the user from inside a statement of this connection. To reset or finalize that // statement now, or to close the connection, frees what SQLite returns to. So every call on the connection throws. bool inCallback = false; + // True if the connection has ever had an update hook or a user function. + bool hasCallbacks = false; sqlite3* handle() const { return closed ? nullptr : db; } @@ -208,9 +216,10 @@ class VersionSqlite3 final : public WTF::RefCounted { // Defined after JSSQLStatement: they need its definition to inspect `stmt`. void closeIfDrained(); + void finalizeOrphans(JSC::JSGlobalObject*); // db.close(force). With it every statement is finalized, so the file is released now. Without it db.prepare() // statements stay usable, and sqlite3_close waits until they drain. Returns SQLite's result code. - int close(bool force); + int close(bool force, bool mayCallScript = true); // process.exit() in a callback: sqlite3_step() stays on the stack, so no destructor may finalize a statement. void abandonStatements(); @@ -246,6 +255,8 @@ VersionSqlite3::~VersionSqlite3() live[liveIndex] = last; last->liveIndex = liveIndex; } + for (auto* stmt : orphans) + Bun::sqliteFinalize(stmt, false); closeHandle(); } @@ -286,8 +297,9 @@ extern "C" void Bun__closeSQLiteDatabasesOfGraphContext(WebCore::ScriptExecution if (record->graphContext == graphContext && record->db && !record->inCallback) owned.append(*record); } + // The JSSQLDatabase and its callbacks may already be dead, so don't run JS here. for (auto& record : owned) - record->close(true); + record->close(true, false); } namespace WebCore { @@ -1760,6 +1772,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementDefineFunction, (JSC::JSGlobalObject * le } database->m_callbacks.set(vm, database, name, argumentCount, roots); + versionDB->hasCallbacks = true; return JSValue::encode(jsUndefined()); } @@ -1865,6 +1878,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementSetUpdateHookFunction, (JSC::JSGlobalObje database->m_wrapper.setMayBeNull(vm, database, hasHook ? callFrame->argument(3).getObject() : nullptr); database->m_safeIntegers = callFrame->argument(4).isInt32() && (callFrame->argument(4).asInt32() & kSafeIntegersFlag); sqlite3_update_hook(db, hasHook ? callUpdateHook : nullptr, database); + database->record().hasCallbacks |= hasHook; return JSValue::encode(jsUndefined()); } @@ -2602,6 +2616,11 @@ JSSQLStatement::~JSSQLStatement() { if (this->version_db) { this->version_db->statements.remove(this); + if (this->version_db->hasCallbacks && Bun::sqliteCommitsWhenFinalized(stmt)) { + this->version_db->orphans.append(stmt); + this->version_db->closeIfDrained(); + return; + } } finalizeStatement(); } @@ -2655,15 +2674,17 @@ JSValue createJSSQLStatementConstructor(Zig::GlobalObject* globalObject) } // namespace WebCore -int VersionSqlite3::close(bool force) +int VersionSqlite3::close(bool force, bool mayCallScript) { + // Finalizing can run JS, and a GC during that JS can remove entries from `statements`, so move the handles out first. for (auto* statement : statements) { if (!statement->stmt || !(force || statement->ownedByDatabase)) continue; - sqlite3_finalize(statement->stmt); - statement->stmt = nullptr; + orphans.append(std::exchange(statement->stmt, nullptr)); statement->finalizedByClose = true; } + while (!orphans.isEmpty()) + Bun::sqliteFinalize(orphans.takeLast(), mayCallScript || !hasCallbacks); closed = true; if (!force) { closeIfDrained(); @@ -2688,6 +2709,20 @@ void VersionSqlite3::abandonStatements() statement->stmt = nullptr; } +void VersionSqlite3::finalizeOrphans(JSC::JSGlobalObject* globalObject) +{ + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(*vm); + while (!orphans.isEmpty()) { + sqlite3_finalize(orphans.takeLast()); + // An exception from a user function is unrelated to the current call, so report it instead of throwing it. + if (auto* exception = scope.exception(); exception && !vm->hasPendingTerminationException()) [[unlikely]] { + scope.clearException(); + Zig::GlobalObject::reportUncaughtExceptionAtEventLoop(globalObject, exception); + } + } + closeIfDrained(); +} + // Drained = every bun-tracked statement finalized. Statements sqlite3 still // holds (vtab modules' cached ones) don't count; close_v2 finalizes those. void VersionSqlite3::closeIfDrained() @@ -2698,5 +2733,8 @@ void VersionSqlite3::closeIfDrained() if (statement->stmt) return; } + // The GC can call this, so don't run JS. No statement is left for the interrupt to disturb. + while (!orphans.isEmpty()) + Bun::sqliteFinalize(orphans.takeLast(), false); closeHandle(); } diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index 3f69b6e612bf..1097c764362e 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -561,7 +561,7 @@ JSDatabaseSync::~JSDatabaseSync() // was called from inside a UDF/authorizer and the heap is destructed on // exit; closing the connection mid-sqlite3_step is a use-after-free. if (!isBusy()) { - closeInternal(); + closeInternal(false); return; } // Pure bookkeeping: none of it calls into SQLite. dbGone stops @@ -575,15 +575,15 @@ JSDatabaseSync::~JSDatabaseSync() unregisterOpenDatabase(this); } -void JSDatabaseSync::closeInternal() +void JSDatabaseSync::closeInternal(bool mayCallScript) { if (!m_db) return; m_deferredClose = std::exchange(m_db, nullptr); if (!isBusy()) - finishDeferredClose(); + finishDeferredClose(mayCallScript); } -void JSDatabaseSync::finishDeferredClose() +void JSDatabaseSync::finishDeferredClose(bool mayCallScript) { ASSERT(!isBusy()); // open() refuses while m_deferredClose is set, so m_db is still null and @@ -592,7 +592,7 @@ void JSDatabaseSync::finishDeferredClose() // sqlite3_finalize() can call an aggregate's xFinal, which runs script: // the depth keeps a BusyScope it takes from coming back here. ++m_busyDepth; - finalizeStatements(); + finalizeStatements(mayCallScript); --m_busyDepth; // Sessions must go before close_v2: the preupdate hook they install // keeps a back-pointer into the connection, and close_v2 does NOT @@ -608,12 +608,30 @@ void JSDatabaseSync::trackStatement(JSStatementSync& statement) m_statements.append(&statement); } -void JSDatabaseSync::finalizeStatements() +void JSDatabaseSync::finalizeStatements(bool mayCallScript) { + mayCallScript |= !hasCallbacks(); // From the head each time: xFinal can run script, so a GC can sweep // (and unlink) any other statement in between. while (!m_statements.isEmpty()) - m_statements.begin()->finalizeStatement(); + m_statements.begin()->finalizeStatement(mayCallScript); + while (!m_orphanedStatements.isEmpty()) + sqliteFinalize(m_orphanedStatements.takeLast(), mayCallScript); +} + +void JSDatabaseSync::finalizeOrphanedStatements() +{ + auto* globalObject = this->globalObject(); + auto& vm = getVM(globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + while (!m_orphanedStatements.isEmpty()) { + sqlite3_finalize(m_orphanedStatements.takeLast()); + // An exception from a callback is unrelated to the current call, so report it instead of throwing it. + if (auto* exception = scope.exception(); exception && !vm.hasPendingTerminationException()) [[unlikely]] { + scope.clearException(); + Zig::GlobalObject::reportUncaughtExceptionAtEventLoop(globalObject, exception); + } + } } // Called from the exiting VM's teardown once script is forbidden and its child workers are @@ -641,7 +659,7 @@ extern "C" void Bun__closeAllNodeSqliteDatabasesForTermination(JSC::JSGlobalObje // closeInternal() re-takes openDatabasesLock to unregister, so the // snapshot lock above must already be dropped; it also nulls m_db, // making a later GC destructor a no-op rather than a double close. - db->closeInternal(); + db->closeInternal(false); } } @@ -659,7 +677,7 @@ extern "C" void Bun__closeNodeSqliteDatabasesOfGraphContext(WebCore::ScriptExecu // (dispose() from inside a UDF/authorizer: closeInternal() leaves the connection to the // outermost BusyScope, which unwinds here, unlike at process exit.) for (auto* db : toClose) - db->closeInternal(); + db->closeInternal(false); } void JSDatabaseSync::deleteTrackedSessions() @@ -1047,11 +1065,14 @@ JSC_DEFINE_HOST_FUNCTION(jsDatabaseSyncLoadExtension, (JSGlobalObject * globalOb entryPtr = entryUtf8.legacyCStringPointer(); } + // The extension's entry point can run SQL, which calls the authorizer. + JSDatabaseSync::BusyScope busy { self }; char* errmsg = nullptr; int r = sqlite3_load_extension(self->connection(), pathUtf8.legacyCStringPointer(), entryPtr, &errmsg); if (r != SQLITE_OK) { WTF::String message = errmsg ? sqliteText(errmsg) : WTF::String::fromUTF8(sqlite3_errstr(r)); if (errmsg) sqlite3_free(errmsg); + CHECK_UDF_EXCEPTION(scope); Bun::throwError(globalObject, scope, ErrorCode::ERR_LOAD_SQLITE_EXTENSION, message); return {}; } @@ -2021,13 +2042,13 @@ void JSStatementSync::finishCreation(VM& vm, JSDatabaseSync* db, sqlite3_stmt* s vm.heap.reportExtraMemoryAllocated(this, m_extraMemorySize); } -void JSStatementSync::finalizeStatement() +void JSStatementSync::finalizeStatement(bool mayCallScript) { if (isOnList()) remove(); m_extraMemorySize = 0; // Cleared first: xFinal can run script, and a GC in it this cell's destructor. - sqlite3_finalize(std::exchange(m_stmt, nullptr)); + sqliteFinalize(std::exchange(m_stmt, nullptr), mayCallScript); } JSStatementSync::~JSStatementSync() @@ -2041,12 +2062,25 @@ JSStatementSync::~JSStatementSync() remove(); return; } - // Do NOT dereference m_database here: GC may have already destroyed - // the JSDatabaseSync, leaving the WriteBarrier pointing at freed - // memory (this cell is then off its list). + // Do NOT dereference m_database unless this is on the list. Otherwise GC + // may have already destroyed the JSDatabaseSync, leaving the WriteBarrier + // pointing at freed memory. + if (auto* database = m_database.unvalidatedGet(); isOnList() && database->hasCallbacks() && sqliteCommitsWhenFinalized(m_stmt)) { + remove(); + database->orphanStatement(m_stmt); + return; + } finalizeStatement(); } +void JSStatementSync::reset() +{ + // Like Node, bump this first so a callback during the reset can't use an iterator from the run that's ending. + ++m_resetGeneration; + SteppingScope stepping { this }; + sqlite3_reset(m_stmt); +} + sqlite3* JSStatementSync::connection() const { auto* db = m_database.get(); @@ -2408,8 +2442,8 @@ JSC_DEFINE_HOST_FUNCTION(jsStatementSyncRun, (JSGlobalObject * globalObject, Cal REQUIRE_STMT(self); REQUIRE_STMT_IDLE(self); BUSY_SCOPE_STMT(self); - sqlite3_reset(self->statement()); - self->bumpResetGeneration(); + self->reset(); + CHECK_UDF_EXCEPTION(scope); if (!self->bindParams(globalObject, scope, callFrame)) return {}; RELEASE_AND_RETURN(scope, statementStepRun(vm, globalObject, scope, self)); } @@ -2420,8 +2454,8 @@ JSC_DEFINE_HOST_FUNCTION(jsStatementSyncGet, (JSGlobalObject * globalObject, Cal REQUIRE_STMT(self); REQUIRE_STMT_IDLE(self); BUSY_SCOPE_STMT(self); - sqlite3_reset(self->statement()); - self->bumpResetGeneration(); + self->reset(); + CHECK_UDF_EXCEPTION(scope); if (!self->bindParams(globalObject, scope, callFrame)) return {}; RELEASE_AND_RETURN(scope, statementStepGet(globalObject, scope, self)); } @@ -2432,8 +2466,8 @@ JSC_DEFINE_HOST_FUNCTION(jsStatementSyncAll, (JSGlobalObject * globalObject, Cal REQUIRE_STMT(self); REQUIRE_STMT_IDLE(self); BUSY_SCOPE_STMT(self); - sqlite3_reset(self->statement()); - self->bumpResetGeneration(); + self->reset(); + CHECK_UDF_EXCEPTION(scope); if (!self->bindParams(globalObject, scope, callFrame)) return {}; RELEASE_AND_RETURN(scope, statementStepAll(globalObject, scope, self)); } @@ -2444,8 +2478,8 @@ JSC_DEFINE_HOST_FUNCTION(jsStatementSyncIterate, (JSGlobalObject * globalObject, REQUIRE_STMT(self); REQUIRE_STMT_IDLE(self); BUSY_SCOPE_STMT(self); - sqlite3_reset(self->statement()); - self->bumpResetGeneration(); + self->reset(); + CHECK_UDF_EXCEPTION(scope); if (!self->bindParams(globalObject, scope, callFrame)) return {}; // Don't step yet — the iterator pulls rows lazily on next(). Don't // reset on scope exit either; the cursor position belongs to the @@ -2706,11 +2740,14 @@ JSC_DEFINE_HOST_FUNCTION(jsStatementSyncIteratorReturn, (JSGlobalObject * global // (Deliberate divergence: Node v26.3.0 resets unconditionally here.) // isStepping() — this iterator's own sqlite3_step is on the C stack (a // UDF re-entered return()); sqlite3_reset on a running VDBE is misuse. - if (!self->done() && stmt && !stmt->isFinalized() && !stmt->isStepping() - && self->capturedGeneration() == stmt->resetGeneration()) { - sqlite3_reset(stmt->statement()); - } + bool ownsRun = !self->done() && stmt && !stmt->isFinalized() && !stmt->isStepping() + && self->capturedGeneration() == stmt->resetGeneration(); self->setDone(); + if (ownsRun) { + BUSY_SCOPE_STMT(stmt); + stmt->reset(); + CHECK_UDF_EXCEPTION(scope); + } return JSValue::encode(createIterResult(vm, globalObject, true, jsNull())); } @@ -2761,12 +2798,11 @@ bool JSNodeSqliteSession::isStale() const void JSNodeSqliteSession::deleteSession() { if (!m_record || m_record->handle == nullptr) return; - // A changeset()/patchset() is on the C stack for this handle; - // sqlite3session_delete would free it under sessionGenerateChangeset(). - // close() throws ERR_INVALID_STATE for this; Symbol.dispose no-ops. - if (m_record->inUse) return; + // Both callers throw if SQLite may still be using this handle. + ASSERT(!m_record->inUse); if (!m_record->dbGone) { auto* db = m_database.get(); + ASSERT(!db->isBusy()); db->untrackSession(m_record.get()); sqlite3session_delete(m_record->handle); } @@ -2873,7 +2909,10 @@ JSC_DEFINE_HOST_FUNCTION(jsSessionClose, (JSGlobalObject * globalObject, CallFra return throwNodeState(globalObject, scope, "session is not open"_s); } if (self->record()->inUse) { - return throwNodeState(globalObject, scope, "session is currently generating a changeset"_s); + return throwNodeState(globalObject, scope, "session is currently in use"_s); + } + if (self->database()->isBusy()) { + return throwNodeState(globalObject, scope, "session cannot be closed while in a callback"_s); } self->deleteSession(); return JSValue::encode(jsUndefined()); @@ -2881,9 +2920,19 @@ JSC_DEFINE_HOST_FUNCTION(jsSessionClose, (JSGlobalObject * globalObject, CallFra JSC_DEFINE_HOST_FUNCTION(jsSessionDispose, (JSGlobalObject * globalObject, CallFrame* callFrame)) { + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); auto* self = dynamicDowncast(callFrame->thisValue()); - (void)globalObject; - if (self) self->deleteSession(); + if (!self || self->isStale() || self->session() == nullptr) { + return JSValue::encode(jsUndefined()); + } + if (self->record()->inUse) { + return throwNodeState(globalObject, scope, "session is currently in use"_s); + } + if (self->database()->isBusy()) { + return throwNodeState(globalObject, scope, "session cannot be closed while in a callback"_s); + } + self->deleteSession(); return JSValue::encode(jsUndefined()); } @@ -3246,15 +3295,16 @@ JSStatementSync* JSNodeSqliteTagStore::prepare(JSGlobalObject* globalObject, Thr // here would spuriously re-throw a cached statement's stale error. // StatementSync's run/get/all correctly ignore it for the same // reason. - sqlite3_stmt* stmt = stmtObj->statement(); - sqlite3_reset(stmt); + // // Deliberate divergence from Node v26.3.0: Node's SQLTagStore calls raw // sqlite3_reset and never bumps the statement's reset_generation_, so an // iterator from tag.iterate`…` silently re-yields from row 1 after any - // other tag call on the same SQL hits the LRU cache and resets it. Bump - // here so that iterator throws ERR_INVALID_STATE instead of returning - // wrong rows. - stmtObj->bumpResetGeneration(); + // other tag call on the same SQL hits the LRU cache and resets it. + // reset() bumps it, so that iterator throws ERR_INVALID_STATE instead of + // returning wrong rows. + sqlite3_stmt* stmt = stmtObj->statement(); + stmtObj->reset(); + RETURN_IF_EXCEPTION(scope, nullptr); sqlite3_clear_bindings(stmt); int paramCount = sqlite3_bind_parameter_count(stmt); for (int i = 0; i < static_cast(nParams) && i < paramCount; ++i) { diff --git a/src/jsc/bindings/sqlite/NodeSqlite.h b/src/jsc/bindings/sqlite/NodeSqlite.h index 6cc15c736db5..d6876b78b687 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.h +++ b/src/jsc/bindings/sqlite/NodeSqlite.h @@ -64,7 +64,7 @@ static constexpr size_t kNodeSqliteLimitCount = 11; // deletes the orphaned handle on its next entry point // inUse — a sqlite3session_changeset/patchset is on the C stack for // this handle (it re-enters the authorizer via its internal -// SAVEPOINT/SELECT). session.close()/Symbol.dispose refuse +// SAVEPOINT/SELECT). session.close()/Symbol.dispose throw // while set. The BusyScope taken before inUse is set means // deleteTrackedSessions()/sweepOrphanedSessions() are never // reached while any record is inUse; the sweep loops ASSERT @@ -131,7 +131,9 @@ class JSDatabaseSync final : public JSC::JSDestructibleObject { bool open(JSC::JSGlobalObject*, JSC::ThrowScope&); // The database reads as closed from here on. finishDeferredClose() takes // the connection down: at once, or when the outermost BusyScope unwinds. - void closeInternal(); + // mayCallScript is false when this is called from the GC, at process exit + // or for a disposed graph. This cell can already be dead in those cases. + void closeInternal(bool mayCallScript = true); sqlite3* connection() const { return m_db; } bool isOpen() const { return m_db != nullptr; } @@ -139,7 +141,12 @@ class JSDatabaseSync final : public JSC::JSDestructibleObject { // close() and deserialize() finalize the StatementSyncs of this connection, as Node does. Not // sqlite3_next_stmt(), which also returns the private statements of virtual table modules (FTS5). void trackStatement(JSStatementSync&); - void finalizeStatements(); + void finalizeStatements(bool mayCallScript = true); + // ~JSStatementSync() can't finalize a statement whose commit would run JS, so it queues it here. + // The next outermost BusyScope finalizes it, or whatever closes the connection does. + void orphanStatement(sqlite3_stmt* stmt) { m_orphanedStatements.append(stmt); } + void finalizeOrphanedStatements(); + bool hasCallbacks() const { return m_authorizer || !m_callbacks.isEmpty(); } bool allowLoadExtension() const { return m_config.allowExtension; } bool enableLoadExtensionIsOn() const { return m_enableLoadExtension; } void setEnableLoadExtension(bool v) { m_enableLoadExtension = v; } @@ -191,7 +198,8 @@ class JSDatabaseSync final : public JSC::JSDestructibleObject { // the depth bump so re-entrant calls from UDF/authorizer // callbacks are skipped by the m_busyDepth guard. db->sweepOrphanedSessions(); - ++db->m_busyDepth; + if (++db->m_busyDepth == 1 && !db->m_orphanedStatements.isEmpty()) [[unlikely]] + db->finalizeOrphanedStatements(); } } ~BusyScope() @@ -205,7 +213,7 @@ class JSDatabaseSync final : public JSC::JSDestructibleObject { BusyScope(BusyScope&&) = delete; BusyScope& operator=(BusyScope&&) = delete; }; - void finishDeferredClose(); + void finishDeferredClose(bool mayCallScript = true); private: JSDatabaseSync(JSC::VM& vm, JSC::Structure* structure) @@ -224,6 +232,7 @@ class JSDatabaseSync final : public JSC::JSDestructibleObject { // Not roots. A cell on the list has not run its destructor: a statement's // unlinks it, and the database's unlinks whatever is left. WTF::SentinelLinkedList> m_statements; + WTF::Vector m_orphanedStatements; // Sessions must be deleted before sqlite3_close_v2() to avoid // use-after-free inside the preupdate hook; track them through shared // records (not JS objects) so close() can sweep regardless of GC @@ -326,7 +335,7 @@ class JSStatementSync final : public JSC::JSDestructibleObject, public WTF::Basi // close() / deserialize() finalized it, or a close() is waiting for the // outermost BusyScope to do so. bool isFinalized() const; - void finalizeStatement(); + void finalizeStatement(bool mayCallScript = true); bool useBigInts() const { return m_useBigInts; } bool returnArrays() const { return m_returnArrays; } @@ -339,11 +348,13 @@ class JSStatementSync final : public JSC::JSDestructibleObject, public WTF::Basi // so a live StatementSyncIterator can detect that its cursor position // has been invalidated by another call on the same statement. unsigned resetGeneration() const { return m_resetGeneration; } - void bumpResetGeneration() { ++m_resetGeneration; } + // Resets the statement under a SteppingScope. If a parked iterator stopped a + // write on a row, the reset commits it, and the commit can run JS. + void reset(); - // True while a sqlite3_step on this statement is on the C stack. A UDF - // that re-enters run()/get()/all()/iterate() on the same statement would - // sqlite3_reset a running VDBE and segfault; REQUIRE_STMT_IDLE checks + // True while a sqlite3_step or sqlite3_reset on this statement is on the C + // stack. A UDF that re-enters run()/get()/all()/iterate() on the same statement + // would sqlite3_reset a running VDBE and segfault; REQUIRE_STMT_IDLE checks // this. Not sqlite3_stmt_busy(): that also reports a parked iterator // (stepped, yielded SQLITE_ROW, returned to JS), which is safe to reset. bool isStepping() const { return m_steppingDepth > 0; } diff --git a/src/jsc/bindings/sqlite/SqliteCallbackRoots.h b/src/jsc/bindings/sqlite/SqliteCallbackRoots.h index b27b9bc2521c..b3ff256e25ac 100644 --- a/src/jsc/bindings/sqlite/SqliteCallbackRoots.h +++ b/src/jsc/bindings/sqlite/SqliteCallbackRoots.h @@ -29,6 +29,9 @@ class SqliteCallbackRoots { m_functions[index].values[i].set(vm, owner, values[i]); } + // Only call this on the thread that calls set() and clear(). + bool isEmpty() const { return m_functions.isEmpty(); } + // After SQLite closed the connection. void clear(JSC::JSCell* owner) { diff --git a/src/jsc/bindings/sqlite/SqliteUserFunction.h b/src/jsc/bindings/sqlite/SqliteUserFunction.h index 5728c1deac9f..152bdf427106 100644 --- a/src/jsc/bindings/sqlite/SqliteUserFunction.h +++ b/src/jsc/bindings/sqlite/SqliteUserFunction.h @@ -85,6 +85,22 @@ inline bool SqliteColumnNames::changed(sqlite3_stmt* stmt) return true; } +// True if resetting or finalizing `stmt` commits a write that iterate() stopped on a row. At that commit, a full-text +// index writes to its own tables with statements that call the connection's update hook, user functions and authorizer. +inline bool sqliteCommitsWhenFinalized(sqlite3_stmt* stmt) +{ + return sqlite3_stmt_busy(stmt) && !sqlite3_stmt_readonly(stmt); +} + +// JS can't run during GC or at process exit. SQLite won't start a statement on an interrupted connection while another +// one is open, so interrupting makes a commit that needs to run a statement fail, and the write is rolled back. +inline void sqliteFinalize(sqlite3_stmt* stmt, bool mayCallScript) +{ + if (!mayCallScript && sqliteCommitsWhenFinalized(stmt)) + sqlite3_interrupt(sqlite3_db_handle(stmt)); + sqlite3_finalize(stmt); +} + // Where an SQL value comes from: a column of the current row, or an argument of a user function. struct SqliteColumn { sqlite3_stmt* statement; diff --git a/src/jsc/bindings/sqlite/lazy_sqlite3.h b/src/jsc/bindings/sqlite/lazy_sqlite3.h index f9c964ac80f4..5986efda142d 100644 --- a/src/jsc/bindings/sqlite/lazy_sqlite3.h +++ b/src/jsc/bindings/sqlite/lazy_sqlite3.h @@ -82,6 +82,7 @@ typedef unsigned char* (*lazy_sqlite3_serialize_type)(sqlite3* db, const char* z typedef int (*lazy_sqlite3_deserialize_type)(sqlite3* db, const char* zSchema, unsigned char* pData, sqlite3_int64 szDb, sqlite3_int64 szBuf, unsigned mFlags); typedef int (*lazy_sqlite3_stmt_readonly_type)(sqlite3_stmt* pStmt); typedef int (*lazy_sqlite3_stmt_busy_type)(sqlite3_stmt* pStmt); +typedef void (*lazy_sqlite3_interrupt_type)(sqlite3*); typedef int (*lazy_sqlite3_compileoption_used_type)(const char* zOptName); typedef int64_t (*lazy_sqlite3_last_insert_rowid_type)(sqlite3* db); typedef int (*lazy_sqlite3_set_authorizer_type)(sqlite3*, int (*xAuth)(void*, int, const char*, const char*, const char*, const char*), void* pUserData); @@ -176,6 +177,7 @@ inline lazy_sqlite3_serialize_type lazy_sqlite3_serialize; inline lazy_sqlite3_deserialize_type lazy_sqlite3_deserialize; inline lazy_sqlite3_stmt_readonly_type lazy_sqlite3_stmt_readonly; inline lazy_sqlite3_stmt_busy_type lazy_sqlite3_stmt_busy; +inline lazy_sqlite3_interrupt_type lazy_sqlite3_interrupt; inline lazy_sqlite3_compileoption_used_type lazy_sqlite3_compileoption_used; inline lazy_sqlite3_config_type lazy_sqlite3_config; inline lazy_sqlite3_extended_result_codes_type lazy_sqlite3_extended_result_codes; @@ -271,6 +273,7 @@ inline lazy_sqlite3changeset_apply_type lazy_sqlite3changeset_apply; #define sqlite3_deserialize lazy_sqlite3_deserialize #define sqlite3_stmt_readonly lazy_sqlite3_stmt_readonly #define sqlite3_stmt_busy lazy_sqlite3_stmt_busy +#define sqlite3_interrupt lazy_sqlite3_interrupt #define sqlite3_column_int64 lazy_sqlite3_column_int64 #define sqlite3_compileoption_used lazy_sqlite3_compileoption_used #define sqlite3_config lazy_sqlite3_config @@ -424,6 +427,7 @@ inline int lazyLoadSQLiteUnlocked(WTF::String* errorMessage = nullptr) lazy_sqlite3_malloc64 = (lazy_sqlite3_malloc64_type)dlsym(sqlite3_handle, "sqlite3_malloc64"); lazy_sqlite3_stmt_readonly = (lazy_sqlite3_stmt_readonly_type)dlsym(sqlite3_handle, "sqlite3_stmt_readonly"); lazy_sqlite3_stmt_busy = (lazy_sqlite3_stmt_busy_type)dlsym(sqlite3_handle, "sqlite3_stmt_busy"); + lazy_sqlite3_interrupt = (lazy_sqlite3_interrupt_type)dlsym(sqlite3_handle, "sqlite3_interrupt"); lazy_sqlite3_compileoption_used = (lazy_sqlite3_compileoption_used_type)dlsym(sqlite3_handle, "sqlite3_compileoption_used"); lazy_sqlite3_config = (lazy_sqlite3_config_type)dlsym(sqlite3_handle, "sqlite3_config"); lazy_sqlite3_extended_result_codes = (lazy_sqlite3_extended_result_codes_type)dlsym(sqlite3_handle, "sqlite3_extended_result_codes"); diff --git a/src/jsc/bindings/uv-posix-polyfills.c b/src/jsc/bindings/uv-posix-polyfills.c index 19ae7e5040c8..a7789cce367f 100644 --- a/src/jsc/bindings/uv-posix-polyfills.c +++ b/src/jsc/bindings/uv-posix-polyfills.c @@ -5,6 +5,8 @@ #include #include #include +#include +#include // libuv does the annoying thing of #undef'ing these #include @@ -14,6 +16,12 @@ #define UV__ERR(x) (x) #endif +#if defined(PATH_MAX) +#define UV__PATH_MAX PATH_MAX +#else +#define UV__PATH_MAX 8192 +#endif + void __bun_throw_not_implemented(const char* symbol_name) { CrashHandler__unsupportedUVFunction(symbol_name); @@ -33,6 +41,46 @@ uint64_t uv__hrtime(uv_clocktype_t type); #include "uv-posix-polyfills-posix.c" #endif +// Copy-pasted from libuv +UV_EXTERN int uv_cwd(char* buffer, size_t* size) +{ + char scratch[1 + UV__PATH_MAX]; + + if (buffer == NULL || size == NULL || *size == 0) + return UV_EINVAL; + + /* Try to read directly into the user's buffer first... */ + if (getcwd(buffer, *size) != NULL) + goto fixup; + + if (errno != ERANGE) + return UV__ERR(errno); + + /* ...or into scratch space if the user's buffer is too small + * so we can report how much space to provide on the next try. + */ + if (getcwd(scratch, sizeof(scratch)) == NULL) + return UV__ERR(errno); + + buffer = scratch; + +fixup: + + *size = strlen(buffer); + + if (*size > 1 && buffer[*size - 1] == '/') { + *size -= 1; + buffer[*size] = '\0'; + } + + if (buffer == scratch) { + *size += 1; + return UV_ENOBUFS; + } + + return 0; +} + uv_pid_t uv_os_getpid() { return getpid(); @@ -130,4 +178,145 @@ UV_EXTERN void uv_mutex_unlock(uv_mutex_t* mutex) abort(); } +// These functions only need the headers, so they're copied from libuv +// (version.c, uv-common.c, uv-data-getter-setters.c, unix/core.c). The +// functions that need a loop are in src/runtime/napi/uv_posix.rs. + +#define UV_STRINGIFY(v) UV_STRINGIFY_HELPER(v) +#define UV_STRINGIFY_HELPER(v) #v + +#define UV_VERSION_STRING_BASE UV_STRINGIFY(UV_VERSION_MAJOR) "." UV_STRINGIFY(UV_VERSION_MINOR) "." UV_STRINGIFY(UV_VERSION_PATCH) + +#if UV_VERSION_IS_RELEASE +#define UV_VERSION_STRING UV_VERSION_STRING_BASE +#else +#define UV_VERSION_STRING UV_VERSION_STRING_BASE "-" UV_VERSION_SUFFIX +#endif + +UV_EXTERN unsigned int uv_version(void) +{ + return UV_VERSION_HEX; +} + +UV_EXTERN const char* uv_version_string(void) +{ + return UV_VERSION_STRING; +} + +UV_EXTERN size_t uv_handle_size(uv_handle_type type) +{ + switch (type) { +#define XX(uc, lc) \ + case UV_##uc: \ + return sizeof(uv_##lc##_t); + UV_HANDLE_TYPE_MAP(XX) +#undef XX + default: + return (size_t)-1; + } +} + +UV_EXTERN size_t uv_req_size(uv_req_type type) +{ + switch (type) { +#define XX(uc, lc) \ + case UV_##uc: \ + return sizeof(uv_##lc##_t); + UV_REQ_TYPE_MAP(XX) +#undef XX + default: + return (size_t)-1; + } +} + +UV_EXTERN const char* uv_handle_type_name(uv_handle_type type) +{ + switch (type) { +#define XX(uc, lc) \ + case UV_##uc: \ + return #lc; + UV_HANDLE_TYPE_MAP(XX) +#undef XX + case UV_FILE: + return "file"; + case UV_HANDLE_TYPE_MAX: + case UV_UNKNOWN_HANDLE: + return NULL; + } + return NULL; +} + +UV_EXTERN const char* uv_req_type_name(uv_req_type type) +{ + switch (type) { +#define XX(uc, lc) \ + case UV_##uc: \ + return #lc; + UV_REQ_TYPE_MAP(XX) +#undef XX + case UV_REQ_TYPE_MAX: + case UV_UNKNOWN_REQ: + default: /* UV_REQ_TYPE_PRIVATE */ + break; + } + return NULL; +} + +UV_EXTERN uv_handle_type uv_handle_get_type(const uv_handle_t* handle) +{ + return handle->type; +} + +UV_EXTERN void* uv_handle_get_data(const uv_handle_t* handle) +{ + return handle->data; +} + +UV_EXTERN uv_loop_t* uv_handle_get_loop(const uv_handle_t* handle) +{ + return handle->loop; +} + +UV_EXTERN void uv_handle_set_data(uv_handle_t* handle, void* data) +{ + handle->data = data; +} + +UV_EXTERN uv_req_type uv_req_get_type(const uv_req_t* req) +{ + return req->type; +} + +UV_EXTERN void* uv_req_get_data(const uv_req_t* req) +{ + return req->data; +} + +UV_EXTERN void uv_req_set_data(uv_req_t* req, void* data) +{ + req->data = data; +} + +// A uv_loop_t* points to a UvLoop (uv_posix.rs), whose first field is also `data`. +UV_EXTERN void* uv_loop_get_data(const uv_loop_t* loop) +{ + return loop->data; +} + +UV_EXTERN void uv_loop_set_data(uv_loop_t* loop, void* data) +{ + loop->data = data; +} + +// On Unix a uv_os_fd_t is an int, so both conversions are no-ops. +UV_EXTERN uv_os_fd_t uv_get_osfhandle(int fd) +{ + return fd; +} + +UV_EXTERN int uv_open_osfhandle(uv_os_fd_t os_fd) +{ + return os_fd; +} + #endif diff --git a/src/jsc/bindings/uv-posix-stubs.c b/src/jsc/bindings/uv-posix-stubs.c index 04868a91cf9e..e0254a2735ce 100644 --- a/src/jsc/bindings/uv-posix-stubs.c +++ b/src/jsc/bindings/uv-posix-stubs.c @@ -8,20 +8,6 @@ UV_EXTERN int uv_accept(uv_stream_t* server, uv_stream_t* client) __builtin_unreachable(); } -UV_EXTERN int uv_async_init(uv_loop_t*, - uv_async_t* async, - uv_async_cb async_cb) -{ - __bun_throw_not_implemented("uv_async_init"); - __builtin_unreachable(); -} - -UV_EXTERN int uv_async_send(uv_async_t* async) -{ - __bun_throw_not_implemented("uv_async_send"); - __builtin_unreachable(); -} - UV_EXTERN unsigned int uv_available_parallelism(void) { __bun_throw_not_implemented("uv_available_parallelism"); @@ -64,48 +50,18 @@ UV_EXTERN uv_buf_t uv_buf_init(char* base, unsigned int len) __builtin_unreachable(); } -UV_EXTERN int uv_cancel(uv_req_t* req) -{ - __bun_throw_not_implemented("uv_cancel"); - __builtin_unreachable(); -} - UV_EXTERN int uv_chdir(const char* dir) { __bun_throw_not_implemented("uv_chdir"); __builtin_unreachable(); } -UV_EXTERN int uv_check_init(uv_loop_t*, uv_check_t* check) -{ - __bun_throw_not_implemented("uv_check_init"); - __builtin_unreachable(); -} - -UV_EXTERN int uv_check_start(uv_check_t* check, uv_check_cb cb) -{ - __bun_throw_not_implemented("uv_check_start"); - __builtin_unreachable(); -} - -UV_EXTERN int uv_check_stop(uv_check_t* check) -{ - __bun_throw_not_implemented("uv_check_stop"); - __builtin_unreachable(); -} - UV_EXTERN int uv_clock_gettime(uv_clock_id clock_id, uv_timespec64_t* ts) { __bun_throw_not_implemented("uv_clock_gettime"); __builtin_unreachable(); } -UV_EXTERN void uv_close(uv_handle_t* handle, uv_close_cb close_cb) -{ - __bun_throw_not_implemented("uv_close"); - __builtin_unreachable(); -} - UV_EXTERN void uv_cond_broadcast(uv_cond_t* cond) { __bun_throw_not_implemented("uv_cond_broadcast"); @@ -156,18 +112,6 @@ UV_EXTERN int uv_cpumask_size(void) __builtin_unreachable(); } -UV_EXTERN int uv_cwd(char* buffer, size_t* size) -{ - __bun_throw_not_implemented("uv_cwd"); - __builtin_unreachable(); -} - -UV_EXTERN uv_loop_t* uv_default_loop(void) -{ - __bun_throw_not_implemented("uv_default_loop"); - __builtin_unreachable(); -} - UV_EXTERN void uv_disable_stdio_inheritance(void) { __bun_throw_not_implemented("uv_disable_stdio_inheritance"); @@ -725,12 +669,6 @@ UV_EXTERN uint64_t uv_get_free_memory(void) __builtin_unreachable(); } -UV_EXTERN uv_os_fd_t uv_get_osfhandle(int fd) -{ - __bun_throw_not_implemented("uv_get_osfhandle"); - __builtin_unreachable(); -} - UV_EXTERN int uv_get_process_title(char* buffer, size_t size) { __bun_throw_not_implemented("uv_get_process_title"); @@ -788,66 +726,6 @@ UV_EXTERN uv_handle_type uv_guess_handle(uv_file file) __builtin_unreachable(); } -UV_EXTERN void* uv_handle_get_data(const uv_handle_t* handle) -{ - __bun_throw_not_implemented("uv_handle_get_data"); - __builtin_unreachable(); -} - -UV_EXTERN uv_loop_t* uv_handle_get_loop(const uv_handle_t* handle) -{ - __bun_throw_not_implemented("uv_handle_get_loop"); - __builtin_unreachable(); -} - -UV_EXTERN uv_handle_type uv_handle_get_type(const uv_handle_t* handle) -{ - __bun_throw_not_implemented("uv_handle_get_type"); - __builtin_unreachable(); -} - -UV_EXTERN void uv_handle_set_data(uv_handle_t* handle, void* data) -{ - __bun_throw_not_implemented("uv_handle_set_data"); - __builtin_unreachable(); -} - -UV_EXTERN size_t uv_handle_size(uv_handle_type type) -{ - __bun_throw_not_implemented("uv_handle_size"); - __builtin_unreachable(); -} - -UV_EXTERN const char* uv_handle_type_name(uv_handle_type type) -{ - __bun_throw_not_implemented("uv_handle_type_name"); - __builtin_unreachable(); -} - -UV_EXTERN int uv_has_ref(const uv_handle_t*) -{ - __bun_throw_not_implemented("uv_has_ref"); - __builtin_unreachable(); -} - -UV_EXTERN int uv_idle_init(uv_loop_t*, uv_idle_t* idle) -{ - __bun_throw_not_implemented("uv_idle_init"); - __builtin_unreachable(); -} - -UV_EXTERN int uv_idle_start(uv_idle_t* idle, uv_idle_cb cb) -{ - __bun_throw_not_implemented("uv_idle_start"); - __builtin_unreachable(); -} - -UV_EXTERN int uv_idle_stop(uv_idle_t* idle) -{ - __bun_throw_not_implemented("uv_idle_stop"); - __builtin_unreachable(); -} - UV_EXTERN int uv_if_indextoiid(unsigned int ifindex, char* buffer, size_t* size) @@ -913,18 +791,6 @@ UV_EXTERN int uv_ip_name(const struct sockaddr* src, char* dst, size_t size) __builtin_unreachable(); } -UV_EXTERN int uv_is_active(const uv_handle_t* handle) -{ - __bun_throw_not_implemented("uv_is_active"); - __builtin_unreachable(); -} - -UV_EXTERN int uv_is_closing(const uv_handle_t* handle) -{ - __bun_throw_not_implemented("uv_is_closing"); - __builtin_unreachable(); -} - UV_EXTERN int uv_is_readable(const uv_stream_t* handle) { __bun_throw_not_implemented("uv_is_readable"); @@ -1015,12 +881,6 @@ UV_EXTERN int uv_loop_fork(uv_loop_t* loop) __builtin_unreachable(); } -UV_EXTERN void* uv_loop_get_data(const uv_loop_t*) -{ - __bun_throw_not_implemented("uv_loop_get_data"); - __builtin_unreachable(); -} - UV_EXTERN int uv_loop_init(uv_loop_t* loop) { __bun_throw_not_implemented("uv_loop_init"); @@ -1033,12 +893,6 @@ UV_EXTERN uv_loop_t* uv_loop_new(void) __builtin_unreachable(); } -UV_EXTERN void uv_loop_set_data(uv_loop_t*, void* data) -{ - __bun_throw_not_implemented("uv_loop_set_data"); - __builtin_unreachable(); -} - UV_EXTERN size_t uv_loop_size(void) { __bun_throw_not_implemented("uv_loop_size"); @@ -1057,18 +911,6 @@ UV_EXTERN int uv_metrics_info(uv_loop_t* loop, uv_metrics_t* metrics) __builtin_unreachable(); } -UV_EXTERN uint64_t uv_now(const uv_loop_t*) -{ - __bun_throw_not_implemented("uv_now"); - __builtin_unreachable(); -} - -UV_EXTERN int uv_open_osfhandle(uv_os_fd_t os_fd) -{ - __bun_throw_not_implemented("uv_open_osfhandle"); - __builtin_unreachable(); -} - UV_EXTERN int uv_os_environ(uv_env_item_t** envitems, int* count) { __bun_throw_not_implemented("uv_os_environ"); @@ -1284,24 +1126,6 @@ UV_EXTERN int uv_poll_stop(uv_poll_t* handle) __builtin_unreachable(); } -UV_EXTERN int uv_prepare_init(uv_loop_t*, uv_prepare_t* prepare) -{ - __bun_throw_not_implemented("uv_prepare_init"); - __builtin_unreachable(); -} - -UV_EXTERN int uv_prepare_start(uv_prepare_t* prepare, uv_prepare_cb cb) -{ - __bun_throw_not_implemented("uv_prepare_start"); - __builtin_unreachable(); -} - -UV_EXTERN int uv_prepare_stop(uv_prepare_t* prepare) -{ - __bun_throw_not_implemented("uv_prepare_stop"); - __builtin_unreachable(); -} - UV_EXTERN void uv_print_active_handles(uv_loop_t* loop, FILE* stream) { __bun_throw_not_implemented("uv_print_active_handles"); @@ -1326,15 +1150,6 @@ UV_EXTERN int uv_process_kill(uv_process_t*, int signum) __builtin_unreachable(); } -UV_EXTERN int uv_queue_work(uv_loop_t* loop, - uv_work_t* req, - uv_work_cb work_cb, - uv_after_work_cb after_work_cb) -{ - __bun_throw_not_implemented("uv_queue_work"); - __builtin_unreachable(); -} - UV_EXTERN int uv_random(uv_loop_t* loop, uv_random_t* req, void* buf, @@ -1366,12 +1181,6 @@ UV_EXTERN int uv_recv_buffer_size(uv_handle_t* handle, int* value) __builtin_unreachable(); } -UV_EXTERN void uv_ref(uv_handle_t*) -{ - __bun_throw_not_implemented("uv_ref"); - __builtin_unreachable(); -} - UV_EXTERN int uv_replace_allocator(uv_malloc_func malloc_func, uv_realloc_func realloc_func, uv_calloc_func calloc_func, @@ -1381,36 +1190,6 @@ UV_EXTERN int uv_replace_allocator(uv_malloc_func malloc_func, __builtin_unreachable(); } -UV_EXTERN void* uv_req_get_data(const uv_req_t* req) -{ - __bun_throw_not_implemented("uv_req_get_data"); - __builtin_unreachable(); -} - -UV_EXTERN uv_req_type uv_req_get_type(const uv_req_t* req) -{ - __bun_throw_not_implemented("uv_req_get_type"); - __builtin_unreachable(); -} - -UV_EXTERN void uv_req_set_data(uv_req_t* req, void* data) -{ - __bun_throw_not_implemented("uv_req_set_data"); - __builtin_unreachable(); -} - -UV_EXTERN size_t uv_req_size(uv_req_type type) -{ - __bun_throw_not_implemented("uv_req_size"); - __builtin_unreachable(); -} - -UV_EXTERN const char* uv_req_type_name(uv_req_type type) -{ - __bun_throw_not_implemented("uv_req_type_name"); - __builtin_unreachable(); -} - UV_EXTERN int uv_resident_set_memory(size_t* rss) { __bun_throw_not_implemented("uv_resident_set_memory"); @@ -1772,51 +1551,6 @@ UV_EXTERN int uv_thread_setpriority(uv_thread_t tid, int priority) __builtin_unreachable(); } -UV_EXTERN int uv_timer_again(uv_timer_t* handle) -{ - __bun_throw_not_implemented("uv_timer_again"); - __builtin_unreachable(); -} - -UV_EXTERN uint64_t uv_timer_get_due_in(const uv_timer_t* handle) -{ - __bun_throw_not_implemented("uv_timer_get_due_in"); - __builtin_unreachable(); -} - -UV_EXTERN uint64_t uv_timer_get_repeat(const uv_timer_t* handle) -{ - __bun_throw_not_implemented("uv_timer_get_repeat"); - __builtin_unreachable(); -} - -UV_EXTERN int uv_timer_init(uv_loop_t*, uv_timer_t* handle) -{ - __bun_throw_not_implemented("uv_timer_init"); - __builtin_unreachable(); -} - -UV_EXTERN void uv_timer_set_repeat(uv_timer_t* handle, uint64_t repeat) -{ - __bun_throw_not_implemented("uv_timer_set_repeat"); - __builtin_unreachable(); -} - -UV_EXTERN int uv_timer_start(uv_timer_t* handle, - uv_timer_cb cb, - uint64_t timeout, - uint64_t repeat) -{ - __bun_throw_not_implemented("uv_timer_start"); - __builtin_unreachable(); -} - -UV_EXTERN int uv_timer_stop(uv_timer_t* handle) -{ - __bun_throw_not_implemented("uv_timer_stop"); - __builtin_unreachable(); -} - UV_EXTERN int uv_translate_sys_error(int sys_errno) { __bun_throw_not_implemented("uv_translate_sys_error"); @@ -2031,18 +1765,6 @@ UV_EXTERN int uv_udp_using_recvmmsg(const uv_udp_t* handle) __builtin_unreachable(); } -UV_EXTERN void uv_unref(uv_handle_t*) -{ - __bun_throw_not_implemented("uv_unref"); - __builtin_unreachable(); -} - -UV_EXTERN void uv_update_time(uv_loop_t*) -{ - __bun_throw_not_implemented("uv_update_time"); - __builtin_unreachable(); -} - UV_EXTERN int uv_uptime(double* uptime) { __bun_throw_not_implemented("uv_uptime"); @@ -2065,18 +1787,6 @@ UV_EXTERN int uv_utf16_to_wtf8(const uint16_t* utf16, __builtin_unreachable(); } -UV_EXTERN unsigned int uv_version(void) -{ - __bun_throw_not_implemented("uv_version"); - __builtin_unreachable(); -} - -UV_EXTERN const char* uv_version_string(void) -{ - __bun_throw_not_implemented("uv_version_string"); - __builtin_unreachable(); -} - UV_EXTERN void uv_walk(uv_loop_t* loop, uv_walk_cb walk_cb, void* arg) { __bun_throw_not_implemented("uv_walk"); diff --git a/src/jsc/bindings/v8/AGENTS.md b/src/jsc/bindings/v8/AGENTS.md index e41b8866cf52..aa45aa5508e6 100644 --- a/src/jsc/bindings/v8/AGENTS.md +++ b/src/jsc/bindings/v8/AGENTS.md @@ -27,7 +27,7 @@ src/jsc/bindings/v8/ ├── V8*.cpp # V8 class implementations ├── shim/ # Internal implementation details │ ├── Handle.h # Handle and ObjectLayout implementation -│ ├── HandleScopeBuffer.h # Handle scope memory management +│ ├── HandleScopeBuffer.h # The V8 handles of one handle scope (the scope itself is Bun::HandleScopeImpl, shared with Node-API) │ ├── TaggedPointer.h # V8-style tagged pointer implementation │ ├── Map.h # V8 Map objects for inline function compatibility │ ├── GlobalInternals.h # V8 global state management @@ -226,6 +226,7 @@ For `ObjectTemplate` or `FunctionTemplate` implementations, see existing pattern - All V8 values must be created within an active handle scope - Use `isolate->currentHandleScope()->createLocal()` to create handles - Handle scopes automatically clean up when destroyed +- Node-API and the V8 API share one handle scope implementation, `Bun::HandleScopeImpl` (`src/jsc/bindings/napi_handle_scope.h`). `v8::HandleScope` and `Bun::NapiHandleScope` both open one, and `currentHandleScope()` returns the V8 handle storage of the innermost one, so V8 calls made from a Node-API callback work without any V8 scope of their own. Addons built against Node 26 headers never construct Bun's `v8::HandleScope` at all (it is inline); their scopes reach Bun only through `HandleScope::Extend` and `DeleteExtensions`. ### JSC Integration diff --git a/src/jsc/bindings/v8/CLAUDE.md b/src/jsc/bindings/v8/CLAUDE.md index e41b8866cf52..aa45aa5508e6 100644 --- a/src/jsc/bindings/v8/CLAUDE.md +++ b/src/jsc/bindings/v8/CLAUDE.md @@ -27,7 +27,7 @@ src/jsc/bindings/v8/ ├── V8*.cpp # V8 class implementations ├── shim/ # Internal implementation details │ ├── Handle.h # Handle and ObjectLayout implementation -│ ├── HandleScopeBuffer.h # Handle scope memory management +│ ├── HandleScopeBuffer.h # The V8 handles of one handle scope (the scope itself is Bun::HandleScopeImpl, shared with Node-API) │ ├── TaggedPointer.h # V8-style tagged pointer implementation │ ├── Map.h # V8 Map objects for inline function compatibility │ ├── GlobalInternals.h # V8 global state management @@ -226,6 +226,7 @@ For `ObjectTemplate` or `FunctionTemplate` implementations, see existing pattern - All V8 values must be created within an active handle scope - Use `isolate->currentHandleScope()->createLocal()` to create handles - Handle scopes automatically clean up when destroyed +- Node-API and the V8 API share one handle scope implementation, `Bun::HandleScopeImpl` (`src/jsc/bindings/napi_handle_scope.h`). `v8::HandleScope` and `Bun::NapiHandleScope` both open one, and `currentHandleScope()` returns the V8 handle storage of the innermost one, so V8 calls made from a Node-API callback work without any V8 scope of their own. Addons built against Node 26 headers never construct Bun's `v8::HandleScope` at all (it is inline); their scopes reach Bun only through `HandleScope::Extend` and `DeleteExtensions`. ### JSC Integration diff --git a/src/jsc/bindings/v8/V8Array.cpp b/src/jsc/bindings/v8/V8Array.cpp index 4ab770f33d4a..e895155724c9 100644 --- a/src/jsc/bindings/v8/V8Array.cpp +++ b/src/jsc/bindings/v8/V8Array.cpp @@ -8,7 +8,7 @@ #include "JavaScriptCore/ArrayConstructor.h" #include "JavaScriptCore/ObjectConstructor.h" #include "JavaScriptCore/JSCJSValue.h" -#include "JavaScriptCore/ThrowScope.h" +#include "JavaScriptCore/TopExceptionScope.h" #include "JavaScriptCore/IteratorOperations.h" #include "JavaScriptCore/ArgList.h" @@ -22,12 +22,14 @@ using JSC::MarkedArgumentBuffer; namespace v8 { +// These use TopExceptionScope instead of ThrowScope because they return to addon code. See NAPI_PREAMBLE. + // Array::New with elements and length Local Array::New(Isolate* isolate, Local* elements, size_t length) { Zig::GlobalObject* globalObject = isolate->globalObject(); auto& vm = isolate->vm(); - auto scope = DECLARE_THROW_SCOPE(vm); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); if (length == 0) { JSArray* array = JSC::constructEmptyArray(globalObject, nullptr); @@ -55,7 +57,7 @@ Local Array::New(Isolate* isolate, int length) { Zig::GlobalObject* globalObject = isolate->globalObject(); auto& vm = isolate->vm(); - auto scope = DECLARE_THROW_SCOPE(vm); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); int realLength = length > 0 ? length : 0; JSArray* array = JSC::constructEmptyArray(globalObject, nullptr, static_cast(realLength)); @@ -74,7 +76,7 @@ MaybeLocal Array::New(Local context, size_t length, EscapableHandleScope handleScope(isolate); - auto scope = DECLARE_THROW_SCOPE(vm); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); MarkedArgumentBuffer args; // Fill array using callback @@ -94,8 +96,6 @@ MaybeLocal Array::New(Local context, size_t length, JSArray* array = JSC::constructArray(globalObject, static_cast(nullptr), args); RETURN_IF_EXCEPTION(scope, MaybeLocal()); - // Note: createLocal must not be called on an EscapableHandleScope -- it does not own a - // buffer (its constructor does not push a Bun handle scope; see V8EscapableHandleScopeBase). Local result = isolate->currentHandleScope()->createLocal(vm, array); return handleScope.Escape(result); } @@ -127,7 +127,7 @@ Maybe Array::Iterate(Local context, IterationCallback callback, v const JSArray* jsArray = localToObjectPointer(); Zig::GlobalObject* globalObject = context->globalObject(); auto& vm = globalObject->vm(); - auto scope = DECLARE_THROW_SCOPE(vm); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); HandleScope handleScope(context->GetIsolate()); diff --git a/src/jsc/bindings/v8/V8ArrayBuffer.cpp b/src/jsc/bindings/v8/V8ArrayBuffer.cpp index 14512c6801e6..a6f7373450ee 100644 --- a/src/jsc/bindings/v8/V8ArrayBuffer.cpp +++ b/src/jsc/bindings/v8/V8ArrayBuffer.cpp @@ -61,7 +61,7 @@ Local ArrayBufferView::Buffer() JSC::JSArrayBuffer* jsBuffer = view->possiblySharedJSBuffer(globalObject); if (!jsBuffer) [[unlikely]] return Local(); - HandleScope* handleScope = globalObject->V8GlobalInternals()->currentHandleScope(); + auto* handleScope = globalObject->V8GlobalInternals()->currentHandleScope(); return handleScope->createLocal(vm, jsBuffer); } diff --git a/src/jsc/bindings/v8/V8Context.h b/src/jsc/bindings/v8/V8Context.h index 4b49a96f20c5..9d0924ddad5b 100644 --- a/src/jsc/bindings/v8/V8Context.h +++ b/src/jsc/bindings/v8/V8Context.h @@ -32,7 +32,7 @@ class Context : public Data { return dynamicDowncast(localToCell()); } - HandleScope* currentHandleScope() const + shim::HandleScopeBuffer* currentHandleScope() const { return globalObject()->V8GlobalInternals()->currentHandleScope(); }; diff --git a/src/jsc/bindings/v8/V8EscapableHandleScopeBase.cpp b/src/jsc/bindings/v8/V8EscapableHandleScopeBase.cpp index c9fdd48d01db..3e19ed5bad66 100644 --- a/src/jsc/bindings/v8/V8EscapableHandleScopeBase.cpp +++ b/src/jsc/bindings/v8/V8EscapableHandleScopeBase.cpp @@ -11,8 +11,8 @@ EscapableHandleScopeBase::EscapableHandleScopeBase(Isolate* isolate) { // This constructor must be ABI-neutral between header generations (see the comment in // V8EscapableHandleScopeBase.h): with Node 26 headers the object is destroyed by V8's inline - // ~HandleScope, with older headers by Bun's exported ~HandleScope, and neither path can pop a - // Bun handle scope. So do not push one. Instead initialize the three V8-visible base words + // ~HandleScope, with older headers by Bun's exported ~HandleScope, and neither path can close a + // Bun scope. So do not open one. Instead initialize the three V8-visible base words // exactly like V8 14's inline HandleScope::Initialize (v8-local-handle.h): // isolate_ <- isolate // prev_next_ <- HandleScopeData::next @@ -24,14 +24,14 @@ EscapableHandleScopeBase::EscapableHandleScopeBase(Isolate* isolate) // CreateHandle advances next past it), so the snapshot we restore preserves that invariant. auto* data = shim::getHandleScopeData(isolate); m_isolate = isolate; - m_previousHandleScope = reinterpret_cast(data->next); - m_buffer = reinterpret_cast(data->limit); + m_scope = reinterpret_cast(data->next); + m_openedScopeMarker = reinterpret_cast(data->limit); data->level++; - // Handles created while this scope is alive land in the surrounding Bun scope's buffer (we - // did not push), so they outlive this scope; that is safe, just slightly longer-lived than - // real V8. An Escape()d value must survive this scope, which that same buffer provides -- - // capture it now so Escape still targets it even if (with old-ABI addons) a deeper scope is + // Handles created while this scope is alive land in the surrounding scope (we did not open + // one), so they outlive this scope. That is safe, just slightly longer-lived than real V8. An + // Escape()d value must survive this scope, and the surrounding scope ensures that. Capture its + // buffer now so Escape still targets it even if (with old-ABI addons) a deeper scope is // current by then. // // Reserve the escape slot NOW, like real V8: its storage index must be below every handle @@ -39,11 +39,9 @@ EscapableHandleScopeBase::EscapableHandleScopeBase(Isolate* isolate) // ~HandleScope) would sweep the just-escaped handle together with the scope's grants. The // reservation is kept in a side registry keyed by `this` because the V8 ABI leaves exactly // one Bun-usable word in this object (m_escapeBuffer). - auto* current = isolate->globalInternals()->currentHandleScope(); - RELEASE_ASSERT(current, "EscapableHandleScope created without an active handle scope"); - m_escapeBuffer = current->m_buffer; - shim::Handle* reserved = current->m_buffer->reserveEscapeHandle(); - isolate->globalInternals()->escapeReservations().set(this, shim::GlobalInternals::EscapeReservation { reserved, current->m_buffer }); + m_escapeBuffer = isolate->currentHandleScope(); + shim::Handle* reserved = m_escapeBuffer->reserveEscapeHandle(); + isolate->globalInternals()->escapeReservations().set(this, shim::GlobalInternals::EscapeReservation { reserved, m_escapeBuffer }); } // Fill the escape slot reserved at construction with escape_value and return its location. @@ -55,7 +53,6 @@ uintptr_t* EscapableHandleScopeBase::EscapeSlot(uintptr_t* escape_value) "EscapableHandleScope escape reservation missing"); TaggedPointer* newHandle = m_escapeBuffer->createHandleFromExistingObject( TaggedPointer::fromRaw(*escape_value), - m_isolate, reservation.handle); m_escapeBuffer = nullptr; return newHandle->asRawPtrLocation(); diff --git a/src/jsc/bindings/v8/V8EscapableHandleScopeBase.h b/src/jsc/bindings/v8/V8EscapableHandleScopeBase.h index 9903cfd045fc..a6e23254f39a 100644 --- a/src/jsc/bindings/v8/V8EscapableHandleScopeBase.h +++ b/src/jsc/bindings/v8/V8EscapableHandleScopeBase.h @@ -12,13 +12,9 @@ namespace v8 { // unwinds the isolate's HandleScopeData using this object's three base words as // { isolate_, prev_next_, prev_limit_ }. Older Node headers (<= 24) instead reach Bun's exported // ~HandleScope through their inline-defaulted destructors. Therefore this constructor must NOT -// push a Bun handle scope (nothing on either path would pop it); it initializes the base words +// open a Bun handle scope (nothing on either path would close it). It initializes the base words // V8-style, and Bun's exported ~HandleScope detects such frames and unwinds them the same way the // inline destructor would. See V8EscapableHandleScopeBase.cpp and V8HandleScope.cpp. -// -// Consequently the inherited m_previousHandleScope/m_buffer words do NOT hold Bun pointers here, -// so inherited HandleScope methods that use them (like createLocal) must not be called on these -// objects; Bun-internal code should use isolate->currentHandleScope()->createLocal instead. class EscapableHandleScopeBase : public HandleScope { public: BUN_EXPORT EscapableHandleScopeBase(Isolate* isolate); diff --git a/src/jsc/bindings/v8/V8Exception.cpp b/src/jsc/bindings/v8/V8Exception.cpp index 46b9b328d2d5..30d762f16b34 100644 --- a/src/jsc/bindings/v8/V8Exception.cpp +++ b/src/jsc/bindings/v8/V8Exception.cpp @@ -3,16 +3,23 @@ #include "V8String.h" #include "V8HandleScope.h" #include +#include namespace v8 { +// JSC::createError asserts that the message isn't empty, but an addon can pass an empty one. +static JSC::JSObject* createErrorInstance(JSC::JSGlobalObject* globalObject, JSC::ErrorType type, const WTF::String& message) +{ + return JSC::ErrorInstance::create(globalObject->vm(), globalObject->errorStructure(type), message, JSC::JSValue(), nullptr, JSC::TypeNothing, type); +} + Local Exception::Error(Local message, Local options) { (void)options; Isolate* isolate = Isolate::GetCurrent(); auto* globalObject = isolate->globalObject(); WTF::String wtfMessage = message->localToJSString()->value(globalObject); - JSC::JSObject* error = JSC::createError(globalObject, wtfMessage); + JSC::JSObject* error = createErrorInstance(globalObject, JSC::ErrorType::Error, wtfMessage); return isolate->currentHandleScope()->createLocal(isolate->vm(), error); } @@ -22,7 +29,7 @@ Local Exception::TypeError(Local message, Local options) Isolate* isolate = Isolate::GetCurrent(); auto* globalObject = isolate->globalObject(); WTF::String wtfMessage = message->localToJSString()->value(globalObject); - JSC::JSObject* error = JSC::createTypeError(globalObject, wtfMessage); + JSC::JSObject* error = createErrorInstance(globalObject, JSC::ErrorType::TypeError, wtfMessage); return isolate->currentHandleScope()->createLocal(isolate->vm(), error); } diff --git a/src/jsc/bindings/v8/V8HandleScope.cpp b/src/jsc/bindings/v8/V8HandleScope.cpp index 0dd444a7e63d..b9a78048a422 100644 --- a/src/jsc/bindings/v8/V8HandleScope.cpp +++ b/src/jsc/bindings/v8/V8HandleScope.cpp @@ -1,5 +1,6 @@ #include "V8HandleScope.h" #include "shim/GlobalInternals.h" +#include "napi_handle_scope.h" #include "v8_compatibility_assertions.h" #include "v8_handle_scope_data.h" @@ -9,7 +10,7 @@ // three words of a HandleScope frame directly as { Isolate* isolate_; Address* prev_next_; // Address* prev_limit_; }. Frames constructed by our exported HandleScope(Isolate*) constructor // are never destroyed by that inline code (old-ABI addons call our exported destructor), so those -// keep Bun meanings for words 1 and 2 (m_previousHandleScope/m_buffer). Frames constructed by the +// keep Bun meanings for words 1 and 2 (m_scope/m_openedScopeMarker). Frames constructed by the // exported EscapableHandleScopeBase constructor *are* unwound by the inline destructor, so that // constructor initializes them with V8's meanings instead -- see V8EscapableHandleScopeBase.cpp // and the comments in ~HandleScope below. @@ -19,82 +20,40 @@ namespace v8 { HandleScope::HandleScope(Isolate* isolate) : m_isolate(isolate) - , m_previousHandleScope(m_isolate->globalInternals()->currentHandleScope()) - , m_buffer(shim::HandleScopeBuffer::create( - isolate->vm(), - isolate->globalInternals()->handleScopeBufferStructure(isolate->globalObject()))) + , m_scope(Bun::HandleScopeImpl::open(isolate->globalObject(), false)) + , m_openedScopeMarker(this) { - m_isolate->globalInternals()->setCurrentHandleScope(this); - // Snapshot the isolate's HandleScopeData so the pop can restore it; see - // the comment on HandleScopeBuffer::saveHandleScopeData. - auto* data = shim::getHandleScopeData(isolate); - m_buffer->saveHandleScopeData(data->next, data->limit); } HandleScope::~HandleScope() { - if (m_isolate->globalInternals()->currentHandleScope() != this) { - // This frame was not pushed onto Bun's handle scope stack, so it must have been - // initialized in V8's inline ABI style by the exported EscapableHandleScopeBase - // constructor (which is the only exported constructor that does not push; plain - // HandleScope frames built by the exported constructor above always have - // currentHandleScope() == this here under correct nesting). Old-ABI addons reach this - // destructor for such frames because their inline-defaulted ~EscapableHandleScopeBase / - // ~EscapableHandleScope call the out-of-line ~HandleScope. Unwind exactly like V8 14's - // inline ~HandleScope would: words 1 and 2 hold the constructor-time snapshot of - // HandleScopeData::next/limit, not Bun pointers. -#if ASSERT_ENABLED - // A Bun-pushed frame destroyed out of LIFO order would also land here and have its - // m_previousHandleScope/m_buffer pointers written into HandleScopeData below, silently - // corrupting the next inline CreateHandle. Fail loudly in debug builds instead. - for (auto* scope = m_isolate->globalInternals()->currentHandleScope(); scope; scope = scope->m_previousHandleScope) { - ASSERT_WITH_MESSAGE(scope != this, "v8::HandleScope destroyed out of LIFO order"); - } -#endif - auto* data = shim::getHandleScopeData(m_isolate); - data->next = reinterpret_cast(m_previousHandleScope); - data->limit = reinterpret_cast(m_buffer); - data->level--; - // Mirror V8 14's inline ~HandleScope: reclaim the slots Extend granted inside this - // frame (a no-op when the frame created no handles, since the newest remaining grant - // then already matches the restored limit). - if (auto* current = m_isolate->globalInternals()->currentHandleScope()) { - current->m_buffer->deleteGrantsBack(m_isolate, data->limit); - } - // This frame is an escapable scope going through the exported destructor (old ABI); - // drop its escape reservation if Escape() was never called. - m_isolate->globalInternals()->escapeReservations().remove(this); + if (m_openedScopeMarker == this) { + Bun::HandleScopeImpl::close(m_isolate->globalObject(), m_scope); return; } - m_isolate->globalInternals()->setCurrentHandleScope(m_previousHandleScope); - // Escape reservations in this buffer belong to scopes that are dead or dying (their slots - // are about to be cleared); purge them so stale stack-address keys can't alias new scopes. - m_isolate->globalInternals()->purgeEscapeReservations(m_buffer); - // Restore HandleScopeData to its push-time snapshot. If Extend granted - // slots from this buffer while this scope was current, next/limit would - // otherwise keep pointing into the buffer we are about to clear, and the - // next inline v8::HandleScope would capture that stale limit as its - // prev_limit_ — its DeleteExtensions would then pop every grant in the - // (foreign) enclosing buffer, killing handles of still-open outer scopes. + // This is a V8-style frame from EscapableHandleScopeBase. Old-ABI addons reach this destructor + // through their inline-defaulted ~EscapableHandleScope. Words 1 and 2 are the HandleScopeData + // next/limit snapshot, so unwind exactly like V8 14's inline ~HandleScope would. auto* data = shim::getHandleScopeData(m_isolate); - data->next = m_buffer->savedNext(); - data->limit = m_buffer->savedLimit(); - // A live callback frame's return value may point into this buffer (scopes - // popping mid-callback: old-ABI addon scopes, Bun-internal ones like - // Array::Iterate's). Move it to the enclosing scope so it survives until - // the frame is read. - if (m_previousHandleScope) { - m_buffer->evacuateActiveReturnValues(m_isolate, m_previousHandleScope->m_buffer); + data->next = reinterpret_cast(m_scope); + data->limit = reinterpret_cast(m_openedScopeMarker); + data->level--; + // Mirror V8 14's inline ~HandleScope: reclaim the slots Extend granted inside this frame (a + // no-op when the frame created no handles, since the newest remaining grant then already + // matches the restored limit). + if (auto* current = m_isolate->globalObject()->m_currentHandleScopeImpl.get()) { + if (auto* handles = current->v8Handles()) { + handles->deleteGrantsBack(data->limit); + } } - m_buffer->clear(); - m_buffer = nullptr; + // Drop this escapable scope's reservation if Escape() was never called. + m_isolate->globalInternals()->escapeReservations().remove(this); } uintptr_t* HandleScope::CreateHandle(internal::Isolate* i_isolate, uintptr_t value) { auto* isolate = reinterpret_cast(i_isolate); - auto* handleScope = isolate->globalInternals()->currentHandleScope(); - TaggedPointer* newSlot = handleScope->m_buffer->createHandleFromExistingObject(TaggedPointer::fromRaw(value), isolate); + TaggedPointer* newSlot = isolate->currentHandleScope()->createHandleFromExistingObject(TaggedPointer::fromRaw(value)); // basically a reinterpret return newSlot->asRawPtrLocation(); } @@ -110,12 +69,12 @@ void HandleScope::Initialize(Isolate* isolate) { // Mirror V8 14's inline HandleScope::Initialize (v8-local-handle.h): // stash the HandleScopeData snapshot in the V8-visible words and bump - // level. The frame is addon-owned and V8-laid-out — do not push a Bun - // scope and do not touch Bun-meaning members beyond the three words. + // level. The frame is addon-owned and V8-laid-out, so do not open a Bun + // scope, and give the words V8's meanings. auto* data = shim::getHandleScopeData(isolate); m_isolate = isolate; - m_previousHandleScope = reinterpret_cast(data->next); - m_buffer = reinterpret_cast(data->limit); + m_scope = reinterpret_cast(data->next); + m_openedScopeMarker = reinterpret_cast(data->limit); data->level++; } @@ -127,11 +86,9 @@ uintptr_t* HandleScope::Extend(Isolate* isolate) // (next == limit == nullptr), and we always hand out exactly one slot with // limit == slot + 1 == the next value the caller will store, so next == limit is reestablished // after every inline allocation and every inline handle creation takes this path. The slots - // come from the current Bun handle scope's buffer, so the values stay alive (and GC-visited, - // see Handle::isCell) until that scope closes. - auto* handleScope = isolate->globalInternals()->currentHandleScope(); - RELEASE_ASSERT(handleScope); - TaggedPointer* slot = handleScope->m_buffer->createRawHandleSlot(); + // come from the innermost open scope, so the values stay alive (and GC-visited, see + // Handle::isCell) until that scope closes. + TaggedPointer* slot = isolate->currentHandleScope()->createRawHandleSlot(); uintptr_t* address = slot->asRawPtrLocation(); auto* data = shim::getHandleScopeData(isolate); data->next = address; @@ -143,15 +100,16 @@ void HandleScope::DeleteExtensions(Isolate* isolate) { // Called by V8 14's inline ~HandleScope after it restored HandleScopeData::next/limit, when // the scope changed the limit (which Extend always does). Free the slots Extend granted inside - // the closing scope — without this, per-iteration v8::HandleScopes in a long native call never - // reclaim memory (everything would otherwise live until the enclosing Bun scope closes). + // the closing scope. Without this, per-iteration v8::HandleScopes in a long native call would + // never reclaim memory (everything would live until the enclosing scope closes). // `this` is the addon's V8-layout HandleScope, so our members must not be touched. - auto* handleScope = isolate->globalInternals()->currentHandleScope(); - if (!handleScope) { + auto* current = isolate->globalObject()->m_currentHandleScopeImpl.get(); + if (!current) { return; } - auto* data = shim::getHandleScopeData(isolate); - handleScope->m_buffer->deleteGrantsBack(isolate, data->limit); + if (auto* handles = current->v8Handles()) { + handles->deleteGrantsBack(shim::getHandleScopeData(isolate)->limit); + } } } // namespace v8 diff --git a/src/jsc/bindings/v8/V8HandleScope.h b/src/jsc/bindings/v8/V8HandleScope.h index eae0556fa81f..1c428072f6f4 100644 --- a/src/jsc/bindings/v8/V8HandleScope.h +++ b/src/jsc/bindings/v8/V8HandleScope.h @@ -5,12 +5,14 @@ #include "v8_internal.h" #include "shim/HandleScopeBuffer.h" #include "shim/GlobalInternals.h" -#include "shim/Map.h" namespace v8 { class Number; +// Opens a Bun::HandleScopeImpl (the same scope Node-API uses) for this object's lifetime. Only Bun +// and addons built against Node <= 24 headers construct these. Node 26 headers inline the +// constructor and destructor, so those scopes only reach Bun through Extend/DeleteExtensions. class HandleScope { public: BUN_EXPORT HandleScope(Isolate* isolate); @@ -18,41 +20,20 @@ class HandleScope { template Local createLocal(JSC::VM& vm, JSC::JSValue value) { - // TODO(@190n) handle more types - if (value.isString()) { - return Local(m_buffer->createHandle(value.asCell(), &shim::Map::string_map(), vm)); - } else if (value.isCell()) { - return Local(m_buffer->createHandle(value.asCell(), &shim::Map::object_map(), vm)); - } else if (value.isInt32()) { - return Local(m_buffer->createSmiHandle(value.asInt32())); - } else if (value.isNumber()) { - return Local(m_buffer->createDoubleHandle(value.asNumber())); - } else if (value.isUndefined()) { - return Local(m_isolate->undefinedSlot()); - } else if (value.isNull()) { - return Local(m_isolate->nullSlot()); - } else if (value.isTrue()) { - return Local(m_isolate->trueSlot()); - } else if (value.isFalse()) { - return Local(m_isolate->falseSlot()); - } else { - V8_UNIMPLEMENTED(); - return Local(); - } + return m_isolate->currentHandleScope()->createLocal(vm, value); } - friend class EscapableHandleScopeBase; - protected: // Used by EscapableHandleScopeBase, whose constructor must initialize the fields itself - // (V8-style, without pushing a Bun handle scope). Mirrors V8's protected - // `HandleScope() = default`. + // (V8-style, without opening a Bun scope). Mirrors V8's protected `HandleScope() = default`. HandleScope() = default; - // must be 24 bytes to match V8 layout + // V8's layout is { isolate_, prev_next_, prev_limit_ }. A frame built by the exported constructor + // stores the scope it opened and `this` as a marker. A frame initialized V8-style + // (EscapableHandleScopeBase, Initialize) stores V8's next/limit snapshot. See ~HandleScope. Isolate* m_isolate; - HandleScope* m_previousHandleScope; - shim::HandleScopeBuffer* m_buffer; + Bun::HandleScopeImpl* m_scope; + HandleScope* m_openedScopeMarker; // is protected in v8, which matters on windows BUN_EXPORT static uintptr_t* CreateHandle(internal::Isolate* isolate, uintptr_t value); @@ -64,7 +45,7 @@ class HandleScope { // Same story for the inline constructor's Initialize: under MSVC /Ob0 the // addon-side inline HandleScope constructor calls an imported Initialize. // Initializes the frame in V8's inline style (snapshot next/limit, - // level++) — never pushes a Bun scope, mirroring EscapableHandleScopeBase. + // level++) and never opens a Bun scope, like EscapableHandleScopeBase. BUN_EXPORT void Initialize(Isolate* isolate); private: diff --git a/src/jsc/bindings/v8/V8Isolate.cpp b/src/jsc/bindings/v8/V8Isolate.cpp index ecb145809496..fc79acab846a 100644 --- a/src/jsc/bindings/v8/V8Isolate.cpp +++ b/src/jsc/bindings/v8/V8Isolate.cpp @@ -6,6 +6,7 @@ #include "real_v8.h" #include "v8_compatibility_assertions.h" #include +#include static_assert(offsetof(v8::Isolate, m_roots) == real_v8::internal::Internals::kIsolateRootsOffset, "Isolate roots array is at wrong offset"); @@ -152,7 +153,8 @@ Local Isolate::ThrowError(Local message) JSC::VM& vm = this->vm(); auto scope = DECLARE_THROW_SCOPE(vm); WTF::String wtfMessage = message->localToJSString()->value(m_globalObject); - JSC::JSObject* error = JSC::createError(m_globalObject, wtfMessage); + // JSC::createError asserts that the message is not empty. + JSC::JSObject* error = JSC::ErrorInstance::create(vm, m_globalObject->errorStructure(), wtfMessage, JSC::JSValue()); Local handle = currentHandleScope()->createLocal(vm, error); JSC::throwException(m_globalObject, scope, error); return handle; @@ -172,7 +174,7 @@ Isolate::Isolate(shim::GlobalInternals* globalInternals) m_roots[kFalseValueRootIndex] = TaggedPointer(&globalInternals->m_falseValue); } -HandleScope* Isolate::currentHandleScope() +shim::HandleScopeBuffer* Isolate::currentHandleScope() { return m_globalInternals->currentHandleScope(); } diff --git a/src/jsc/bindings/v8/V8Isolate.h b/src/jsc/bindings/v8/V8Isolate.h index 97f4b4cb9657..837e2d15a049 100644 --- a/src/jsc/bindings/v8/V8Isolate.h +++ b/src/jsc/bindings/v8/V8Isolate.h @@ -5,7 +5,6 @@ namespace v8 { -class HandleScope; class Context; class Value; class String; @@ -15,6 +14,7 @@ class Isolate; namespace shim { class GlobalInternals; +class HandleScopeBuffer; } // v8-callbacks.h @@ -90,7 +90,8 @@ class Isolate final { Zig::GlobalObject* globalObject() { return m_globalObject; } JSC::VM& vm() { return globalObject()->vm(); } shim::GlobalInternals* globalInternals() { return m_globalInternals; } - HandleScope* currentHandleScope(); + // Returns the innermost open handle scope, where new handles are created (see GlobalInternals::currentHandleScope). + shim::HandleScopeBuffer* currentHandleScope(); TaggedPointer* undefinedSlot() { return &m_roots[Isolate::kUndefinedValueRootIndex]; } diff --git a/src/jsc/bindings/v8/V8Object.cpp b/src/jsc/bindings/v8/V8Object.cpp index db2b59c40860..0b9df03870a8 100644 --- a/src/jsc/bindings/v8/V8Object.cpp +++ b/src/jsc/bindings/v8/V8Object.cpp @@ -101,7 +101,7 @@ MaybeLocal Object::Get(Local context, Local key) return MaybeLocal(); } - HandleScope* handleScope = globalObject->V8GlobalInternals()->currentHandleScope(); + auto* handleScope = globalObject->V8GlobalInternals()->currentHandleScope(); return handleScope->createLocal(vm, result); } @@ -118,7 +118,7 @@ MaybeLocal Object::Get(Local context, uint32_t index) return MaybeLocal(); } - HandleScope* handleScope = globalObject->V8GlobalInternals()->currentHandleScope(); + auto* handleScope = globalObject->V8GlobalInternals()->currentHandleScope(); return handleScope->createLocal(vm, result); } @@ -142,7 +142,7 @@ Local Object::SlowGetInternalField(int index) auto* fields = getInternalFieldsContainer(this); JSObject* js_object = localToObjectPointer(); auto* globalObject = dynamicDowncast(js_object->globalObject()); - HandleScope* handleScope = globalObject->V8GlobalInternals()->currentHandleScope(); + auto* handleScope = globalObject->V8GlobalInternals()->currentHandleScope(); if (fields && index >= 0 && index < fields->size()) { auto& field = fields->at(index); return handleScope->createLocal(globalObject->vm(), field.get()); diff --git a/src/jsc/bindings/v8/V8String.cpp b/src/jsc/bindings/v8/V8String.cpp index 4c2fe9735446..d15b7405c28e 100644 --- a/src/jsc/bindings/v8/V8String.cpp +++ b/src/jsc/bindings/v8/V8String.cpp @@ -3,6 +3,7 @@ #include "helpers.h" #include "wtf/SIMDUTF.h" #include "v8_compatibility_assertions.h" +#include "headers-handwritten.h" ASSERT_V8_TYPE_LAYOUT_MATCHES(v8::String) @@ -154,9 +155,6 @@ bool String::IsExternalOneByte() const return !impl->isNull() && impl->impl()->isExternal() && impl->is8Bit(); } -extern "C" size_t TextEncoder__encodeInto8(const Latin1Character* stringPtr, size_t stringLen, void* ptr, size_t len); -extern "C" size_t TextEncoder__encodeInto16(const char16_t* stringPtr, size_t stringLen, void* ptr, size_t len); - int String::WriteUtf8(Isolate* isolate, char* buffer, int length, int* nchars_ref, int options) const { RELEASE_ASSERT(options == 0); @@ -165,10 +163,11 @@ int String::WriteUtf8(Isolate* isolate, char* buffer, int length, int* nchars_re size_t unsigned_length = length < 0 ? static_cast(std::numeric_limits::max()) : static_cast(length); - uint64_t result = string.is8Bit() ? TextEncoder__encodeInto8(string.span8().data(), string.span8().size(), buffer, unsigned_length) - : TextEncoder__encodeInto16(string.span16().data(), string.span16().size(), buffer, unsigned_length); - uint32_t read = static_cast(result); - uint32_t written = static_cast(result >> 32); + TextEncoderEncodeIntoResult result = string.is8Bit() ? TextEncoder__encodeInto8(string.span8().data(), string.span8().size(), buffer, unsigned_length) + : TextEncoder__encodeInto16(string.span16().data(), string.span16().size(), buffer, unsigned_length); + // unsigned_length <= INT_MAX, so both counts fit in 32 bits here. + uint32_t read = static_cast(result.read); + uint32_t written = static_cast(result.written); if (written < length && read == string.length()) { buffer[written] = 0; @@ -244,37 +243,15 @@ size_t String::WriteUtf8V2(Isolate* isolate, char* buffer, size_t capacity, int // uses when kReplaceInvalidUtf8 is not set, so the result size matches either // way). if (str->is8Bit()) { - // Latin-1 expands at most 2x: 2 * (2^31 - 1) < 2^32, so the packed - // 32-bit counts cannot wrap. const auto span = str->span8(); - uint64_t result = TextEncoder__encodeInto8(span.data(), span.size(), buffer, writableCapacity); - read = static_cast(result); - written = static_cast(result >> 32); + TextEncoderEncodeIntoResult result = TextEncoder__encodeInto8(span.data(), span.size(), buffer, writableCapacity); + read = result.read; + written = result.written; } else { - // UTF-16 expands up to 3x, which can exceed the 32-bit counts - // TextEncoder__encodeInto packs its result into (3 * (2^31 - 1) > - // 2^32). Encode in chunks small enough that each chunk's counts - // fit, accumulating in size_t. const auto span = str->span16(); - const size_t total = span.size(); - constexpr size_t maxChunk = static_cast(1) << 30; // <= 3 GiB UTF-8 per chunk - while (read < total) { - size_t chunkLength = std::min(maxChunk, total - read); - // Never split a surrogate pair across chunks: the encoder - // would see two unpaired halves and write U+FFFD twice. - if (read + chunkLength < total && U16_IS_LEAD(span[read + chunkLength - 1])) { - chunkLength--; - } - uint64_t result = TextEncoder__encodeInto16(span.data() + read, chunkLength, buffer + written, writableCapacity - written); - const uint32_t chunkRead = static_cast(result); - const uint32_t chunkWritten = static_cast(result >> 32); - read += chunkRead; - written += chunkWritten; - if (chunkRead < chunkLength) { - // Ran out of output capacity. - break; - } - } + TextEncoderEncodeIntoResult result = TextEncoder__encodeInto16(span.data(), span.size(), buffer, writableCapacity); + read = result.read; + written = result.written; } } diff --git a/src/jsc/bindings/v8/V8TypedArray.cpp b/src/jsc/bindings/v8/V8TypedArray.cpp index 94f5d2f904ff..13098948a70a 100644 --- a/src/jsc/bindings/v8/V8TypedArray.cpp +++ b/src/jsc/bindings/v8/V8TypedArray.cpp @@ -28,7 +28,7 @@ static Local newTypedArray(Local array_buffer, size_t b auto* view = JSCType::create(globalObject, structure, WTF::move(backing), byte_offset, length); RETURN_IF_EXCEPTION(scope, Local()); - HandleScope* handleScope = globalObject->V8GlobalInternals()->currentHandleScope(); + auto* handleScope = globalObject->V8GlobalInternals()->currentHandleScope(); return handleScope->createLocal(vm, view); } diff --git a/src/jsc/bindings/v8/node.cpp b/src/jsc/bindings/v8/node.cpp index 7389e978c201..46183a118dc8 100644 --- a/src/jsc/bindings/v8/node.cpp +++ b/src/jsc/bindings/v8/node.cpp @@ -3,9 +3,7 @@ #include "JavaScriptCore/ArgList.h" #include "JavaScriptCore/CallData.h" -#include "JavaScriptCore/ObjectConstructor.h" #include "JavaScriptCore/ThrowScope.h" -#include "JSCommonJSModule.h" #include "node/node_version.h" @@ -21,9 +19,12 @@ using v8::Object; using v8::Value; using JSC::JSObject; -using JSC::jsUndefined; using JSC::JSValue; +#if !OS(WINDOWS) +extern "C" struct uv_loop_s* Bun__VirtualMachine__uvLoop(void* bunVM); +#endif + namespace node { void AddEnvironmentCleanupHook(v8::Isolate* isolate, @@ -45,10 +46,7 @@ struct uv_loop_s* GetCurrentEventLoop(v8::Isolate* isolate) #if OS(WINDOWS) return reinterpret_cast(isolate->globalObject()->uvLoop()); #else - // Bun does not run a libuv event loop on POSIX; per node.h this may return - // nullptr when the context is not associated with a Node instance. - (void)isolate; - return nullptr; + return Bun__VirtualMachine__uvLoop(isolate->globalObject()->bunVM()); #endif } @@ -110,22 +108,17 @@ v8::MaybeLocal MakeCallback(v8::Isolate* isolate, void node_module_register(void* opaque_mod) { // TODO unify this with napi_module_register + // This runs in one of the addon's static constructors, so like Node it only queues the module. auto* globalObject = defaultGlobalObject(); - auto& vm = JSC::getVM(globalObject); - auto* mod = reinterpret_cast(opaque_mod); - - auto keyStr = WTF::String::fromUTF8(mod->nm_modname); - - // Append to GlobalObject vector so BunProcess.cpp can save ALL registrations after dlopen completes - globalObject->m_pendingV8Modules.append(mod); - + globalObject->m_pendingV8Modules.append(reinterpret_cast(opaque_mod)); globalObject->napiModuleRegisterCallCount++; - JSValue pendingNapiModule = globalObject->m_pendingNapiModuleAndExports[0].get(); - JSObject* object = (pendingNapiModule && pendingNapiModule.isObject()) ? pendingNapiModule.getObject() - : nullptr; +} - auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); - JSC::Strong strongExportsObject; +void executePendingV8Module(Zig::GlobalObject* globalObject, node_module* mod, JSObject* object) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto keyStr = WTF::String::fromUTF8(mod->nm_modname); if (mod->nm_version != REPORTED_NODEJS_ABI_VERSION) { auto* error = JSC::createError(globalObject, @@ -136,31 +129,19 @@ void node_module_register(void* opaque_mod) ". This version of Bun requires NODE_MODULE_VERSION "_s, REPORTED_NODEJS_ABI_VERSION, ". Please try re-compiling or re-installing the module."_s)); - globalObject->m_pendingNapiModuleAndExports[0].set(vm, globalObject, error); + JSC::throwException(globalObject, scope, error); return; } - if (!object) { - auto* exportsObject = JSC::constructEmptyObject(globalObject); - RETURN_IF_EXCEPTION(scope, void()); - - object = Bun::JSCommonJSModule::create(globalObject, keyStr, exportsObject, false, jsUndefined()); - RETURN_IF_EXCEPTION(scope, void()); - strongExportsObject = { vm, exportsObject }; - } else { - JSValue exportsObject = object->get(globalObject, WebCore::builtinNames(vm).exportsPublicName()); - RETURN_IF_EXCEPTION(scope, void()); - - // Convert exports to object, matching Node.js behavior. - // This throws for null/undefined and creates wrapper objects for primitives. - JSObject* exports = exportsObject.toObject(globalObject); - RETURN_IF_EXCEPTION(scope, void()); + JSValue exportsValue = object->get(globalObject, WebCore::builtinNames(vm).exportsPublicName()); + RETURN_IF_EXCEPTION(scope, void()); - ASSERT(exports); - strongExportsObject = { vm, exports }; - } + // Like Node, convert exports to an object. null and undefined throw, and a primitive gets a wrapper object. + JSObject* exportsObject = exportsValue.toObject(globalObject); + RETURN_IF_EXCEPTION(scope, void()); - JSC::Strong strongObject = { vm, object }; + ASSERT(exportsObject); + JSC::Strong strongExportsObject = { vm, exportsObject }; auto* isolate = globalObject->V8GlobalInternals()->isolate(); HandleScope hs(isolate); @@ -175,12 +156,11 @@ void node_module_register(void* opaque_mod) mod->nm_register_func(exports, module, mod->nm_priv); } else { auto* error = JSC::createError(globalObject, WTF::makeString("The module '"_s, keyStr, "' has no declared entry point."_s)); - globalObject->m_pendingNapiModuleAndExports[0].set(vm, globalObject, error); + JSC::throwException(globalObject, scope, error); return; } RETURN_IF_EXCEPTION(scope, void()); - globalObject->m_pendingNapiModuleAndExports[1].set(vm, globalObject, object); } } // namespace node diff --git a/src/jsc/bindings/v8/node.h b/src/jsc/bindings/v8/node.h index 74bce2b9e36a..803227750ad3 100644 --- a/src/jsc/bindings/v8/node.h +++ b/src/jsc/bindings/v8/node.h @@ -73,4 +73,7 @@ struct node_module { extern "C" BUN_EXPORT void node_module_register(void* mod); +// Runs the entry point of a module queued by node_module_register, with moduleObject as `module`. Throws on failure. +void executePendingV8Module(Zig::GlobalObject* globalObject, node_module* mod, JSC::JSObject* moduleObject); + } // namespace node diff --git a/src/jsc/bindings/v8/shim/GlobalInternals.cpp b/src/jsc/bindings/v8/shim/GlobalInternals.cpp index 7145ecf8ce45..dab08664f39d 100644 --- a/src/jsc/bindings/v8/shim/GlobalInternals.cpp +++ b/src/jsc/bindings/v8/shim/GlobalInternals.cpp @@ -5,9 +5,12 @@ #include "HandleScopeBuffer.h" #include "../V8FunctionTemplate.h" #include "../V8Function.h" +#include "ZigGlobalObject.h" +#include "napi_handle_scope.h" #include "JavaScriptCore/FunctionPrototype.h" #include "JavaScriptCore/LazyClassStructureInlines.h" +#include "JavaScriptCore/LazyPropertyInlines.h" #include "JavaScriptCore/VMTrapsInlines.h" using JSC::ClassInfo; @@ -37,18 +40,14 @@ void GlobalInternals::finishCreation(VM& vm) m_objectTemplateStructure.initLater([](LazyClassStructure::Initializer& init) { init.setStructure(ObjectTemplate::createStructure(init.vm, init.global, init.global->functionPrototype())); }); - m_handleScopeBufferStructure.initLater([](LazyClassStructure::Initializer& init) { - init.setStructure(HandleScopeBuffer::createStructure(init.vm, init.global)); - }); m_functionTemplateStructure.initLater([](LazyClassStructure::Initializer& init) { init.setStructure(FunctionTemplate::createStructure(init.vm, init.global)); }); m_v8FunctionStructure.initLater([](LazyClassStructure::Initializer& init) { init.setStructure(Function::createStructure(init.vm, init.global)); }); - m_globalHandles.initLater([](const LazyProperty::Initializer& init) { - init.set(HandleScopeBuffer::create(init.vm, - init.owner->handleScopeBufferStructure(init.owner->m_globalObject))); + m_globalHandles.initLater([](const LazyProperty::Initializer& init) { + init.set(Bun::HandleScopeImpl::create(init.vm, init.owner->m_globalObject->HandleScopeImplStructure(), nullptr)); }); } @@ -60,7 +59,6 @@ void GlobalInternals::visitChildrenImpl(JSCell* cell, Visitor& visitor) Base::visitChildren(thisObject, visitor); thisObject->m_objectTemplateStructure.visit(visitor); - thisObject->m_handleScopeBufferStructure.visit(visitor); thisObject->m_functionTemplateStructure.visit(visitor); thisObject->m_v8FunctionStructure.visit(visitor); thisObject->m_globalHandles.visit(visitor); @@ -68,5 +66,17 @@ void GlobalInternals::visitChildrenImpl(JSCell* cell, Visitor& visitor) DEFINE_VISIT_CHILDREN_WITH_MODIFIER(JS_EXPORT_PRIVATE, GlobalInternals); +HandleScopeBuffer* GlobalInternals::globalHandles() +{ + return &m_globalHandles.getInitializedOnMainThread(this)->ensureV8Handles(&m_isolate); +} + +HandleScopeBuffer* GlobalInternals::currentHandleScope() +{ + auto* scope = m_globalObject->m_currentHandleScopeImpl.get(); + RELEASE_ASSERT_WITH_MESSAGE(scope, "Cannot create a V8 handle without an open handle scope"); + return &scope->ensureV8Handles(&m_isolate); +} + } // namespace shim } // namespace v8 diff --git a/src/jsc/bindings/v8/shim/GlobalInternals.h b/src/jsc/bindings/v8/shim/GlobalInternals.h index 6ef2e1be5021..018b5bc0ae51 100644 --- a/src/jsc/bindings/v8/shim/GlobalInternals.h +++ b/src/jsc/bindings/v8/shim/GlobalInternals.h @@ -7,9 +7,11 @@ #include "../V8Isolate.h" #include "Oddball.h" -namespace v8 { +namespace Bun { +class HandleScopeImpl; +} -class HandleScope; +namespace v8 { namespace shim { @@ -40,11 +42,6 @@ class GlobalInternals : public JSC::JSCell { return m_objectTemplateStructure.getInitializedOnMainThread(globalObject); } - JSC::Structure* handleScopeBufferStructure(JSC::JSGlobalObject* globalObject) const - { - return m_handleScopeBufferStructure.getInitializedOnMainThread(globalObject); - } - JSC::Structure* functionTemplateStructure(JSC::JSGlobalObject* globalObject) const { return m_functionTemplateStructure.getInitializedOnMainThread(globalObject); @@ -55,9 +52,12 @@ class GlobalInternals : public JSC::JSCell { return m_v8FunctionStructure.getInitializedOnMainThread(globalObject); } - HandleScopeBuffer* globalHandles() const { return m_globalHandles.getInitializedOnMainThread(this); } + // Returns the scope for v8::Global handles. It's never closed. + HandleScopeBuffer* globalHandles(); - HandleScope* currentHandleScope() const { return m_currentHandleScope; } + // Returns the innermost open scope's V8 handles (GlobalObject::m_currentHandleScopeImpl). Like V8, + // aborts if no scope is open. Bun opens a scope around every native callback it makes. + HandleScopeBuffer* currentHandleScope(); // Escape-slot reservations for live EscapableHandleScopes, keyed by the // scope's stack address. The slot is reserved at scope construction (so it @@ -105,8 +105,6 @@ class GlobalInternals : public JSC::JSCell { GlobalInternals* m_internals; }; - void setCurrentHandleScope(HandleScope* handleScope) { m_currentHandleScope = handleScope; } - WTF::Vector>& gcPrologueCallbacks() { return m_gcPrologueCallbacks; } WTF::Vector>& gcEpilogueCallbacks() { return m_gcEpilogueCallbacks; } WTF::Vector>& nearHeapLimitCallbacks() { return m_nearHeapLimitCallbacks; } @@ -126,15 +124,13 @@ class GlobalInternals : public JSC::JSCell { private: Zig::GlobalObject* m_globalObject; JSC::LazyClassStructure m_objectTemplateStructure; - JSC::LazyClassStructure m_handleScopeBufferStructure; JSC::LazyClassStructure m_functionTemplateStructure; JSC::LazyClassStructure m_v8FunctionStructure; - HandleScope* m_currentHandleScope; WTF::HashMap m_escapeReservations; - // No inline capacity for the same ASAN reason as - // HandleScopeBuffer::m_rawGrants (cells are swept without destructors). + // No inline capacity, because in-cell inline Vector storage would leave stale ASAN container + // annotations behind (this cell is swept without running destructors). WTF::Vector m_activeReturnValueSlots; - JSC::LazyProperty m_globalHandles; + JSC::LazyProperty m_globalHandles; WTF::Vector> m_gcPrologueCallbacks; WTF::Vector> m_gcEpilogueCallbacks; @@ -151,7 +147,6 @@ class GlobalInternals : public JSC::JSCell { void finishCreation(JSC::VM& vm); GlobalInternals(JSC::VM& vm, JSC::Structure* structure, Zig::GlobalObject* globalObject) : Base(vm, structure) - , m_currentHandleScope(nullptr) , m_undefinedValue(Oddball::Kind::kUndefined) , m_nullValue(Oddball::Kind::kNull) , m_trueValue(Oddball::Kind::kTrue) diff --git a/src/jsc/bindings/v8/shim/HandleScopeBuffer.cpp b/src/jsc/bindings/v8/shim/HandleScopeBuffer.cpp index 8a2189509843..fd56f47d082b 100644 --- a/src/jsc/bindings/v8/shim/HandleScopeBuffer.cpp +++ b/src/jsc/bindings/v8/shim/HandleScopeBuffer.cpp @@ -1,49 +1,32 @@ #include "HandleScopeBuffer.h" #include "GlobalInternals.h" -#include "../V8Isolate.h" +#include "../v8_handle_scope_data.h" +#include namespace v8 { namespace shim { -// for CREATE_METHOD_TABLE -namespace JSCastingHelpers = JSC::JSCastingHelpers; +WTF_MAKE_TZONE_ALLOCATED_IMPL(HandleScopeBuffer); -const JSC::ClassInfo HandleScopeBuffer::s_info = { - "HandleScopeBuffer"_s, - nullptr, - nullptr, - nullptr, - CREATE_METHOD_TABLE(HandleScopeBuffer) -}; - -HandleScopeBuffer* HandleScopeBuffer::create(JSC::VM& vm, JSC::Structure* structure) +HandleScopeBuffer::HandleScopeBuffer(Bun::HandleScopeImpl* owner, Isolate* isolate) + : m_owner(owner) + , m_isolate(isolate) { - HandleScopeBuffer* buffer = new (NotNull, JSC::allocateCell(vm)) HandleScopeBuffer(vm, structure); - buffer->finishCreation(vm); - return buffer; + auto* data = getHandleScopeData(isolate); + m_savedNext = data->next; + m_savedLimit = data->limit; } -template -void HandleScopeBuffer::visitChildrenImpl(JSCell* cell, Visitor& visitor) -{ - HandleScopeBuffer* thisObject = uncheckedDowncast(cell); - ASSERT_GC_OBJECT_INHERITS(thisObject, info()); - Base::visitChildren(thisObject, visitor); - - WTF::Locker locker { thisObject->m_gcLock }; +HandleScopeBuffer::~HandleScopeBuffer() = default; - for (auto& handle : thisObject->m_storage) { - if (handle.isCell()) { - visitor.append(handle.asCell()); - } - } +JSC::VM& HandleScopeBuffer::vm() const +{ + return m_isolate->vm(); } -DEFINE_VISIT_CHILDREN(HandleScopeBuffer); - Handle& HandleScopeBuffer::createEmptyHandle() { - WTF::Locker locker { m_gcLock }; + WTF::Locker locker { m_owner->cellLock() }; m_storage.append(Handle {}); return m_storage.last(); } @@ -51,7 +34,7 @@ Handle& HandleScopeBuffer::createEmptyHandle() TaggedPointer* HandleScopeBuffer::createHandle(JSCell* ptr, const Map* map, JSC::VM& vm) { auto& handle = createEmptyHandle(); - handle = Handle(map, ptr, vm, this); + handle = Handle(map, ptr, vm, m_owner); return handle.slot(); } @@ -71,7 +54,7 @@ TaggedPointer* HandleScopeBuffer::createDoubleHandle(double value) TaggedPointer* HandleScopeBuffer::createRawHandleSlot() { - WTF::Locker locker { m_gcLock }; + WTF::Locker locker { m_owner->cellLock() }; m_storage.append(Handle {}); TaggedPointer* slot = m_storage.last().slot(); m_rawGrants.append({ slot, m_storage.size() - 1 }); @@ -83,13 +66,13 @@ Handle* HandleScopeBuffer::reserveEscapeHandle() return &createEmptyHandle(); } -void HandleScopeBuffer::deleteGrantsBack(Isolate* isolate, const uintptr_t* limit) +void HandleScopeBuffer::deleteGrantsBack(const uintptr_t* limit) { // (slot to repoint, copy of the layout it pointed at) for every condemned // handle a live return-value slot references. WTF::Vector, 2> rescues; { - WTF::Locker locker { m_gcLock }; + WTF::Locker locker { m_owner->cellLock() }; // Pop grants (and every handle created after each, which V8 semantics also // scope to the closing inline HandleScope) until the newest remaining grant // is the one the restored limit points one past — i.e. the last grant made @@ -106,7 +89,7 @@ void HandleScopeBuffer::deleteGrantsBack(Isolate* isolate, const uintptr_t* limi // frame, and the returned value must outlive this scope (in V8 the scope // owns only the slot, never the object). Copy out any condemned handle a // frame still points at so it can be re-created in the surviving region. - for (TaggedPointer* returnSlot : isolate->globalInternals()->activeReturnValueSlots()) { + for (TaggedPointer* returnSlot : m_isolate->globalInternals()->activeReturnValueSlots()) { if (const ObjectLayout* layout = findLayoutInRangeLocked(*returnSlot, cut)) { rescues.append({ returnSlot, *layout }); } @@ -151,18 +134,17 @@ TaggedPointer HandleScopeBuffer::appendRescuedLayout(const ObjectLayout& layout) // Besides heap numbers, scope buffers only ever own string_map/object_map // cells (oddballs live in the isolate's roots and globals in their own // buffer), so everything else is a cell. - handle = Handle(layout.map(), layout.asCell(), vm(), this); + handle = Handle(layout.map(), layout.asCell(), vm(), m_owner); } return *handle.slot(); } -void HandleScopeBuffer::evacuateActiveReturnValues(Isolate* isolate, HandleScopeBuffer* target) +void HandleScopeBuffer::evacuateActiveReturnValues(Bun::HandleScopeImpl* parent) { - ASSERT(target != this); - for (TaggedPointer* returnSlot : isolate->globalInternals()->activeReturnValueSlots()) { + for (TaggedPointer* returnSlot : m_isolate->globalInternals()->activeReturnValueSlots()) { ObjectLayout rescued; { - WTF::Locker locker { m_gcLock }; + WTF::Locker locker { m_owner->cellLock() }; const ObjectLayout* layout = findLayoutInRangeLocked(*returnSlot, 0); if (!layout) { continue; @@ -171,11 +153,11 @@ void HandleScopeBuffer::evacuateActiveReturnValues(Isolate* isolate, HandleScope } // The source handle is still alive (this buffer clears after the // evacuation), so the cell stays visited across this append. - *returnSlot = target->appendRescuedLayout(rescued); + *returnSlot = parent->ensureV8Handles(m_isolate).appendRescuedLayout(rescued); } } -TaggedPointer* HandleScopeBuffer::createHandleFromExistingObject(TaggedPointer address, Isolate* isolate, Handle* reuseHandle) +TaggedPointer* HandleScopeBuffer::createHandleFromExistingObject(TaggedPointer address, Handle* reuseHandle) { int32_t smi; if (address.getSmi(smi)) { @@ -192,20 +174,20 @@ TaggedPointer* HandleScopeBuffer::createHandleFromExistingObject(TaggedPointer a // find which oddball this is switch (reinterpret_cast(v8_object)->kind()) { case Kind::kNull: - return isolate->nullSlot(); + return m_isolate->nullSlot(); case Kind::kUndefined: - return isolate->undefinedSlot(); + return m_isolate->undefinedSlot(); case Kind::kTrue: - return isolate->trueSlot(); + return m_isolate->trueSlot(); case Kind::kFalse: - return isolate->falseSlot(); + return m_isolate->falseSlot(); default: RELEASE_ASSERT_NOT_REACHED("HandleScopeBuffer::createHandleFromExistingObject passed an unknown Oddball kind: %d", reinterpret_cast(v8_object)->kind()); } } if (reuseHandle) { - *reuseHandle = Handle(v8_object->map(), v8_object->asCell(), vm(), this); + *reuseHandle = Handle(v8_object->map(), v8_object->asCell(), vm(), m_owner); return reuseHandle->slot(); } else { return createHandle(v8_object->asCell(), v8_object->map(), vm()); @@ -213,15 +195,23 @@ TaggedPointer* HandleScopeBuffer::createHandleFromExistingObject(TaggedPointer a } } -void HandleScopeBuffer::clear() +void HandleScopeBuffer::close(Bun::HandleScopeImpl* parent) { - // detect use-after-free of handles - WTF::Locker locker { m_gcLock }; - for (auto& handle : m_storage) { - handle = Handle(); + auto* internals = m_isolate->globalInternals(); + // Escape reservations in this buffer belong to scopes that are dead or dying (their slots + // are about to be cleared). Purge them so stale stack-address keys can't alias new scopes. + internals->purgeEscapeReservations(this); + auto* data = getHandleScopeData(m_isolate); + data->next = m_savedNext; + data->limit = m_savedLimit; + // A live callback frame's return value may point into this buffer (scopes + // popping mid-callback: old-ABI addon scopes, Bun-internal ones like + // Array::Iterate's). Move it to the enclosing scope so it survives until + // the frame is read. + if (parent && !internals->activeReturnValueSlots().isEmpty()) { + evacuateActiveReturnValues(parent); } - m_storage.clear(); - m_rawGrants.clear(); + // The owner detaches this buffer and then frees it, along with every handle. } } // namespace shim diff --git a/src/jsc/bindings/v8/shim/HandleScopeBuffer.h b/src/jsc/bindings/v8/shim/HandleScopeBuffer.h index 59a467c46852..27921b3c0aad 100644 --- a/src/jsc/bindings/v8/shim/HandleScopeBuffer.h +++ b/src/jsc/bindings/v8/shim/HandleScopeBuffer.h @@ -2,36 +2,49 @@ #include "../v8.h" #include "../V8Isolate.h" +#include "../V8Local.h" #include "TaggedPointer.h" #include "Map.h" #include "Handle.h" -#include +#include "napi_handle_scope.h" +#include +#include namespace v8 { - -class EscapableHandleScopeBase; - namespace shim { -// An array used by HandleScope to store the items. Must keep pointer stability when resized, since -// v8::Locals point inside this array. -class HandleScopeBuffer : public JSC::JSCell { -public: - using Base = JSC::JSCell; - - static HandleScopeBuffer* create(JSC::VM& vm, JSC::Structure* structure); +// Stores the V8 handles for one Bun::HandleScopeImpl, which owns this and attaches it on the first +// V8 call inside the scope. v8::Locals point to the handles, so a SegmentedVector keeps their +// addresses stable. The owning scope visits the handles, and mutations must lock its cellLock(). +class HandleScopeBuffer { + WTF_MAKE_TZONE_ALLOCATED(HandleScopeBuffer); - static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject) - { - return JSC::Structure::create(vm, globalObject, JSC::jsNull(), JSC::TypeInfo(JSC::CellType, StructureFlags), info(), 0, 0); - } +public: + HandleScopeBuffer(Bun::HandleScopeImpl* owner, Isolate* isolate); + ~HandleScopeBuffer(); - template - static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + template Local createLocal(JSC::VM& vm, JSC::JSValue value) { - if constexpr (mode == JSC::SubspaceAccess::Concurrently) - return nullptr; - return WebCore::subspaceForImpl(vm, BUN_SUBSPACE_SLOTS(m_clientSubspaceForHandleScopeBuffer, m_subspaceForHandleScopeBuffer)); + if (value.isString()) { + return Local(createHandle(value.asCell(), &Map::string_map(), vm)); + } else if (value.isCell()) { + return Local(createHandle(value.asCell(), &Map::object_map(), vm)); + } else if (value.isInt32()) { + return Local(createSmiHandle(value.asInt32())); + } else if (value.isNumber()) { + return Local(createDoubleHandle(value.asNumber())); + } else if (value.isUndefined()) { + return Local(m_isolate->undefinedSlot()); + } else if (value.isNull()) { + return Local(m_isolate->nullSlot()); + } else if (value.isTrue()) { + return Local(m_isolate->trueSlot()); + } else if (value.isFalse()) { + return Local(m_isolate->falseSlot()); + } else { + V8_UNIMPLEMENTED(); + return Local(); + } } TaggedPointer* createHandle(JSC::JSCell* object, const Map* map, JSC::VM& vm); @@ -47,17 +60,10 @@ class HandleScopeBuffer : public JSC::JSCell { // Free every handle created after the raw slot whose address + 1 equals `limit` (the // HandleScopeData::limit value V8's inline ~HandleScope just restored). Called from // HandleScope::DeleteExtensions so per-iteration inline v8::HandleScopes inside a single - // native call reclaim their handles instead of accumulating until the enclosing Bun scope + // native call reclaim their handles instead of accumulating until the enclosing scope // closes. Handles that a live callback return-value slot points at are copied into the - // surviving region first (see activeReturnValueSlots()). - void deleteGrantsBack(Isolate* isolate, const uintptr_t* limit); - - // Copy any handle of this buffer that a live callback return-value slot still points at - // into `target`, repointing the slot at the copy. Called right before this buffer clears: - // V8's inline ReturnValue::Set stores a Local's Address into the callback frame, and the - // frame outlives scopes that pop while the callback is still on the stack (old-ABI addon - // scopes, Bun-internal scopes like Array::Iterate's). - void evacuateActiveReturnValues(Isolate* isolate, HandleScopeBuffer* target); + // surviving region first (see GlobalInternals::activeReturnValueSlots()). + void deleteGrantsBack(const uintptr_t* limit); // Reserve an empty handle for an EscapableHandleScope's escape slot. // Called from the scope's constructor so the slot's storage index is below @@ -65,61 +71,52 @@ class HandleScopeBuffer : public JSC::JSCell { // it); EscapeSlot() fills it via createHandleFromExistingObject(reuseHandle). Handle* reserveEscapeHandle(); - // HandleScopeData::{next,limit} as they were when the owning Bun - // HandleScope was pushed. ~HandleScope writes them back when it pops so - // the isolate's HandleScopeData never dangles into this (cleared) buffer - // — otherwise the next inline v8::HandleScope would snapshot a stale - // limit and its DeleteExtensions would sweep a foreign buffer's grants. - void saveHandleScopeData(uintptr_t* next, uintptr_t* limit) - { - m_savedNext = next; - m_savedLimit = limit; - } - uintptr_t* savedNext() const { return m_savedNext; } - uintptr_t* savedLimit() const { return m_savedLimit; } - // Given a tagged pointer from V8, create a handle around the same object or the same // numeric value // // address: V8 object pointer or Smi - // isolate: received in any V8 method // reuseHandle: if nonnull, change this handle instead of creating a new one // returns the location of the new handle's V8 object pointer or Smi - TaggedPointer* createHandleFromExistingObject(TaggedPointer address, Isolate* isolate, Handle* reuseHandle = nullptr); - - void clear(); + TaggedPointer* createHandleFromExistingObject(TaggedPointer address, Handle* reuseHandle = nullptr); - DECLARE_INFO; - DECLARE_VISIT_CHILDREN; + // Called when the owning scope is closing and about to free this. Restores the isolate's + // HandleScopeData and moves any handle a live callback return value still points to into `parent`. + void close(Bun::HandleScopeImpl* parent); - friend class ::v8::EscapableHandleScopeBase; + // Called by the owning scope's visitChildren, which holds the owner's cellLock(). + template void visitHandles(Visitor& visitor) + { + for (auto& handle : m_storage) { + if (handle.isCell()) { + visitor.append(handle.asCell()); + } + } + } private: - WTF::Lock m_gcLock; + Bun::HandleScopeImpl* m_owner; + Isolate* m_isolate; WTF::SegmentedVector m_storage; // (slot, index in m_storage) for every createRawHandleSlot grant, in creation order. - // No inline capacity: in-cell inline Vector storage would leave stale ASAN - // container annotations behind (this cell type is swept without running - // C++ destructors), tripping container-overflow on cell reuse. The heap - // buffer is released in clear(). WTF::Vector> m_rawGrants; - uintptr_t* m_savedNext { nullptr }; - uintptr_t* m_savedLimit { nullptr }; + // HandleScopeData::{next,limit} at the time this was attached. They're the same as when the + // owning scope was opened, because changing them requires a buffer on the innermost scope. + // close() restores them so they never point into freed handles. + uintptr_t* m_savedNext; + uintptr_t* m_savedLimit; + JSC::VM& vm() const; Handle& createEmptyHandle(); - // Requires m_gcLock. Return `value`'s target if it is the ObjectLayout of a handle at - // index >= begin, else null. + // Requires the owner's cellLock(). Return `value`'s target if it is the ObjectLayout of a + // handle at index >= begin, else null. const ObjectLayout* findLayoutInRangeLocked(TaggedPointer value, size_t begin) const; // Append an owning copy of `layout` and return the tagged pointer a frame slot should hold - // to reference it. Takes m_gcLock itself (do not call while holding it). + // to reference it. Takes the lock itself (do not call while holding it). TaggedPointer appendRescuedLayout(const ObjectLayout& layout); - HandleScopeBuffer(JSC::VM& vm, JSC::Structure* structure) - : Base(vm, structure) - { - } + void evacuateActiveReturnValues(Bun::HandleScopeImpl* parent); }; } // namespace shim diff --git a/src/jsc/bindings/v8/v8_api_internal.cpp b/src/jsc/bindings/v8/v8_api_internal.cpp index acf8941c19c0..866ce93bcf93 100644 --- a/src/jsc/bindings/v8/v8_api_internal.cpp +++ b/src/jsc/bindings/v8/v8_api_internal.cpp @@ -36,7 +36,7 @@ uintptr_t* GlobalizeReference(internal::Isolate* i_isolate, uintptr_t address) { auto* isolate = reinterpret_cast(i_isolate); auto* globalHandles = isolate->globalInternals()->globalHandles(); - TaggedPointer* newSlot = globalHandles->createHandleFromExistingObject(TaggedPointer::fromRaw(address), isolate); + TaggedPointer* newSlot = globalHandles->createHandleFromExistingObject(TaggedPointer::fromRaw(address)); return newSlot->asRawPtrLocation(); } diff --git a/src/jsc/bindings/webcore/DOMClientIsoSubspaces.h b/src/jsc/bindings/webcore/DOMClientIsoSubspaces.h index c2cf3f0df379..bce08c4984c8 100644 --- a/src/jsc/bindings/webcore/DOMClientIsoSubspaces.h +++ b/src/jsc/bindings/webcore/DOMClientIsoSubspaces.h @@ -40,7 +40,6 @@ class DOMClientIsoSubspaces { GCClient::IsoSubspace* m_clientSubspaceForNapiExternal { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForBundlerPlugin { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForNodeVMGlobalObject { nullptr }; - GCClient::IsoSubspace* m_clientSubspaceForNodeVMSpecialSandbox { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForNodeVMScript { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForNodeVMSourceTextModule { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForNodeVMSyntheticModule { nullptr }; @@ -62,7 +61,7 @@ class DOMClientIsoSubspaces { GCClient::IsoSubspace* m_clientSubspaceForJSDiffieHellmanGroup { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForJSECDH { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForTTYWrapObject { nullptr }; - GCClient::IsoSubspace* m_clientSubspaceForNapiHandleScopeImpl { nullptr }; + GCClient::IsoSubspace* m_clientSubspaceForHandleScopeImpl { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForStrongRootBlock { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForNapiTypeTag { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForNativePromiseContext { nullptr }; @@ -71,7 +70,6 @@ class DOMClientIsoSubspaces { GCClient::IsoSubspace* m_clientSubspaceForJSMIMEType { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForJSMIMEParams { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForV8GlobalInternals { nullptr }; - GCClient::IsoSubspace* m_clientSubspaceForHandleScopeBuffer { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForFunctionTemplate { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForV8Function { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForJSNodeHTTPServerSocket { nullptr }; diff --git a/src/jsc/bindings/webcore/DOMIsoSubspaces.h b/src/jsc/bindings/webcore/DOMIsoSubspaces.h index 1e8b986fe616..9cb57ca92365 100644 --- a/src/jsc/bindings/webcore/DOMIsoSubspaces.h +++ b/src/jsc/bindings/webcore/DOMIsoSubspaces.h @@ -41,7 +41,6 @@ class DOMIsoSubspaces { IsoSubspace* m_subspaceForImportMeta { nullptr }; IsoSubspace* m_subspaceForBundlerPlugin { nullptr }; IsoSubspace* m_subspaceForNodeVMGlobalObject { nullptr }; - IsoSubspace* m_subspaceForNodeVMSpecialSandbox { nullptr }; IsoSubspace* m_subspaceForNodeVMScript { nullptr }; IsoSubspace* m_subspaceForNodeVMSourceTextModule { nullptr }; IsoSubspace* m_subspaceForNodeVMSyntheticModule { nullptr }; @@ -60,14 +59,13 @@ class DOMIsoSubspaces { IsoSubspace* m_subspaceForJSModuleGraph { nullptr }; IsoSubspace* m_subspaceForJSSocketHandlers { nullptr }; IsoSubspace* m_subspaceForTTYWrapObject { nullptr }; - IsoSubspace* m_subspaceForNapiHandleScopeImpl { nullptr }; + IsoSubspace* m_subspaceForHandleScopeImpl { nullptr }; IsoSubspace* m_subspaceForStrongRootBlock { nullptr }; IsoSubspace* m_subspaceForNapiTypeTag { nullptr }; IsoSubspace* m_subspaceForNativePromiseContext { nullptr }; IsoSubspace* m_subspaceForObjectTemplate { nullptr }; IsoSubspace* m_subspaceForInternalFieldObject { nullptr }; IsoSubspace* m_subspaceForV8GlobalInternals { nullptr }; - IsoSubspace* m_subspaceForHandleScopeBuffer { nullptr }; IsoSubspace* m_subspaceForFunctionTemplate { nullptr }; IsoSubspace* m_subspaceForJSMIMEType { nullptr }; IsoSubspace* m_subspaceForJSMIMEParams { nullptr }; diff --git a/src/jsc/bindings/webcore/FetchHeaders.cpp b/src/jsc/bindings/webcore/FetchHeaders.cpp index bd817e8f01e6..663177e2e409 100644 --- a/src/jsc/bindings/webcore/FetchHeaders.cpp +++ b/src/jsc/bindings/webcore/FetchHeaders.cpp @@ -31,6 +31,7 @@ #include "HTTPHeaderNames.h" #include "HTTPParsers.h" +#include "StringSizeLimit.h" #include "wtf/DebugHeap.h" #include #include @@ -82,10 +83,10 @@ static ExceptionOr canWriteHeader(const HTTPHeaderName name, const String& static ExceptionOr canWriteHeader(const String& name, const String& value, const String& combinedValue, FetchHeaders::Guard guard) { if (!isValidHTTPToken(name)) - return Exception { TypeError, makeString("Invalid header name: '"_s, name, "'"_s) }; + return exceptionWithMessage(TypeError, "Invalid header name: '"_s, name, "'"_s); ASSERT(value.isEmpty() || (!isHTTPSpace(value[0]) && !isHTTPSpace(value[value.length() - 1]))); if (!isValidHTTPHeaderValue((value))) - return Exception { TypeError, makeString("Header '"_s, name, "' has invalid value"_s) }; + return exceptionWithMessage(TypeError, "Header '"_s, name, "' has invalid value"_s); if (guard == FetchHeaders::Guard::Immutable) return Exception { TypeError, "Headers object's guard is 'immutable'"_s }; return true; @@ -109,10 +110,12 @@ static ExceptionOr appendToHeaderMap(const String& name, const String& val if (index.isValid()) { auto existing = headers.getIndex(index); if (headerName == HTTPHeaderName::Cookie) { - combinedTemp = makeString(existing, "; "_s, normalizedValue); + combinedTemp = tryMakeString(existing, "; "_s, normalizedValue); } else { - combinedTemp = makeString(existing, ", "_s, normalizedValue); + combinedTemp = tryMakeString(existing, ", "_s, normalizedValue); } + if (combinedTemp.isNull() || Bun::exceedsStringLimit(combinedTemp.length())) [[unlikely]] + return Exception { OutOfMemoryError }; valueToSet = &combinedTemp; } } @@ -127,15 +130,17 @@ static ExceptionOr appendToHeaderMap(const String& name, const String& val if (headerName != HTTPHeaderName::SetCookie) { if (!headers.setIndex(index, *valueToSet)) headers.set(headerName, *valueToSet); - } else { - headers.add(headerName, normalizedValue); + } else if (!headers.tryAddSetCookie(normalizedValue)) [[unlikely]] { + return Exception { OutOfMemoryError }; } return {}; } auto index = headers.indexOf(name); if (index.isValid()) { - combinedTemp = makeString(headers.getIndex(index), ", "_s, normalizedValue); + combinedTemp = tryMakeString(headers.getIndex(index), ", "_s, normalizedValue); + if (combinedTemp.isNull() || Bun::exceedsStringLimit(combinedTemp.length())) [[unlikely]] + return Exception { OutOfMemoryError }; valueToSet = &combinedTemp; } auto canWriteResult = canWriteHeader(name, normalizedValue, *valueToSet, guard); @@ -249,7 +254,7 @@ ExceptionOr FetchHeaders::remove(const StringView name) } if (!isValidHTTPToken(name)) - return Exception { TypeError, makeString("Invalid header name: '"_s, name, "'"_s) }; + return exceptionWithMessage(TypeError, "Invalid header name: '"_s, name, "'"_s); m_headers.willChange().removeUncommonHeader(name); @@ -263,12 +268,21 @@ size_t FetchHeaders::memoryCost() const ExceptionOr FetchHeaders::get(const StringView name) const { - auto result = internalHeaders().get(name); - if (result.isEmpty()) { - if (!isValidHTTPToken(name)) - return Exception { TypeError, makeString("Invalid header name: '"_s, name, "'"_s) }; + HTTPHeaderName headerName; + if (findHTTPHeaderName(name, headerName)) { + if (headerName == HTTPHeaderName::SetCookie) { + auto joined = internalHeaders().tryJoinSetCookieHeaders(); + if (!joined) [[unlikely]] + return Exception { OutOfMemoryError }; + return WTF::move(*joined); + } + return internalHeaders().get(headerName); } + auto result = internalHeaders().getUncommonHeader(name); + if (result.isEmpty() && !isValidHTTPToken(name)) + return exceptionWithMessage(TypeError, "Invalid header name: '"_s, name, "'"_s); + return result; } @@ -277,7 +291,7 @@ ExceptionOr FetchHeaders::has(const StringView name) const bool has = internalHeaders().contains(name); if (!has) { if (!isValidHTTPToken(name)) - return Exception { TypeError, makeString("Invalid header name: '"_s, name, '"') }; + return exceptionWithMessage(TypeError, "Invalid header name: '"_s, name, "'"_s); } return has; } diff --git a/src/jsc/bindings/webcore/HTTPHeaderMap.cpp b/src/jsc/bindings/webcore/HTTPHeaderMap.cpp index 3bc4366ddf3b..b7b6a3ae3f7b 100644 --- a/src/jsc/bindings/webcore/HTTPHeaderMap.cpp +++ b/src/jsc/bindings/webcore/HTTPHeaderMap.cpp @@ -32,6 +32,8 @@ #include "config.h" #include "HTTPHeaderMap.h" +#include "StringSizeLimit.h" +#include "VectorSizeLimit.h" #include #include @@ -211,28 +213,42 @@ bool HTTPHeaderMap::removeUncommonHeader(const StringView name) }); } -String HTTPHeaderMap::get(HTTPHeaderName name) const +std::optional HTTPHeaderMap::tryJoinSetCookieHeaders() const { - if (name == HTTPHeaderName::SetCookie) { - unsigned count = m_setCookieHeaders.size(); - switch (count) { - case 0: - return String(); - case 1: - return m_setCookieHeaders[0]; - default: { - StringBuilder builder; - builder.reserveCapacity(m_setCookieHeaders[0].length() * count + (count - 1)); - builder.append(m_setCookieHeaders[0]); - for (unsigned i = 1; i < count; ++i) { - builder.append(", "_s); - builder.append(m_setCookieHeaders[i]); - } - return builder.toString(); - } - } + unsigned count = m_setCookieHeaders.size(); + switch (count) { + case 0: + return String(); + case 1: + return m_setCookieHeaders[0]; + default: + break; } + uint64_t length = 2 * static_cast(count - 1); + for (const auto& header : m_setCookieHeaders) + length += header.length(); + if (Bun::exceedsStringLimit(length)) [[unlikely]] + return std::nullopt; + + StringBuilder builder { OverflowPolicy::RecordOverflow }; + builder.reserveCapacity(static_cast(length)); + builder.append(m_setCookieHeaders[0]); + for (unsigned i = 1; i < count; ++i) { + builder.append(", "_s); + builder.append(m_setCookieHeaders[i]); + } + if (builder.hasOverflowed()) [[unlikely]] + return std::nullopt; + return builder.toString(); +} + +String HTTPHeaderMap::get(HTTPHeaderName name) const +{ + // If the joined value exceeds the max string length, treat the header as absent. FetchHeaders::get() throws instead. + if (name == HTTPHeaderName::SetCookie) + return tryJoinSetCookieHeaders().value_or(String()); + auto index = m_commonHeaders.findIf([&](auto& header) { return header.key == name; }); @@ -316,6 +332,11 @@ bool HTTPHeaderMap::remove(HTTPHeaderName name) }); } +bool HTTPHeaderMap::tryAddSetCookie(const String& value) +{ + return m_setCookieHeaders.size() < Bun::maxVectorSize() && m_setCookieHeaders.tryAppend(value); +} + void HTTPHeaderMap::add(HTTPHeaderName name, const String& value) { if (name == HTTPHeaderName::SetCookie) { diff --git a/src/jsc/bindings/webcore/HTTPHeaderMap.h b/src/jsc/bindings/webcore/HTTPHeaderMap.h index 547ae5b15c04..3b3a6e43adf4 100644 --- a/src/jsc/bindings/webcore/HTTPHeaderMap.h +++ b/src/jsc/bindings/webcore/HTTPHeaderMap.h @@ -160,8 +160,12 @@ class HTTPHeaderMap { HeaderIndex indexOf(HTTPHeaderName name) const; WEBCORE_EXPORT String get(HTTPHeaderName) const; + // Returns the Set-Cookie values joined with ", ", or nullopt if the result exceeds the max string length. + WEBCORE_EXPORT std::optional tryJoinSetCookieHeaders() const; void set(HTTPHeaderName, const String& value); void add(HTTPHeaderName, const String& value); + // Returns false if the list of Set-Cookie values can't grow. + bool tryAddSetCookie(const String& value); WEBCORE_EXPORT bool contains(HTTPHeaderName) const; WEBCORE_EXPORT bool remove(HTTPHeaderName); @@ -215,10 +219,9 @@ class HTTPHeaderMap { void setUncommonHeader(const String& name, const String& value); void addUncommonHeader(const String& name, const String& value); void addUncommonHeaderCloneName(const StringView name, const String& value); - -private: WEBCORE_EXPORT String getUncommonHeader(const StringView name) const; +private: CommonHeadersVector m_commonHeaders; UncommonHeadersVector m_uncommonHeaders; Vector m_setCookieHeaders; diff --git a/src/jsc/bindings/webcore/JSBroadcastChannel.cpp b/src/jsc/bindings/webcore/JSBroadcastChannel.cpp index eb6e8a16a834..50643ccacb6b 100644 --- a/src/jsc/bindings/webcore/JSBroadcastChannel.cpp +++ b/src/jsc/bindings/webcore/JSBroadcastChannel.cpp @@ -197,7 +197,7 @@ JSC_DEFINE_HOST_FUNCTION(jsBroadcastChannelPrototype_inspectCustom, (JSC::JSGlob JSValue value = options->get(lexicalGlobalObject, name); RETURN_IF_EXCEPTION(throwScope, {}); - newOptions->putDirect(vm, name, value, 0); + newOptions->putDirectMayBeIndex(lexicalGlobalObject, name, value); RETURN_IF_EXCEPTION(throwScope, {}); } diff --git a/src/jsc/bindings/webcore/JSCookieMap.cpp b/src/jsc/bindings/webcore/JSCookieMap.cpp index ea81609d02e7..d9e1a442cffc 100644 --- a/src/jsc/bindings/webcore/JSCookieMap.cpp +++ b/src/jsc/bindings/webcore/JSCookieMap.cpp @@ -338,7 +338,9 @@ static inline JSC::EncodedJSValue jsCookieMapPrototypeFunction_toSetCookieHeader RETURN_IF_EXCEPTION(throwScope, {}); size_t i = 0; for (auto& item : cookies) { - resultArray->putDirectIndex(lexicalGlobalObject, i, jsString(vm, item->toString(vm))); + auto header = toJS(*lexicalGlobalObject, throwScope, item->toString(vm)); + RETURN_IF_EXCEPTION(throwScope, {}); + resultArray->putDirectIndex(lexicalGlobalObject, i, header); RETURN_IF_EXCEPTION(throwScope, {}); i += 1; } diff --git a/src/jsc/bindings/webcore/JSDOMConvertRecord.h b/src/jsc/bindings/webcore/JSDOMConvertRecord.h index 371f590ccf32..4004f1252fe5 100644 --- a/src/jsc/bindings/webcore/JSDOMConvertRecord.h +++ b/src/jsc/bindings/webcore/JSDOMConvertRecord.h @@ -188,7 +188,10 @@ template struct Converter> : DefaultConv UNUSED_VARIABLE(resultMap); // 5. Otherwise, append to result a mapping (typedKey, typedValue). - result.append({ WTF::move(typedKey), WTF::move(typedValue) }); + if (!result.tryAppend({ WTF::move(typedKey), WTF::move(typedValue) })) [[unlikely]] { + throwTypeError(&lexicalGlobalObject, scope); + return {}; + } } return result; @@ -242,7 +245,10 @@ template struct Converter> : DefaultConv UNUSED_VARIABLE(resultMap); // 5. Otherwise, append to result a mapping (typedKey, typedValue). - result.append({ WTF::move(typedKey), WTF::move(typedValue) }); + if (!result.tryAppend({ WTF::move(typedKey), WTF::move(typedValue) })) [[unlikely]] { + throwTypeError(&lexicalGlobalObject, scope); + return {}; + } } } diff --git a/src/jsc/bindings/webcore/JSDOMConvertSequences.h b/src/jsc/bindings/webcore/JSDOMConvertSequences.h index e64502eace34..1f432157f7c1 100644 --- a/src/jsc/bindings/webcore/JSDOMConvertSequences.h +++ b/src/jsc/bindings/webcore/JSDOMConvertSequences.h @@ -88,11 +88,18 @@ struct SequenceTraits< T&& element) { ASSERT(index == sequence.size()); + bool appended; if constexpr (std::is_same_v, JSC::JSValue>) { // `JSValue` should not be stored on the heap. - sequence.append(JSC::Strong { JSC::getVM(&lexicalGlobalObject), element }); + appended = sequence.tryAppend(JSC::Strong { JSC::getVM(&lexicalGlobalObject), element }); } else { - sequence.append(std::forward(element)); + appended = sequence.tryAppend(std::forward(element)); + } + // reserveExact throws the same error when the Vector can't grow. + if (!appended) [[unlikely]] { + auto& vm = JSC::getVM(&lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + throwTypeError(&lexicalGlobalObject, scope); } } }; @@ -261,6 +268,7 @@ struct SequenceConverter { auto convertedValue = Bun::convertIDL(lexicalGlobalObject, indexValue, elementCtx); RETURN_IF_EXCEPTION(scope, {}); Traits::append(lexicalGlobalObject, result, i, WTF::move(convertedValue)); + RETURN_IF_EXCEPTION(scope, {}); } return result; } @@ -275,6 +283,7 @@ struct SequenceConverter { auto convertedValue = Bun::convertIDL(lexicalGlobalObject, indexValue, elementCtx); RETURN_IF_EXCEPTION(scope, {}); Traits::append(lexicalGlobalObject, result, i, WTF::move(convertedValue)); + RETURN_IF_EXCEPTION(scope, {}); } return result; } diff --git a/src/jsc/bindings/webcore/JSDOMURL.cpp b/src/jsc/bindings/webcore/JSDOMURL.cpp index 422215a96e28..78e14db5ecdb 100644 --- a/src/jsc/bindings/webcore/JSDOMURL.cpp +++ b/src/jsc/bindings/webcore/JSDOMURL.cpp @@ -750,7 +750,7 @@ JSC_DEFINE_HOST_FUNCTION(jsDOMURLPrototypeFunction_inspectCustom, (JSGlobalObjec { auto& vm = JSC::getVM(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); - JSValue thisValue = callFrame->thisValue(); + JSValue thisValue = callFrame->thisValue().toThis(lexicalGlobalObject, JSC::ECMAMode::strict()); auto* thisObject = dynamicDowncast(thisValue); if (!thisObject) [[unlikely]] return JSValue::encode(thisValue); diff --git a/src/jsc/bindings/webcore/JSEventEmitterCustom.cpp b/src/jsc/bindings/webcore/JSEventEmitterCustom.cpp index 84f3d5ee1dc4..aede2d24072a 100644 --- a/src/jsc/bindings/webcore/JSEventEmitterCustom.cpp +++ b/src/jsc/bindings/webcore/JSEventEmitterCustom.cpp @@ -16,6 +16,7 @@ #include "JSDOMConvertSequences.h" #include "JSDOMConvertStrings.h" #include "BunClientData.h" +#include "ObjectBindings.h" namespace WebCore { using namespace JSC; @@ -64,7 +65,8 @@ JSEventEmitter* jsEventEmitterCastFast(VM& vm, JSC::JSGlobalObject* lexicalGloba auto result = toJSNewlyCreated>(*lexicalGlobalObject, *globalObject, throwScope, WTF::move(impl)); RETURN_IF_EXCEPTION(throwScope, nullptr); - thisObject->putDirect(vm, name, result, 0); + Bun::defineOwnDataProperty(lexicalGlobalObject, thisObject, name, result, 0); + RETURN_IF_EXCEPTION(throwScope, nullptr); return uncheckedDowncast(asObject(result)); } diff --git a/src/jsc/bindings/webcore/JSEventListener.cpp b/src/jsc/bindings/webcore/JSEventListener.cpp index c50beca477d0..07b7ba289f7c 100644 --- a/src/jsc/bindings/webcore/JSEventListener.cpp +++ b/src/jsc/bindings/webcore/JSEventListener.cpp @@ -174,7 +174,10 @@ void JSEventListener::handleEvent(ScriptExecutionContext& scriptExecutionContext if (!globalObject) return; - JSGlobalObject* lexicalGlobalObject = jsFunction->globalObject(); + // A WebAssembly GC object doesn't have a realm. + JSGlobalObject* lexicalGlobalObject = jsFunction->realmMayBeNull(); + if (!lexicalGlobalObject) + return; JSValue handleEventFunction = jsFunction; diff --git a/src/jsc/bindings/webcore/JSMIMEParams.cpp b/src/jsc/bindings/webcore/JSMIMEParams.cpp index 11194acdd057..48c98d95a9f8 100644 --- a/src/jsc/bindings/webcore/JSMIMEParams.cpp +++ b/src/jsc/bindings/webcore/JSMIMEParams.cpp @@ -20,6 +20,7 @@ #include "ZigGlobalObject.h" #include "NodeValidator.h" // For Bun::V:: #include "ErrorCode.h" // For Bun::ERR:: +#include "StringSizeLimit.h" #include "JavaScriptCore/JSMapInlines.h" namespace WebCore { @@ -516,7 +517,7 @@ JSC_DEFINE_HOST_FUNCTION(jsMIMEParamsProtoFuncToString, (JSGlobalObject * global } JSMap* map = thisObject->jsMap(); - StringBuilder builder; + StringBuilder builder { OverflowPolicy::RecordOverflow }; bool first = true; JSMapIterator* iterator = JSMapIterator::create(vm, globalObject->mapIteratorStructure(), map, IterationKind::Entries); @@ -548,6 +549,11 @@ JSC_DEFINE_HOST_FUNCTION(jsMIMEParamsProtoFuncToString, (JSGlobalObject * global encodeParamValue(value, builder); } + if (builder.hasOverflowed() || Bun::exceedsStringLimit(builder.length())) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return {}; + } + return JSValue::encode(jsString(vm, builder.toString())); } diff --git a/src/jsc/bindings/webcore/JSMIMEType.cpp b/src/jsc/bindings/webcore/JSMIMEType.cpp index 09d571fbee88..29cf65931b92 100644 --- a/src/jsc/bindings/webcore/JSMIMEType.cpp +++ b/src/jsc/bindings/webcore/JSMIMEType.cpp @@ -18,6 +18,7 @@ #include "ZigGlobalObject.h" #include "NodeValidator.h" // For Bun::V:: #include "ErrorCode.h" // For Bun::ERR:: +#include "StringSizeLimit.h" #include "JavaScriptCore/JSMapInlines.h" namespace WebCore { @@ -426,7 +427,11 @@ JSC_DEFINE_CUSTOM_GETTER(jsMIMETypeProtoGetterEssence, (JSGlobalObject * globalO return {}; } - String essence = makeString(thisObject->type(), '/', thisObject->subtype()); + String essence = tryMakeString(thisObject->type(), '/', thisObject->subtype()); + if (essence.isNull() || Bun::exceedsStringLimit(essence.length())) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return {}; + } return JSValue::encode(jsString(vm, essence)); } @@ -465,7 +470,7 @@ JSC_DEFINE_HOST_FUNCTION(jsMIMETypeProtoFuncToString, (JSGlobalObject * globalOb String paramsStr = paramsStrValue.toWTFString(globalObject); RETURN_IF_EXCEPTION(scope, {}); - StringBuilder builder; + StringBuilder builder { OverflowPolicy::RecordOverflow }; builder.append(thisObject->type()); builder.append('/'); builder.append(thisObject->subtype()); @@ -474,6 +479,11 @@ JSC_DEFINE_HOST_FUNCTION(jsMIMETypeProtoFuncToString, (JSGlobalObject * globalOb builder.append(paramsStr); } + if (builder.hasOverflowed() || Bun::exceedsStringLimit(builder.length())) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return {}; + } + return JSValue::encode(jsString(vm, builder.toString())); } diff --git a/src/jsc/bindings/webcore/JSPerformanceEntry.cpp b/src/jsc/bindings/webcore/JSPerformanceEntry.cpp index 5154ec5e0aa4..ce417deb0a1f 100644 --- a/src/jsc/bindings/webcore/JSPerformanceEntry.cpp +++ b/src/jsc/bindings/webcore/JSPerformanceEntry.cpp @@ -260,7 +260,7 @@ JSC_DEFINE_HOST_FUNCTION(jsPerformanceEntryPrototypeFunction_inspectCustom, (JSG auto& vm = JSC::getVM(lexicalGlobalObject); auto throwScope = DECLARE_THROW_SCOPE(vm); - JSValue thisValue = callFrame->thisValue(); + JSValue thisValue = callFrame->thisValue().toThis(lexicalGlobalObject, JSC::ECMAMode::strict()); double depth = callFrame->argument(0).toNumber(lexicalGlobalObject); RETURN_IF_EXCEPTION(throwScope, {}); auto* entry = dynamicDowncast(thisValue); diff --git a/src/jsc/bindings/webcore/JSTextEncoder.cpp b/src/jsc/bindings/webcore/JSTextEncoder.cpp index 03f855fce874..617dd968cffe 100644 --- a/src/jsc/bindings/webcore/JSTextEncoder.cpp +++ b/src/jsc/bindings/webcore/JSTextEncoder.cpp @@ -53,14 +53,13 @@ #include "JSDOMOperation.h" #include "JSDOMWrapperCache.h" #include "BunClientData.h" +#include "headers-handwritten.h" namespace WebCore { using namespace JSC; extern "C" JSC::EncodedJSValue TextEncoder__encode8(JSC::JSGlobalObject* global, const Latin1Character* stringPtr, size_t stringLen); extern "C" JSC::EncodedJSValue TextEncoder__encode16(JSC::JSGlobalObject* global, const char16_t* stringPtr, size_t stringLen); -extern "C" size_t TextEncoder__encodeInto8(const Latin1Character* stringPtr, size_t stringLen, void* ptr, size_t len); -extern "C" size_t TextEncoder__encodeInto16(const char16_t* stringPtr, size_t stringLen, void* ptr, size_t len); extern "C" JSC::EncodedJSValue TextEncoder__encodeRopeString(JSC::JSGlobalObject* lexicalGlobalObject, JSC::JSString* str); // Functions @@ -280,7 +279,7 @@ static inline JSC::EncodedJSValue jsTextEncoderPrototypeFunction_encodeIntoBody( return {}; } - size_t res = 0; + TextEncoderEncodeIntoResult res = {}; if (!source->is8Bit()) { const auto span = source->span16(); res = TextEncoder__encodeInto16(span.data(), span.size(), destination->vector(), destination->byteLength()); @@ -291,8 +290,14 @@ static inline JSC::EncodedJSValue jsTextEncoderPrototypeFunction_encodeIntoBody( Bun::GlobalScope* globalScope = reinterpret_cast(lexicalGlobalObject); auto* result = JSC::constructEmptyObject(vm, globalScope->encodeIntoObjectStructure()); - result->putDirectOffset(vm, 0, JSC::jsNumber(static_cast(res))); - result->putDirectOffset(vm, 1, JSC::jsNumber(static_cast(res >> 32))); + // The counts only need a double when the destination is 2 GiB or larger. + if ((res.read | res.written) <= static_cast(std::numeric_limits::max())) [[likely]] { + result->putDirectOffset(vm, 0, JSC::jsNumber(static_cast(res.read))); + result->putDirectOffset(vm, 1, JSC::jsNumber(static_cast(res.written))); + } else { + result->putDirectOffset(vm, 0, JSC::jsNumber(res.read)); + result->putDirectOffset(vm, 1, JSC::jsNumber(res.written)); + } return JSValue::encode(result); } diff --git a/src/jsc/bindings/webcore/JSURLSearchParams.cpp b/src/jsc/bindings/webcore/JSURLSearchParams.cpp index 329248f19d48..ba79193600c5 100644 --- a/src/jsc/bindings/webcore/JSURLSearchParams.cpp +++ b/src/jsc/bindings/webcore/JSURLSearchParams.cpp @@ -194,7 +194,7 @@ JSC_DEFINE_HOST_FUNCTION(jsURLSearchParamsPrototypeFunction_inspectCustom, (JSGl { auto& vm = JSC::getVM(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); - JSValue thisValue = callFrame->thisValue(); + JSValue thisValue = callFrame->thisValue().toThis(lexicalGlobalObject, JSC::ECMAMode::strict()); auto* thisObject = dynamicDowncast(thisValue); if (!thisObject) [[unlikely]] return JSValue::encode(thisValue); @@ -231,7 +231,8 @@ JSC_DEFINE_HOST_FUNCTION(jsURLSearchParamsPrototypeFunction_inspectCustom, (JSGl for (size_t i = 0; i < names.size(); ++i) { JSValue v = options->get(lexicalGlobalObject, names[i]); RETURN_IF_EXCEPTION(scope, {}); - opts->putDirect(vm, names[i], v, 0); + opts->putDirectMayBeIndex(lexicalGlobalObject, names[i], v); + RETURN_IF_EXCEPTION(scope, {}); } if (!depthValue.isUndefinedOrNull()) childDepth = jsNumber(depth - 1); diff --git a/src/jsc/bindings/webcore/JSWorker.cpp b/src/jsc/bindings/webcore/JSWorker.cpp index f07f75660f12..bffa96184423 100644 --- a/src/jsc/bindings/webcore/JSWorker.cpp +++ b/src/jsc/bindings/webcore/JSWorker.cpp @@ -276,8 +276,9 @@ template<> __attribute__((minsize)) JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES if (envValue && envValue.isCell()) { envObject = dynamicDowncast(envValue); - } else if (globalObject->m_processEnvObject.isInitialized()) { - envObject = globalObject->processEnvObject(); + } else { + // Null if nothing has read process.env yet, in which case there's nothing to copy. + envObject = globalObject->m_processEnvObject.get(); } if (envObject) { diff --git a/src/jsc/bindings/webcore/MessagePort.cpp b/src/jsc/bindings/webcore/MessagePort.cpp index 3610608050c7..eb62c0f244c9 100644 --- a/src/jsc/bindings/webcore/MessagePort.cpp +++ b/src/jsc/bindings/webcore/MessagePort.cpp @@ -407,6 +407,8 @@ void MessagePort::contextDestroyed() { ASSERT(scriptExecutionContext()); + // close() can drop the last reference if no stop phase ran first, e.g. for a collected ShadowRealm. + Ref protectedThis { *this }; close(); ActiveDOMObject::contextDestroyed(); } diff --git a/src/jsc/bindings/webcore/PerformanceUserTiming.cpp b/src/jsc/bindings/webcore/PerformanceUserTiming.cpp index fb5fccbb6560..c22f27472ae0 100644 --- a/src/jsc/bindings/webcore/PerformanceUserTiming.cpp +++ b/src/jsc/bindings/webcore/PerformanceUserTiming.cpp @@ -63,12 +63,13 @@ size_t PerformanceUserTiming::memoryCost() const return size; } -static void clearPerformanceEntries(PerformanceEntryMap& map, const String& name) +static void clearPerformanceEntries(PerformanceEntryMap& map, const String& name, int64_t& counter) { - if (name.isNull()) + if (name.isNull()) { map.clear(); - else - map.remove(name); + counter = 0; + } else + counter -= static_cast(map.take(name).size()); } static void addPerformanceEntry(PerformanceEntryMap& map, const String& name, PerformanceEntry& entry) @@ -98,8 +99,7 @@ ExceptionOr> PerformanceUserTiming::mark(JSC::JSGlobalObjec void PerformanceUserTiming::clearMarks(const String& markName) { - clearPerformanceEntries(m_marksMap, markName); - m_markCounter = 0; + clearPerformanceEntries(m_marksMap, markName, m_markCounter); } ExceptionOr PerformanceUserTiming::convertMarkToTimestamp(const std::variant& mark) const @@ -267,8 +267,7 @@ ExceptionOr> PerformanceUserTiming::measure(JSC::JSGloba void PerformanceUserTiming::clearMeasures(const String& measureName) { - clearPerformanceEntries(m_measuresMap, measureName); - m_measureCounter = 0; + clearPerformanceEntries(m_measuresMap, measureName, m_measureCounter); } static Vector> convertToEntrySequence(const PerformanceEntryMap& map, int64_t initialCapacity) diff --git a/src/jsc/bindings/webcore/SerializedScriptValue.cpp b/src/jsc/bindings/webcore/SerializedScriptValue.cpp index 81bd5650dca1..57e3c710c262 100644 --- a/src/jsc/bindings/webcore/SerializedScriptValue.cpp +++ b/src/jsc/bindings/webcore/SerializedScriptValue.cpp @@ -1971,13 +1971,18 @@ class CloneSerializer : public CloneBase { write(*downcast(*key).exportData()); break; } - case CryptoKeyClass::OKP: + case CryptoKeyClass::OKP: { + auto& okpKey = downcast(*key); write(CryptoKeyClassSubtag::OKP); write(key->algorithmIdentifier()); - write(downcast(*key).namedCurve()); - write(downcast(*key).platformKey()); + write(okpKey.namedCurve()); + // X25519 public and private keys are both 32 bytes, so write which one this is. + if (okpKey.namedCurve() == CryptoKeyOKP::NamedCurve::X25519) + write(key->type() == CryptoKey::Type::Public ? CryptoKeyAsymmetricTypeSubtag::Public : CryptoKeyAsymmetricTypeSubtag::Private); + write(okpKey.platformKey()); break; } + } } #endif ObjectPool m_objectPool; @@ -3201,8 +3206,6 @@ class CloneDeserializer : public CloneBase { CryptoAlgorithmIdentifier algorithm; if (!read(algorithm)) return false; - if (!CryptoKeyOKP::isValidOKPAlgorithm(algorithm)) - return false; CryptoKeyOKP::NamedCurve namedCurve; if (!read(namedCurve)) return false; @@ -3211,10 +3214,19 @@ class CloneDeserializer : public CloneBase { if (algorithm != CryptoAlgorithmIdentifier::Ed25519) return false; break; - case CryptoKeyOKP::NamedCurve::X25519: + case CryptoKeyOKP::NamedCurve::X25519: { if (algorithm != CryptoAlgorithmIdentifier::X25519) return false; - break; + // Records from Bun <= 1.4.3 don't have a type subtag, so they fail its range check. No release could read them anyway. + CryptoKeyAsymmetricTypeSubtag type; + if (!read(type)) + return false; + Vector keyData; + if (!read(keyData)) + return false; + result = CryptoKeyOKP::create(algorithm, namedCurve, type == CryptoKeyAsymmetricTypeSubtag::Public ? CryptoKeyType::Public : CryptoKeyType::Private, WTF::move(keyData), extractable, usages); + return !!result; + } } Vector keyData; if (!read(keyData)) @@ -3656,7 +3668,8 @@ class CloneDeserializer : public CloneBase { double d; if (!read(d)) return JSValue(); - DateInstance* obj = DateInstance::create(m_lexicalGlobalObject->vm(), m_globalObject->dateStructure(), d); + // timeClip() preserves a NaN's payload and getTime() boxes the number as-is, so purify it here. + DateInstance* obj = DateInstance::create(m_lexicalGlobalObject->vm(), m_globalObject->dateStructure(), purifyNaN(d)); addTerminalToObjectPool(obj); return obj; } @@ -4271,8 +4284,11 @@ size_t SerializedScriptValue::computeMemoryCost() const #if ENABLE(WEBASSEMBLY) // We are not supporting WebAssembly Module memory estimation yet. if (m_wasmMemoryHandlesArray) { - for (auto& content : *m_wasmMemoryHandlesArray) - cost += content->sizeInBytes(std::memory_order_relaxed); + // A shared memory with a maximum of zero has null contents. + for (auto& content : *m_wasmMemoryHandlesArray) { + if (content) + cost += content->sizeInBytes(std::memory_order_relaxed); + } } #endif @@ -4337,6 +4353,9 @@ size_t SerializedScriptValue::computeMemoryCost() const static void markObjectWithPrivateName(VM& vm, JSObject& object, const Identifier& privateName) { + // A WebAssembly GC object's Structure can't transition, and these objects aren't cloneable or transferable anyway. + if (object.type() == WebAssemblyGCObjectType) + return; if (object.getDirect(vm, privateName)) return; object.putDirect(vm, privateName, jsBoolean(true), PropertyAttribute::ReadOnly | PropertyAttribute::DontEnum | PropertyAttribute::DontDelete | 0); diff --git a/src/jsc/bindings/webcore/URLPattern.cpp b/src/jsc/bindings/webcore/URLPattern.cpp index e6ca32fff62e..7437a5c44330 100644 --- a/src/jsc/bindings/webcore/URLPattern.cpp +++ b/src/jsc/bindings/webcore/URLPattern.cpp @@ -45,12 +45,17 @@ namespace WebCore { using namespace JSC; // https://urlpattern.spec.whatwg.org/#process-a-base-url-string -static String processBaseURLString(StringView input, BaseURLStringType type) +static String processBaseURLString(StringView input, BaseURLStringType type, bool& outOfMemory) { if (type != BaseURLStringType::Pattern) return input.toString(); - return URLPatternUtilities::escapePatternString(input); + auto escaped = URLPatternUtilities::escapePatternString(input); + if (escaped.hasException()) [[unlikely]] { + outOfMemory = true; + return {}; + } + return escaped.releaseReturnValue(); } // https://urlpattern.spec.whatwg.org/#hostname-pattern-is-an-ipv6-address @@ -75,6 +80,7 @@ static ExceptionOr processInit(URLPatternInit&& init, BaseURLStr URLPatternInit result { WTF::move(protocol), WTF::move(username), WTF::move(password), WTF::move(hostname), WTF::move(port), WTF::move(pathname), WTF::move(search), WTF::move(hash), {} }; URL baseURL; + bool outOfMemory = false; if (!init.baseURL.isNull()) { baseURL = URL(init.baseURL); @@ -83,14 +89,14 @@ static ExceptionOr processInit(URLPatternInit&& init, BaseURLStr return Exception { ExceptionCode::TypeError, "Invalid baseURL."_s }; if (init.protocol.isNull()) - result.protocol = processBaseURLString(baseURL.protocol(), type); + result.protocol = processBaseURLString(baseURL.protocol(), type, outOfMemory); if (type != BaseURLStringType::Pattern && init.protocol.isNull() && init.hostname.isNull() && init.port.isNull() && init.username.isNull()) - result.username = processBaseURLString(baseURL.user(), type); + result.username = processBaseURLString(baseURL.user(), type, outOfMemory); if (type != BaseURLStringType::Pattern && init.protocol.isNull() @@ -98,11 +104,11 @@ static ExceptionOr processInit(URLPatternInit&& init, BaseURLStr && init.port.isNull() && init.username.isNull() && init.password.isNull()) - result.password = processBaseURLString(baseURL.password(), type); + result.password = processBaseURLString(baseURL.password(), type, outOfMemory); if (init.protocol.isNull() && init.hostname.isNull()) { - result.hostname = processBaseURLString(!baseURL.host().isNull() ? baseURL.host() : StringView { emptyString() }, type); + result.hostname = processBaseURLString(!baseURL.host().isNull() ? baseURL.host() : StringView { emptyString() }, type, outOfMemory); } if (init.protocol.isNull() @@ -116,7 +122,7 @@ static ExceptionOr processInit(URLPatternInit&& init, BaseURLStr && init.hostname.isNull() && init.port.isNull() && init.pathname.isNull()) { - result.pathname = processBaseURLString(baseURL.path(), type); + result.pathname = processBaseURLString(baseURL.path(), type, outOfMemory); } if (init.protocol.isNull() @@ -124,7 +130,7 @@ static ExceptionOr processInit(URLPatternInit&& init, BaseURLStr && init.port.isNull() && init.pathname.isNull() && init.search.isNull()) { - result.search = processBaseURLString(baseURL.hasQuery() ? baseURL.query() : StringView { emptyString() }, type); + result.search = processBaseURLString(baseURL.hasQuery() ? baseURL.query() : StringView { emptyString() }, type, outOfMemory); } if (init.protocol.isNull() @@ -133,8 +139,11 @@ static ExceptionOr processInit(URLPatternInit&& init, BaseURLStr && init.pathname.isNull() && init.search.isNull() && init.hash.isNull()) { - result.hash = processBaseURLString(baseURL.hasFragmentIdentifier() ? baseURL.fragmentIdentifier() : StringView { emptyString() }, type); + result.hash = processBaseURLString(baseURL.hasFragmentIdentifier() ? baseURL.fragmentIdentifier() : StringView { emptyString() }, type, outOfMemory); } + + if (outOfMemory) [[unlikely]] + return Exception { ExceptionCode::OutOfMemoryError }; } if (!init.protocol.isNull()) { @@ -174,11 +183,14 @@ static ExceptionOr processInit(URLPatternInit&& init, BaseURLStr result.pathname = init.pathname; if (!baseURL.isNull() && !baseURL.hasOpaquePath() && !isAbsolutePathname(result.pathname, type)) { - auto baseURLPath = processBaseURLString(baseURL.path(), type); + auto baseURLPath = processBaseURLString(baseURL.path(), type, outOfMemory); size_t slashIndex = baseURLPath.reverseFind('/'); if (slashIndex != notFound) - result.pathname = makeString(StringView { baseURLPath }.left(slashIndex + 1), result.pathname); + result.pathname = tryMakeString(StringView { baseURLPath }.left(slashIndex + 1), result.pathname); + + if (outOfMemory || result.pathname.isNull()) [[unlikely]] + return Exception { ExceptionCode::OutOfMemoryError }; } auto pathResult = processPathname(result.pathname, result.protocol, type); diff --git a/src/jsc/bindings/webcore/URLPatternCanonical.cpp b/src/jsc/bindings/webcore/URLPatternCanonical.cpp index 35a2f56f7c59..65eba1c7d323 100644 --- a/src/jsc/bindings/webcore/URLPatternCanonical.cpp +++ b/src/jsc/bindings/webcore/URLPatternCanonical.cpp @@ -29,6 +29,7 @@ #include "ExceptionOr.h" #include "URLDecomposition.h" #include "URLPattern.h" +#include "StringSizeLimit.h" #include #include #include @@ -78,7 +79,12 @@ ExceptionOr canonicalizeProtocol(StringView value, BaseURLStringType val if (valueType == BaseURLStringType::Pattern) return strippedValue.toString(); - URL dummyURL(makeString(strippedValue, "://w/"_s)); + auto dummyURLString = tryMakeString(strippedValue, "://w/"_s); + + if (dummyURLString.isNull() || Bun::exceedsStringLimit(dummyURLString.length())) [[unlikely]] + return Exception { ExceptionCode::OutOfMemoryError }; + + URL dummyURL(WTF::move(dummyURLString)); if (!dummyURL.isValid()) return Exception { ExceptionCode::TypeError, "Invalid input to canonicalize a URL protocol string."_s }; @@ -177,7 +183,12 @@ ExceptionOr canonicalizeOpaquePathname(StringView value) if (value.isEmpty()) return value.toString(); - URL dummyURL(makeString("a:"_s, value)); + auto dummyURLString = tryMakeString("a:"_s, value); + + if (dummyURLString.isNull() || Bun::exceedsStringLimit(dummyURLString.length())) [[unlikely]] + return Exception { ExceptionCode::OutOfMemoryError }; + + URL dummyURL(WTF::move(dummyURLString)); if (!dummyURL.isValid()) return Exception { ExceptionCode::TypeError, "Invalid input to canonicalize a URL opaque path string."_s }; @@ -192,7 +203,10 @@ ExceptionOr canonicalizePathname(StringView pathnameValue) return pathnameValue.toString(); bool hasLeadingSlash = pathnameValue[0] == '/'; - String maybeAddSlashPrefix = hasLeadingSlash ? pathnameValue.toString() : makeString("/-"_s, pathnameValue); + String maybeAddSlashPrefix = hasLeadingSlash ? pathnameValue.toString() : tryMakeString("/-"_s, pathnameValue); + + if (maybeAddSlashPrefix.isNull() || Bun::exceedsStringLimit(maybeAddSlashPrefix.length())) [[unlikely]] + return Exception { ExceptionCode::OutOfMemoryError }; // FIXME: Set state override to State::PathStart after URLParser supports state override. URL dummyURL(dummyURLCharacters); diff --git a/src/jsc/bindings/webcore/URLPatternComponent.cpp b/src/jsc/bindings/webcore/URLPatternComponent.cpp index ea5f03546562..3fe2688af6f9 100644 --- a/src/jsc/bindings/webcore/URLPatternComponent.cpp +++ b/src/jsc/bindings/webcore/URLPatternComponent.cpp @@ -53,7 +53,10 @@ ExceptionOr URLPatternComponent::compile(Ref vm, S return maybePartList.releaseException(); Vector partList = maybePartList.releaseReturnValue(); - auto [regularExpressionString, nameList] = generateRegexAndNameList(partList, options); + auto maybeRegexAndNameList = generateRegexAndNameList(partList, options); + if (maybeRegexAndNameList.hasException()) + return maybeRegexAndNameList.releaseException(); + auto [regularExpressionString, nameList] = maybeRegexAndNameList.releaseReturnValue(); OptionSet flags = { JSC::Yarr::Flags::UnicodeSets }; if (options.ignoreCase) @@ -63,7 +66,10 @@ ExceptionOr URLPatternComponent::compile(Ref vm, S if (!regularExpression->isValid()) return Exception { ExceptionCode::TypeError, "Unable to create RegExp object regular expression from provided URLPattern string."_s }; - String patternString = generatePatternString(partList, options); + auto maybePatternString = generatePatternString(partList, options); + if (maybePatternString.hasException()) + return maybePatternString.releaseException(); + String patternString = maybePatternString.releaseReturnValue(); bool hasRegexGroups = partList.containsIf([](auto& part) { return part.type == PartType::Regexp; diff --git a/src/jsc/bindings/webcore/URLPatternParser.cpp b/src/jsc/bindings/webcore/URLPatternParser.cpp index ff45aa4da4d7..fc757894708c 100644 --- a/src/jsc/bindings/webcore/URLPatternParser.cpp +++ b/src/jsc/bindings/webcore/URLPatternParser.cpp @@ -27,6 +27,7 @@ #include "URLPatternParser.h" #include "ExceptionOr.h" +#include "StringSizeLimit.h" #include "URLPatternCanonical.h" #include "URLPatternTokenizer.h" #include @@ -306,39 +307,54 @@ ExceptionOr> URLPatternParser::parse(StringView patternStringInput, return tokenParser.takePartList(); } -// https://urlpattern.spec.whatwg.org/#generate-a-segment-wildcard-regexp -String generateSegmentWildcardRegexp(const URLPatternStringOptions& options) -{ - return makeString("[^"_s, escapeRegexString(options.delimiterCodepoint), "]+?"_s); -} - -template -static String escapeRegexStringForCharacters(std::span characters) +template +static void appendWithBackslashEscapes(StringBuilder& result, std::span characters, const std::array& escapeSet) { - static constexpr auto regexEscapeCharacters = std::to_array({ '.', '+', '*', '?', '^', '$', '{', '}', '(', ')', '[', ']', '|', '/', '\\' }); // NOLINT + if (result.hasOverflowed()) [[unlikely]] + return; - StringBuilder result; - result.reserveCapacity(characters.size()); + if (result.isEmpty()) + result.reserveCapacity(static_cast(characters.size())); for (auto character : characters) { - if (std::ranges::find(regexEscapeCharacters, character) != regexEscapeCharacters.end()) + if (result.hasOverflowed()) [[unlikely]] + return; + + if (std::ranges::find(escapeSet, character) != escapeSet.end()) result.append('\\'); result.append(character); } +} - return result.toString(); +template +static void appendEscapedRegexStringForCharacters(StringBuilder& result, std::span characters) +{ + static constexpr auto regexEscapeCharacters = std::to_array({ '.', '+', '*', '?', '^', '$', '{', '}', '(', ')', '[', ']', '|', '/', '\\' }); // NOLINT + + appendWithBackslashEscapes(result, characters, regexEscapeCharacters); } // https://urlpattern.spec.whatwg.org/#escape-a-regexp-string -String escapeRegexString(StringView input) +static void appendEscapedRegexString(StringBuilder& result, StringView input) { // FIXME: Ensure input only contains ASCII based on spec after the parser (or tokenizer) knows to filter non-ASCII input. if (input.is8Bit()) - return escapeRegexStringForCharacters(input.span8()); + appendEscapedRegexStringForCharacters(result, input.span8()); + else + appendEscapedRegexStringForCharacters(result, input.span16()); +} - return escapeRegexStringForCharacters(input.span16()); +// https://urlpattern.spec.whatwg.org/#generate-a-segment-wildcard-regexp +String generateSegmentWildcardRegexp(const URLPatternStringOptions& options) +{ + StringBuilder result; + result.append("[^"_s); + appendEscapedRegexString(result, options.delimiterCodepoint); + result.append("]+?"_s); + + return result.toString(); } // https://urlpattern.spec.whatwg.org/#convert-a-modifier-to-a-string @@ -357,9 +373,9 @@ ASCIILiteral convertModifierToString(Modifier modifier) } // https://urlpattern.spec.whatwg.org/#generate-a-regular-expression-and-name-list -std::pair> generateRegexAndNameList(const Vector& partList, const URLPatternStringOptions& options) +ExceptionOr>> generateRegexAndNameList(const Vector& partList, const URLPatternStringOptions& options) { - StringBuilder result; + StringBuilder result { OverflowPolicy::RecordOverflow }; result.append('^'); Vector nameList; @@ -367,9 +383,12 @@ std::pair> generateRegexAndNameList(const Vector& p for (auto& part : partList) { if (part.type == PartType::FixedText) { if (part.modifier == Modifier::None) - result.append(escapeRegexString(part.value)); - else - result.append("(?:"_s, escapeRegexString(part.value), ')', convertModifierToString(part.modifier)); + appendEscapedRegexString(result, part.value); + else { + result.append("(?:"_s); + appendEscapedRegexString(result, part.value); + result.append(')', convertModifierToString(part.modifier)); + } continue; } @@ -397,7 +416,11 @@ std::pair> generateRegexAndNameList(const Vector& p } if (part.modifier == Modifier::None || part.modifier == Modifier::Optional) { - result.append("(?:"_s, escapeRegexString(part.prefix), '(', regexpValue, ')', escapeRegexString(part.suffix), ')', convertModifierToString(part.modifier)); + result.append("(?:"_s); + appendEscapedRegexString(result, part.prefix); + result.append('(', regexpValue, ')'); + appendEscapedRegexString(result, part.suffix); + result.append(')', convertModifierToString(part.modifier)); continue; } @@ -405,18 +428,14 @@ std::pair> generateRegexAndNameList(const Vector& p ASSERT(part.modifier == Modifier::ZeroOrMore || part.modifier == Modifier::OneOrMore); ASSERT(!part.prefix.isEmpty() || !part.suffix.isEmpty()); - result.append("(?:"_s, - escapeRegexString(part.prefix), - "((?:"_s, - regexpValue, - ")(?:"_s, - escapeRegexString(part.suffix), - escapeRegexString(part.prefix), - "(?:"_s, - regexpValue, - "))*)"_s, - escapeRegexString(part.suffix), - ')'); + result.append("(?:"_s); + appendEscapedRegexString(result, part.prefix); + result.append("((?:"_s, regexpValue, ")(?:"_s); + appendEscapedRegexString(result, part.suffix); + appendEscapedRegexString(result, part.prefix); + result.append("(?:"_s, regexpValue, "))*)"_s); + appendEscapedRegexString(result, part.suffix); + result.append(')'); if (part.modifier == Modifier::ZeroOrMore) result.append('?'); @@ -424,13 +443,18 @@ std::pair> generateRegexAndNameList(const Vector& p result.append('$'); - return { result.toString(), WTF::move(nameList) }; + if (result.hasOverflowed() || Bun::exceedsStringLimit(result.length())) [[unlikely]] + return Exception { ExceptionCode::OutOfMemoryError }; + + return std::pair { String { result.toString() }, WTF::move(nameList) }; } +static void appendEscapedPatternString(StringBuilder&, StringView); + // https://urlpattern.spec.whatwg.org/#generate-a-pattern-string -String generatePatternString(const Vector& partList, const URLPatternStringOptions& options) +ExceptionOr generatePatternString(const Vector& partList, const URLPatternStringOptions& options) { - StringBuilder result; + StringBuilder result { OverflowPolicy::RecordOverflow }; for (size_t index = 0; index < partList.size(); ++index) { auto& part = partList[index]; @@ -445,11 +469,13 @@ String generatePatternString(const Vector& partList, const URLPatternStrin if (part.type == PartType::FixedText) { if (part.modifier == Modifier::None) { - result.append(escapePatternString(part.value)); + appendEscapedPatternString(result, part.value); continue; } - result.append('{', escapePatternString(part.value), '}', convertModifierToString(part.modifier)); + result.append('{'); + appendEscapedPatternString(result, part.value); + result.append('}', convertModifierToString(part.modifier)); continue; } @@ -479,7 +505,7 @@ String generatePatternString(const Vector& partList, const URLPatternStrin if (needsGrouping) result.append('{'); - result.append(escapePatternString(part.prefix)); + appendEscapedPatternString(result, part.prefix); if (hasCustomName) result.append(':', part.name); @@ -500,7 +526,7 @@ String generatePatternString(const Vector& partList, const URLPatternStrin if (part.type == PartType::SegmentWildcard && hasCustomName && !part.suffix.isEmpty() && isValidNameCodepoint(*StringView(part.suffix).codePoints().begin(), IsFirst::Yes)) result.append('\\'); - result.append(escapePatternString(part.suffix)); + appendEscapedPatternString(result, part.suffix); if (needsGrouping) result.append('}'); @@ -508,36 +534,40 @@ String generatePatternString(const Vector& partList, const URLPatternStrin result.append(convertModifierToString(part.modifier)); } - return result.toString(); + if (result.hasOverflowed() || Bun::exceedsStringLimit(result.length())) [[unlikely]] + return Exception { ExceptionCode::OutOfMemoryError }; + + return String { result.toString() }; } template -static String escapePatternStringForCharacters(std::span characters) +static void appendEscapedPatternStringForCharacters(StringBuilder& result, std::span characters) { static constexpr auto escapeCharacters = std::to_array({ '+', '*', '?', ':', '(', ')', '\\', '{', '}' }); // NOLINT - StringBuilder result; - result.reserveCapacity(characters.size()); - - for (auto character : characters) { - if (std::ranges::find(escapeCharacters, character) != escapeCharacters.end()) - result.append('\\'); - - result.append(character); - } - - return result.toString(); + appendWithBackslashEscapes(result, characters, escapeCharacters); } // https://urlpattern.spec.whatwg.org/#escape-a-pattern-string -String escapePatternString(StringView input) +static void appendEscapedPatternString(StringBuilder& result, StringView input) { // FIXME: Ensure input only contains ASCII based on spec after the parser (or tokenizer) knows to filter non-ASCII input. if (input.is8Bit()) - return escapePatternStringForCharacters(input.span8()); + appendEscapedPatternStringForCharacters(result, input.span8()); + else + appendEscapedPatternStringForCharacters(result, input.span16()); +} + +ExceptionOr escapePatternString(StringView input) +{ + StringBuilder result { OverflowPolicy::RecordOverflow }; + appendEscapedPatternString(result, input); + + if (result.hasOverflowed() || Bun::exceedsStringLimit(result.length())) [[unlikely]] + return Exception { ExceptionCode::OutOfMemoryError }; - return escapePatternStringForCharacters(input.span16()); + return String { result.toString() }; } // https://urlpattern.spec.whatwg.org/#is-a-valid-name-code-point diff --git a/src/jsc/bindings/webcore/URLPatternParser.h b/src/jsc/bindings/webcore/URLPatternParser.h index a06b56db525e..6f9406247bb5 100644 --- a/src/jsc/bindings/webcore/URLPatternParser.h +++ b/src/jsc/bindings/webcore/URLPatternParser.h @@ -99,11 +99,11 @@ class URLPatternParser { // FIXME: Consider moving functions to somewhere generic, perhaps refactor Part to its own class. String generateSegmentWildcardRegexp(const URLPatternStringOptions&); -String escapeRegexString(StringView); ASCIILiteral convertModifierToString(Modifier); -std::pair> generateRegexAndNameList(const Vector& partList, const URLPatternStringOptions&); -String generatePatternString(const Vector& partList, const URLPatternStringOptions&); -String escapePatternString(StringView input); +// These three return OutOfMemoryError if the result exceeds the max string length. +ExceptionOr>> generateRegexAndNameList(const Vector& partList, const URLPatternStringOptions&); +ExceptionOr generatePatternString(const Vector& partList, const URLPatternStringOptions&); +ExceptionOr escapePatternString(StringView input); bool isValidNameCodepoint(char32_t codepoint, URLPatternUtilities::IsFirst); } // namespace URLPatternUtilities diff --git a/src/jsc/bindings/webcore/URLPatternTokenizer.cpp b/src/jsc/bindings/webcore/URLPatternTokenizer.cpp index 4c8d87700c7e..efe491360155 100644 --- a/src/jsc/bindings/webcore/URLPatternTokenizer.cpp +++ b/src/jsc/bindings/webcore/URLPatternTokenizer.cpp @@ -28,6 +28,7 @@ #include "ExceptionOr.h" #include "URLPatternParser.h" +#include "VectorSizeLimit.h" #include #include @@ -69,8 +70,10 @@ void Tokenizer::seekNextCodePoint(size_t index) // https://urlpattern.spec.whatwg.org/#add-a-token void Tokenizer::addToken(TokenType currentType, size_t nextPosition, size_t valuePosition, size_t valueLength) { - m_tokenList.append(Token { currentType, m_index, m_input.substring(valuePosition, valueLength) }); - m_index = nextPosition; + if (m_tokenList.size() >= Bun::maxVectorSize() || !m_tokenList.tryAppend(Token { currentType, m_index, m_input.substring(valuePosition, valueLength) })) + m_tokenAppendFailure = true; + else + m_index = nextPosition; } // https://urlpattern.spec.whatwg.org/#add-a-token-with-default-length @@ -109,7 +112,7 @@ ExceptionOr> Tokenizer::tokenize() { ExceptionOr maybeException; - while (m_index < m_input.length()) { + while (m_index < m_input.length() && !m_tokenAppendFailure) { if (m_policy == TokenizePolicy::Strict && maybeException.hasException()) return maybeException.releaseException(); @@ -266,6 +269,10 @@ ExceptionOr> Tokenizer::tokenize() } addToken(TokenType::End, m_index, m_index); + + if (m_tokenAppendFailure) + return Exception { ExceptionCode::TypeError, "URLPattern constructor: Failed to create URLPattern (from input string)"_s }; + return WTF::move(m_tokenList); } diff --git a/src/jsc/bindings/webcore/URLPatternTokenizer.h b/src/jsc/bindings/webcore/URLPatternTokenizer.h index fb5a79061687..576b5153883f 100644 --- a/src/jsc/bindings/webcore/URLPatternTokenizer.h +++ b/src/jsc/bindings/webcore/URLPatternTokenizer.h @@ -67,6 +67,7 @@ class Tokenizer { size_t m_index { 0 }; size_t m_nextIndex { 0 }; char32_t m_codepoint; + bool m_tokenAppendFailure { false }; void getNextCodePoint(); void seekNextCodePoint(size_t index); diff --git a/src/jsc/bindings/webcore/Worker.cpp b/src/jsc/bindings/webcore/Worker.cpp index 15b0b9ff83e8..3922915cf2ae 100644 --- a/src/jsc/bindings/webcore/Worker.cpp +++ b/src/jsc/bindings/webcore/Worker.cpp @@ -299,7 +299,6 @@ JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject) auto* proxy = WebWorker__getMessagingProxy(globalObject->bunVM()); if (proxy) { auto& options = proxy->options(); - auto ports = MessagePort::entanglePorts(*globalObject->scriptExecutionContext(), WTF::move(options.dataMessagePorts)); RefPtr serialized = WTF::move(options.workerDataAndEnvironmentData); // `workerDataAndEnvironmentData` is moved-from on the first call. If // this binding is created twice (lazy-init re-entry), `serialized` is @@ -308,6 +307,7 @@ JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject) // deserialize on second call (workerData stays jsUndefined), and use a // checked cast so a non-Array deserialize result doesn't trap. if (serialized) { + auto ports = MessagePort::entanglePorts(*globalObject->scriptExecutionContext(), WTF::move(options.dataMessagePorts)); JSValue deserialized = serialized->deserialize(*globalObject, globalObject, WTF::move(ports)); RETURN_IF_EXCEPTION(scope, {}); // Should always be set to an Array of length 2 in the constructor in JSWorker.cpp diff --git a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp index fee6c0d4f0b6..e578ade0c3f9 100644 --- a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp +++ b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp @@ -36,6 +36,7 @@ #include "MessageEvent.h" #include "MessagePort.h" #include "SerializedScriptValue.h" +#include "VectorSizeLimit.h" #include "Worker.h" #include "ZigGlobalObject.h" #include @@ -123,7 +124,8 @@ ExceptionOr WorkerMessagingProxy::startWorkerGlobalScope(const String& scr } Vector preloadModules; - preloadModules.reserveInitialCapacity(m_options.preloadModules.size()); + if (!Bun::tryReserveCapacityWithinLimit(preloadModules, m_options.preloadModules.size())) [[unlikely]] + return Exception { OutOfMemoryError }; for (auto& str : m_options.preloadModules) { if (str.startsWith("file://"_s)) { WTF::URL urlObject = WTF::URL(str); diff --git a/src/jsc/bindings/webcore/streams/BunStandaloneTextSink.h b/src/jsc/bindings/webcore/streams/BunStandaloneTextSink.h index fbd24d1ccdcf..7a2e04544d19 100644 --- a/src/jsc/bindings/webcore/streams/BunStandaloneTextSink.h +++ b/src/jsc/bindings/webcore/streams/BunStandaloneTextSink.h @@ -11,6 +11,7 @@ #include "root.h" #include "StreamsForward.h" #include "VectorSizeLimit.h" +#include "WebStreamsInternals.h" #include #include @@ -30,16 +31,45 @@ namespace WebStreams { // inside its ONE `Locker { cellLock() }` scope and proves that with the AbstractLocker // parameter (cellLock() is non-recursive — see StreamQueue.h's discipline comment). struct BunTextAccumulator { - // the pure-string fast-path rope. RecordOverflow: an append past - // StringImpl::MaxLength must surface as a catchable out-of-memory error at the - // write site, never as the default policy's process abort. - WTF::StringBuilder rope { WTF::OverflowPolicy::RecordOverflow }; // string + typed-array-view pieces (the mixed path). WTF::Vector> pieces; double estimatedLength { 0 }; bool hasString { false }; bool hasBuffer { false }; + // Returns false if the chunk couldn't be appended. The caller must throw. + ALWAYS_INLINE bool tryAppendString(const WTF::String& chunk) + { + if (m_rope.hasOverflowed() || exceedsStringLimit(static_cast(m_rope.length()) + chunk.length())) [[unlikely]] + return false; + m_rope.append(chunk); + if (m_rope.hasOverflowed()) [[unlikely]] { + // The builder may keep the buffer it failed to grow. Nothing can read it anymore, so free it. + m_rope.clear(); + m_rope.didOverflow(); + return false; + } + hasString = true; + estimatedLength += chunk.length(); + return true; + } + + // True if any string chunks arrived after the last binary chunk. Also true for a rope that overflowed. + bool hasRope() const { return !m_rope.isEmpty(); } + // Returns null if the rope overflowed. The caller must throw. + WTF::String tryRopeString() + { + if (m_rope.hasOverflowed()) [[unlikely]] + return {}; + // toString() shrinks the buffer first and asserts if that overflows, so shrink it here and check. + if (m_rope.capacity() != m_rope.length()) { + m_rope.shrinkToFit(); + if (m_rope.hasOverflowed()) [[unlikely]] + return {}; + } + return m_rope.toString(); + } + // Script adds a piece per write(), so growth is fallible. On false the caller throws after it drops the lock. bool tryAppendPieces(const WTF::AbstractLocker&, JSC::VM& vm, JSC::JSCell* owner, JSC::JSString* flushedRope, JSC::JSValue chunk) { @@ -47,7 +77,7 @@ struct BunTextAccumulator { if (flushedRope) { if (pieces.size() >= Bun::maxVectorSize() || !pieces.tryAppend(Piece(vm, owner, flushedRope))) [[unlikely]] return false; - rope.clear(); + m_rope.clear(); } return pieces.size() < Bun::maxVectorSize() && pieces.tryAppend(Piece(vm, owner, chunk)); } @@ -60,7 +90,7 @@ struct BunTextAccumulator { { pieces.clear(); pieces.shrinkToFit(); - rope.clear(); + m_rope.clear(); estimatedLength = 0; hasString = false; hasBuffer = false; @@ -85,6 +115,10 @@ struct BunTextAccumulator { analyzer.analyzeIndexEdge(from, v.asCell(), i); } } + +private: + // An overflowed builder has lost its text, and its length() and toString() assert, so check hasOverflowed() first. + WTF::StringBuilder m_rope { WTF::OverflowPolicy::RecordOverflow }; }; } // namespace WebStreams diff --git a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp index f510e1a76142..782980b46cdc 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp @@ -540,6 +540,23 @@ static JSValue convertChunksToBytes(JSGlobalObject* globalObject, JSValue chunks static JSValue textAccumulatorWrite(JSC::VM& vm, JSGlobalObject*, JSC::JSObject* owner, BunTextAccumulator&, JSValue chunk); static WTF::String finishTextAccumulator(JSC::VM& vm, JSGlobalObject*, JSC::JSObject* owner, BunTextAccumulator&); +// Joins the string chunks with one allocation. Returns a null string if that fails. +template +static WTF::String tryJoinStringChunks(const MarkedArgumentBuffer& chunks, unsigned length) +{ + std::span characters; + WTF::String joined = WTF::String::tryCreateUninitialized(length, characters); + if (joined.isNull()) [[unlikely]] + return joined; + for (unsigned i = 0; i < chunks.size(); i++) { + JSString* chunk = asString(chunks.at(i)); + // Copies a rope's fibers directly without resolving the rope. + chunk->resolveToBuffer(characters.first(chunk->length())); + characters = characters.subspan(chunk->length()); + } + return joined; +} + // The chunk-array -> text conversion: pure-string arrays join once (no UTF-8 round trip); // mixed/binary chunk arrays run through the shared text accumulator. static JSValue convertChunksToText(JSGlobalObject* globalObject, JSValue chunksValue) @@ -585,6 +602,7 @@ static JSValue convertChunksToText(JSGlobalObject* globalObject, JSValue chunksV // MarkedArgumentBuffer for the conversion below. MarkedArgumentBuffer values; bool allStrings = true; + bool all8Bit = true; WTF::CheckedUint32 codeUnits = 0; for (unsigned i = 0; i < length; i++) { JSValue chunk = chunks->getIndex(globalObject, i); @@ -592,8 +610,10 @@ static JSValue convertChunksToText(JSGlobalObject* globalObject, JSValue chunksV values.append(chunk); if (!chunk.isString()) allStrings = false; - else if (allStrings) + else if (allStrings) { codeUnits += asString(chunk)->length(); + all8Bit = all8Bit && asString(chunk)->is8Bit(); + } } if (values.hasOverflowed()) [[unlikely]] { throwOutOfMemoryError(globalObject, scope); @@ -604,18 +624,14 @@ static JSValue convertChunksToText(JSGlobalObject* globalObject, JSValue chunksV throwOutOfMemoryError(globalObject, scope); return {}; } - WTF::StringBuilder rope; - rope.reserveCapacity(codeUnits.value()); - for (unsigned i = 0; i < length; i++) { - WTF::String string = asString(values.at(i))->value(globalObject); - RETURN_IF_EXCEPTION(scope, {}); - rope.append(string); - } - if (rope.hasOverflowed()) [[unlikely]] { + WTF::String joined = all8Bit + ? tryJoinStringChunks(values, codeUnits.value()) + : tryJoinStringChunks(values, codeUnits.value()); + if (joined.isNull()) [[unlikely]] { throwOutOfMemoryError(globalObject, scope); return {}; } - RELEASE_AND_RETURN(scope, jsString(vm, stripTextResultBOM(rope.toString()))); + RELEASE_AND_RETURN(scope, jsString(vm, stripTextResultBOM(joined))); } // Mixed string/binary chunks: drive the shared accumulator so adjacent-string rope @@ -625,7 +641,12 @@ static JSValue convertChunksToText(JSGlobalObject* globalObject, JSValue chunksV auto* sink = WebCore::JSBunStandaloneTextSink::create(vm, runtime->standaloneTextSinkStructure(domGlobalObject)); for (unsigned i = 0; i < length; i++) { textAccumulatorWrite(vm, globalObject, sink, sink->m_accumulator, values.at(i)); - RETURN_IF_EXCEPTION(scope, {}); + if (scope.exception()) [[unlikely]] { + // Nothing reads the sink after this, so free its text now. The GC doesn't know how big it is. + WTF::Locker locker { sink->cellLock() }; + sink->m_accumulator.reset(locker); + return {}; + } } WTF::String text = finishTextAccumulator(vm, globalObject, sink, sink->m_accumulator); RETURN_IF_EXCEPTION(scope, {}); @@ -664,14 +685,9 @@ static JSValue textAccumulatorWrite(JSC::VM& vm, JSGlobalObject* globalObject, J WTF::String string = asString(chunk)->value(globalObject); RETURN_IF_EXCEPTION(scope, {}); unsigned length = string.length(); - if (length) { - accumulator.rope.append(string); - if (accumulator.rope.hasOverflowed()) [[unlikely]] { - throwOutOfMemoryError(globalObject, scope); - return {}; - } - accumulator.hasString = true; - accumulator.estimatedLength += length; + if (length && !accumulator.tryAppendString(string)) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return {}; } return jsNumber(length); } @@ -688,8 +704,13 @@ static JSValue textAccumulatorWrite(JSC::VM& vm, JSGlobalObject* globalObject, J if (byteLength) { accumulator.hasBuffer = true; JSC::JSString* flushedRope = nullptr; - if (accumulator.rope.length()) { - flushedRope = jsString(vm, accumulator.rope.toString()); + if (accumulator.hasRope()) { + WTF::String rope = accumulator.tryRopeString(); + if (rope.isNull()) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return {}; + } + flushedRope = jsString(vm, WTF::move(rope)); RETURN_IF_EXCEPTION(scope, {}); } bool appended; @@ -722,13 +743,12 @@ static WTF::String finishTextAccumulator(JSC::VM& vm, JSGlobalObject* globalObje if (!hasString && !hasBuffer) return WTF::emptyString(); if (hasString && !hasBuffer) { - if (exceedsStringLimit(accumulator.rope.length())) [[unlikely]] { - releaseAccumulated(); + WTF::String rope = accumulator.tryRopeString(); + releaseAccumulated(); + if (rope.isNull()) [[unlikely]] { throwOutOfMemoryError(globalObject, scope); return WTF::String(); } - WTF::String rope = accumulator.rope.toString(); - releaseAccumulated(); if (rope.length() && rope[0] == 0xFEFF) return rope.substring(1); return rope; @@ -757,8 +777,13 @@ static WTF::String finishTextAccumulator(JSC::VM& vm, JSGlobalObject* globalObje return WTF::String(); } } - if (accumulator.rope.length()) { - WTF::String rope = accumulator.rope.toString(); + if (accumulator.hasRope()) { + WTF::String rope = accumulator.tryRopeString(); + if (rope.isNull()) [[unlikely]] { + releaseAccumulated(); + throwOutOfMemoryError(globalObject, scope); + return WTF::String(); + } if (rope[0] == 0xFEFF) rope = rope.substring(1); if (!appendUTF8WithinStringLimit(rope, bytes)) [[unlikely]] { diff --git a/src/jsc/bindings/webcore/streams/BunStreamSource.cpp b/src/jsc/bindings/webcore/streams/BunStreamSource.cpp index 247629b9cd5f..3749ba2be5a6 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamSource.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamSource.cpp @@ -106,16 +106,20 @@ JSReadStreamIntoSinkOperation::JSReadStreamIntoSinkOperation(VM& vm, Structure* { } -void JSReadStreamIntoSinkOperation::finishCreation(VM& vm) +void JSReadStreamIntoSinkOperation::finishCreation(VM& vm, JSReadableStream* stream, JSReadableStreamDefaultReader* reader, JSObject* sink, JSPromise* result) { Base::finishCreation(vm); ASSERT(inherits(info())); + internalField(Field::Stream).set(vm, this, stream); + internalField(Field::Reader).set(vm, this, reader); + internalField(Field::Sink).set(vm, this, sink); + internalField(Field::Result).set(vm, this, result); } -JSReadStreamIntoSinkOperation* JSReadStreamIntoSinkOperation::create(VM& vm, Structure* structure) +JSReadStreamIntoSinkOperation* JSReadStreamIntoSinkOperation::create(VM& vm, Structure* structure, JSReadableStream* stream, JSReadableStreamDefaultReader* reader, JSObject* sink, JSPromise* result) { auto* cell = new (NotNull, allocateCell(vm)) JSReadStreamIntoSinkOperation(vm, structure); - cell->finishCreation(vm); + cell->finishCreation(vm, stream, reader, sink, result); return cell; } @@ -771,11 +775,8 @@ JSValue readDirectStream(JSGlobalObject* globalObject, JSReadableStream* stream, pullPromise->performPromiseThenWithContext(vm, globalObject, runtime->onReadDirectStreamPullFulfilled(), runtime->onReadDirectStreamPullRejected(), jsUndefined(), sinkController); // pull() already called close(error). if (JSValue failed = sinkController->m_failReason.get()) { - auto* rejected = promiseRejectedWith(globalObject, failed); - RETURN_IF_EXCEPTION(scope, {}); // Every caller is a native sink owner that reads the promise's state instead of attaching a reaction. - markPromiseAsHandled(vm, rejected); - return rejected; + return promiseRejectedWithAsHandled(globalObject, failed); } if (stream->m_state == ReadableStreamState::Readable) { auto* closePromise = JSPromise::create(vm, globalObject->promiseStructure()); @@ -909,7 +910,6 @@ static void rsisAbrupt(JSC::VM& vm, JSGlobalObject* globalObject, JSReadStreamIn auto scope = DECLARE_THROW_SCOPE(vm); op->m_didThrow = true; auto* reader = op->reader(); - const bool pumpHoldsLock = !!reader; if (reader) reader->m_pipeOperation.clear(); op->clearReader(); @@ -921,7 +921,7 @@ static void rsisAbrupt(JSC::VM& vm, JSGlobalObject* globalObject, JSReadStreamIn RETURN_IF_EXCEPTION(scope, ); // The orphaned reader keeps the stream locked. Cancel before rsisFinally clears the // controller slots, so the source's cancel(reason) runs. - if (stream && (pumpHoldsLock || !isReadableStreamLocked(stream))) { + if (reader && stream) { auto* cancelPromise = readableStreamCancel(globalObject, stream, error); RETURN_IF_EXCEPTION(scope, ); markPromiseAsHandled(vm, cancelPromise); @@ -932,6 +932,26 @@ static void rsisAbrupt(JSC::VM& vm, JSGlobalObject* globalObject, JSReadStreamIn RELEASE_AND_RETURN(scope, rsisSinkClose(vm, globalObject, sink, error)); } +// Fails a pump that never acquired the stream. Its lazy start or its acquire threw, so there's no +// operation yet. This must not write to a stream that another consumer has locked. +static void rsisFailBeforeAcquire(JSC::VM& vm, JSGlobalObject* globalObject, JSReadableStream* stream, JSObject* sink, JSPromise* result, JSValue error) +{ + auto scope = DECLARE_THROW_SCOPE(vm); + rejectPromise(globalObject, result, error); + RETURN_IF_EXCEPTION(scope, ); + if (!isReadableStreamLocked(stream)) { + // The stream is unlocked, so its lazy start threw before creating a controller. The pump was its only consumer, so cancel it. + auto* cancelPromise = readableStreamCancel(globalObject, stream, error); + RETURN_IF_EXCEPTION(scope, ); + markPromiseAsHandled(vm, cancelPromise); + } else if (auto* controller = dynamicDowncast(sink)) { + // Clear the pipe slot first. Otherwise the sink's owner would end the stream when the sink closes (FileSink's on_close and handle_reject_stream). + controller->m_pipeHoldsStream = false; + controller->clearReadableStream(); + } + RELEASE_AND_RETURN(scope, rsisSinkClose(vm, globalObject, sink, error)); +} + // One sink.write(chunk). wrote<0 = backpressure: stash tail on m_pendingBatch, suspend; onReady resumes. static std::optional rsisWriteChunk(JSC::VM& vm, JSGlobalObject* globalObject, JSReadStreamIntoSinkOperation* op, JSValue chunk, JSObject* batchValues, unsigned nextIndex, unsigned length) { @@ -1113,17 +1133,18 @@ static JSTransformStream* nativeByteTransformBehind(JSReadableStream* stream) } } -static void rsisBegin(JSC::VM& vm, JSGlobalObject* globalObject, JSReadStreamIntoSinkOperation* op) +// Sets `op` once the pump has acquired the reader. `op` is still null if this throws before that. +static void rsisBegin(JSC::VM& vm, JSGlobalObject* globalObject, JSReadableStream* stream, JSObject* sink, JSPromise* result, JSReadStreamIntoSinkOperation*& op) { auto scope = DECLARE_THROW_SCOPE(vm); - auto* stream = op->stream(); stream->materializeIfNeeded(globalObject); RETURN_IF_EXCEPTION(scope, ); auto* reader = acquireReadableStreamDefaultReader(globalObject, stream); RETURN_IF_EXCEPTION(scope, ); // The sink consumes the stream as a body: it stays locked after rsisFinally releases the pump's reader. stream->markConsumedAsBody(); - op->setReader(vm, reader); + auto* runtime = WebCore::JSStreamsRuntime::from(globalObject); + op = JSReadStreamIntoSinkOperation::create(vm, runtime->readStreamIntoSinkOperationStructure(defaultGlobalObject(globalObject)), stream, reader, sink, result); reader->m_pipeOperation.set(vm, reader, op); // Byte-producing native transform + native JSSink: attach the sink to the transform so its // transform arms write coder output straight to the sink (no JSUint8Array per chunk). The @@ -1131,7 +1152,7 @@ static void rsisBegin(JSC::VM& vm, JSGlobalObject* globalObject, JSReadStreamInt // Attached only after the reader is acquired so a failed second attempt (stream already // locked) cannot overwrite the first pump's attachment. if (auto* ts = nativeByteTransformBehind(stream); ts && !ts->m_nativeSinkPtr) { - if (auto* sinkCtrl = dynamicDowncast(op->sink())) { + if (auto* sinkCtrl = dynamicDowncast(sink)) { if (void* sinkPtr = sinkCtrl->wrapped()) { ts->m_nativeSinkPtr = sinkPtr; ts->m_nativeSinkId = static_cast(sinkCtrl->sinkId()); @@ -1146,7 +1167,6 @@ static void rsisBegin(JSC::VM& vm, JSGlobalObject* globalObject, JSReadStreamInt // The sink may abort before readMany settles (#6758): start it now. rsisRegisterAndStart(vm, globalObject, op); RETURN_IF_EXCEPTION(scope, ); - auto* runtime = WebCore::JSStreamsRuntime::from(globalObject); manyPromise->performPromiseThenWithContext(vm, globalObject, runtime->onReadStreamIntoSinkReadManyFulfilled(), runtime->onReadStreamIntoSinkRejected(), jsUndefined(), op); return; } @@ -1157,15 +1177,14 @@ JSPromise* readStreamIntoSink(JSGlobalObject* globalObject, JSReadableStream* st { auto& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - auto* domGlobalObject = defaultGlobalObject(globalObject); - auto* runtime = WebCore::JSStreamsRuntime::from(globalObject); - auto* op = JSReadStreamIntoSinkOperation::create(vm, runtime->readStreamIntoSinkOperationStructure(domGlobalObject)); - op->setStream(vm, stream); - op->setSink(vm, sink); auto* result = JSPromise::create(vm, globalObject->promiseStructure()); - op->setResult(vm, result); + JSReadStreamIntoSinkOperation* op = nullptr; // This returns the pump's promise, so a throw while starting the pump is its rejection. - atStreamsBoundary(globalObject, [&] { rsisBegin(vm, globalObject, op); }, [&](JSValue error) { rsisAbrupt(vm, globalObject, op, error); }); + atStreamsBoundary(globalObject, [&] { rsisBegin(vm, globalObject, stream, sink, result, op); }, [&](JSValue error) { + if (op) + rsisAbrupt(vm, globalObject, op, error); + else + rsisFailBeforeAcquire(vm, globalObject, stream, sink, result, error); }); RETURN_IF_EXCEPTION(scope, nullptr); return result; } diff --git a/src/jsc/bindings/webcore/streams/JSByteLengthQueuingStrategy.cpp b/src/jsc/bindings/webcore/streams/JSByteLengthQueuingStrategy.cpp index 1e41a645f2c2..f374484076a4 100644 --- a/src/jsc/bindings/webcore/streams/JSByteLengthQueuingStrategy.cpp +++ b/src/jsc/bindings/webcore/streams/JSByteLengthQueuingStrategy.cpp @@ -156,7 +156,7 @@ JSC_DEFINE_HOST_FUNCTION(jsByteLengthQueuingStrategyPrototype_inspectCustom, (JS { auto& vm = JSC::getVM(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); - JSValue thisValue = callFrame->thisValue(); + JSValue thisValue = callFrame->thisValue().toThis(lexicalGlobalObject, JSC::ECMAMode::strict()); auto* thisObject = dynamicDowncast(thisValue); if (!thisObject) [[unlikely]] return JSValue::encode(thisValue); diff --git a/src/jsc/bindings/webcore/streams/JSCountQueuingStrategy.cpp b/src/jsc/bindings/webcore/streams/JSCountQueuingStrategy.cpp index f76d99262512..b167cfdad6af 100644 --- a/src/jsc/bindings/webcore/streams/JSCountQueuingStrategy.cpp +++ b/src/jsc/bindings/webcore/streams/JSCountQueuingStrategy.cpp @@ -156,7 +156,7 @@ JSC_DEFINE_HOST_FUNCTION(jsCountQueuingStrategyPrototype_inspectCustom, (JSGloba { auto& vm = JSC::getVM(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); - JSValue thisValue = callFrame->thisValue(); + JSValue thisValue = callFrame->thisValue().toThis(lexicalGlobalObject, JSC::ECMAMode::strict()); auto* thisObject = dynamicDowncast(thisValue); if (!thisObject) [[unlikely]] return JSValue::encode(thisValue); diff --git a/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp b/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp index f215b546112f..a8ec19ecf04f 100644 --- a/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp +++ b/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp @@ -402,13 +402,10 @@ static JSValue writeToTextSink(JSGlobalObject* globalObject, JSDirectStreamContr if (length > 0) { String value = string->value(globalObject); RETURN_IF_EXCEPTION(scope, {}); - accumulator.rope.append(value); - if (accumulator.rope.hasOverflowed()) [[unlikely]] { + if (!accumulator.tryAppendString(value)) [[unlikely]] { throwOutOfMemoryError(globalObject, scope); return {}; } - accumulator.hasString = true; - accumulator.estimatedLength += length; } return jsNumber(length); } @@ -427,8 +424,13 @@ static JSValue writeToTextSink(JSGlobalObject* globalObject, JSDirectStreamContr if (byteLength > 0) { accumulator.hasBuffer = true; JSString* ropeString = nullptr; - if (!accumulator.rope.isEmpty()) { - ropeString = jsString(vm, accumulator.rope.toString()); + if (accumulator.hasRope()) { + String rope = accumulator.tryRopeString(); + if (rope.isNull()) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return {}; + } + ropeString = jsString(vm, WTF::move(rope)); RETURN_IF_EXCEPTION(scope, {}); } // GC-allocation is done; the barrier container is only mutated under the cell lock, and the throw waits for the unlock. @@ -487,11 +489,11 @@ static String finishTextSink(JSC::VM& vm, JSGlobalObject* globalObject, JSDirect auto scope = DECLARE_THROW_SCOPE(vm); // Pure-string rope: the ONLY arm of the direct Text sink that strips a leading BOM. if (hasString && !hasBuffer) { - if (Bun::WebStreams::exceedsStringLimit(accumulator.rope.length())) [[unlikely]] { + String rope = accumulator.tryRopeString(); + if (rope.isNull()) [[unlikely]] { throwOutOfMemoryError(globalObject, scope); return String(); } - String rope = accumulator.rope.toString(); if (rope.length() && rope[0] == 0xFEFF) return rope.substring(1); return rope; @@ -529,8 +531,12 @@ static String finishTextSink(JSC::VM& vm, JSGlobalObject* globalObject, JSDirect return String(); } } - if (!accumulator.rope.isEmpty()) { - String rope = accumulator.rope.toString(); + if (accumulator.hasRope()) { + String rope = accumulator.tryRopeString(); + if (rope.isNull()) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return String(); + } if (rope[0] == 0xFEFF) rope = rope.substring(1); if (!Bun::WebStreams::appendUTF8WithinStringLimit(rope, bytes)) [[unlikely]] { @@ -624,28 +630,33 @@ static JSValue flushDirectSink(JSC::VM& vm, JSGlobalObject* globalObject, JSDire return {}; } -// `sink.close(error)`: the Text/Array sinks fulfill their closing promise with the partial result. -static void closeDirectSinkForError(JSC::VM& vm, JSGlobalObject* globalObject, JSDirectStreamController* controller) +// Called when the stream errors. Nothing reads the sink's text or array after that, so just free them. +static void closeDirectSinkForError(JSC::VM& vm, JSDirectStreamController* controller, JSValue error) { switch (controller->m_sinkKind) { case DirectSinkKind::ArrayBuffer: controller->freeBuffer(); return; - case DirectSinkKind::Text: - if (!controller->m_calledDone) - endTextSink(vm, globalObject, controller); - return; + case DirectSinkKind::Text: { + if (controller->m_calledDone) + return; + controller->m_calledDone = true; + Locker locker { controller->cellLock() }; + controller->m_textAccumulator.reset(locker); + break; + } case DirectSinkKind::Array: - if (!controller->m_calledDone) - endArraySink(vm, globalObject, controller); - return; + if (controller->m_calledDone) + return; + controller->m_calledDone = true; + controller->m_array.clear(); + break; } - RELEASE_ASSERT_NOT_REACHED(); + if (auto* closingPromise = controller->closingPromise().get()) + closingPromise->rejectAsHandled(vm, error); } -// Errors the stream with `error`: rejects the pending read, errors the stream, tears the sink down, -// then runs the user's close(error) hook. The stream is fully errored before anything that can throw -// (the sink teardown, the hook) runs, so a throw from those propagates with the stream consistent. +// Errors the stream with `error`, frees the sink, then runs the user's close(error) hook. bool JSDirectStreamController::handleError(JSGlobalObject* globalObject, JSValue error) { auto& vm = getVM(globalObject); @@ -675,8 +686,7 @@ bool JSDirectStreamController::handleError(JSGlobalObject* globalObject, JSValue // (end() arming the final chunk leaves the stream Readable), so doing it again would double it. if (wasClosed) return delivered; - closeDirectSinkForError(vm, globalObject, this); - RETURN_IF_EXCEPTION(scope, false); + closeDirectSinkForError(vm, this, error); if (source) { source->close(globalObject, error); RETURN_IF_EXCEPTION(scope, false); diff --git a/src/jsc/bindings/webcore/streams/JSReadRequest.cpp b/src/jsc/bindings/webcore/streams/JSReadRequest.cpp index bba98242cb8e..1f2d0a9af3b3 100644 --- a/src/jsc/bindings/webcore/streams/JSReadRequest.cpp +++ b/src/jsc/bindings/webcore/streams/JSReadRequest.cpp @@ -90,6 +90,22 @@ void JSReadRequest::analyzeHeap(JSCell* cell, HeapAnalyzer& analyzer) analyzeBarrierEdge(vm, analyzer, cell, thisObject->internalField(Field::Context), "context"_s); } +static bool closeStepsCloseAnotherStream(ReadRequestKind kind) +{ + switch (kind) { + case ReadRequestKind::DefaultTee: + case ReadRequestKind::ByteTee: + case ReadRequestKind::TextDecode: + return true; + case ReadRequestKind::Promise: + case ReadRequestKind::PipeTo: + case ReadRequestKind::ReadStreamIntoSink: + case ReadRequestKind::AsyncIterator: + return false; + } + RELEASE_ASSERT_NOT_REACHED(); +} + void JSReadRequest::chunkSteps(JSGlobalObject* globalObject, JSValue chunk) { auto& vm = getVM(globalObject); @@ -124,10 +140,12 @@ void JSReadRequest::chunkSteps(JSGlobalObject* globalObject, JSValue chunk) RELEASE_ASSERT_NOT_REACHED(); } -void JSReadRequest::closeSteps(JSGlobalObject* globalObject) +void JSReadRequest::closeSteps(JSGlobalObject* globalObject, MayDefer mayDefer) { auto& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); + if (mayDefer == MayDefer::Yes && closeStepsCloseAnotherStream(m_kind) && streamLinkMustDefer(vm)) [[unlikely]] + return queueReactionJob(vm, globalObject, JSStreamsRuntime::from(globalObject)->onReadRequestCloseStepsDeferred(), jsUndefined(), this); switch (m_kind) { case ReadRequestKind::Promise: { auto* promise = uncheckedDowncast(context()); @@ -199,7 +217,7 @@ void JSReadRequest::closeSteps(JSGlobalObject* globalObject) RELEASE_ASSERT_NOT_REACHED(); } -void JSReadRequest::errorSteps(JSGlobalObject* globalObject, JSValue error) +void JSReadRequest::errorSteps(JSGlobalObject* globalObject, JSValue error, MayDefer mayDefer) { auto& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); @@ -225,6 +243,8 @@ void JSReadRequest::errorSteps(JSGlobalObject* globalObject, JSValue error) return; } case ReadRequestKind::TextDecode: { + if (mayDefer == MayDefer::Yes && streamLinkMustDefer(vm)) [[unlikely]] + return queueReactionJob(vm, globalObject, JSStreamsRuntime::from(globalObject)->onReadRequestErrorStepsDeferred(), error, this); auto* controller = uncheckedDowncast(context()); auto* reader = dynamicDowncast(controller->m_algorithms.algorithmContext.get()); readableStreamDefaultControllerError(globalObject, controller, error); @@ -306,7 +326,7 @@ void JSReadIntoRequest::chunkSteps(JSGlobalObject* globalObject, JSArrayBufferVi RELEASE_ASSERT_NOT_REACHED(); } -void JSReadIntoRequest::closeSteps(JSGlobalObject* globalObject, JSArrayBufferView* chunkOrNull) +void JSReadIntoRequest::closeSteps(JSGlobalObject* globalObject, JSArrayBufferView* chunkOrNull, MayDefer mayDefer) { auto& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); @@ -318,6 +338,8 @@ void JSReadIntoRequest::closeSteps(JSGlobalObject* globalObject, JSArrayBufferVi RELEASE_AND_RETURN(scope, resolvePromise(globalObject, promise, result)); } case ReadIntoRequestKind::ByteTee: { + if (mayDefer == MayDefer::Yes && streamLinkMustDefer(vm)) [[unlikely]] + return queueReactionJob(vm, globalObject, JSStreamsRuntime::from(globalObject)->onReadIntoRequestCloseStepsDeferred(), chunkOrNull ? JSValue(chunkOrNull) : jsUndefined(), this); auto* context = uncheckedDowncast(this->context()); auto* teeState = uncheckedDowncast(context->getInternalField(0)); bool forBranch2 = context->getInternalField(1).asBoolean(); @@ -369,4 +391,31 @@ void JSReadIntoRequest::errorSteps(JSGlobalObject* globalObject, JSValue error) RELEASE_ASSERT_NOT_REACHED(); } +JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onReadRequestCloseStepsDeferred, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + uncheckedDowncast(callFrame->argument(1))->closeSteps(globalObject, MayDefer::No); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onReadRequestErrorStepsDeferred, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + uncheckedDowncast(callFrame->argument(1))->errorSteps(globalObject, callFrame->argument(0), MayDefer::No); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onReadIntoRequestCloseStepsDeferred, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + uncheckedDowncast(callFrame->argument(1))->closeSteps(globalObject, dynamicDowncast(callFrame->argument(0)), MayDefer::No); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(jsUndefined()); +} + } // namespace WebCore diff --git a/src/jsc/bindings/webcore/streams/JSReadRequest.h b/src/jsc/bindings/webcore/streams/JSReadRequest.h index be47d0a17993..7cce69dd5dfe 100644 --- a/src/jsc/bindings/webcore/streams/JSReadRequest.h +++ b/src/jsc/bindings/webcore/streams/JSReadRequest.h @@ -49,9 +49,9 @@ class JSReadRequest final : public JSC::JSInternalFieldObjectImpl<1> { // "chunk steps, given chunk" void chunkSteps(JSC::JSGlobalObject*, JSC::JSValue chunk); // "close steps" - void closeSteps(JSC::JSGlobalObject*); + void closeSteps(JSC::JSGlobalObject*, Bun::WebStreams::MayDefer = Bun::WebStreams::MayDefer::Yes); // "error steps, given e" - void errorSteps(JSC::JSGlobalObject*, JSC::JSValue error); + void errorSteps(JSC::JSGlobalObject*, JSC::JSValue error, Bun::WebStreams::MayDefer = Bun::WebStreams::MayDefer::Yes); const JSC::WriteBarrier& internalField(Field field) const { return Base::internalField(static_cast(field)); } JSC::WriteBarrier& internalField(Field field) { return Base::internalField(static_cast(field)); } @@ -100,7 +100,7 @@ class JSReadIntoRequest final : public JSC::JSInternalFieldObjectImpl<1> { // "chunk steps, given chunk" void chunkSteps(JSC::JSGlobalObject*, JSC::JSArrayBufferView* chunk); // "close steps, given chunk" — chunk may be null (the spec's `undefined`). - void closeSteps(JSC::JSGlobalObject*, JSC::JSArrayBufferView* chunkOrNull); + void closeSteps(JSC::JSGlobalObject*, JSC::JSArrayBufferView* chunkOrNull, Bun::WebStreams::MayDefer = Bun::WebStreams::MayDefer::Yes); // "error steps, given e" void errorSteps(JSC::JSGlobalObject*, JSC::JSValue error); diff --git a/src/jsc/bindings/webcore/streams/JSReadStreamIntoSinkOperation.h b/src/jsc/bindings/webcore/streams/JSReadStreamIntoSinkOperation.h index 673d69501b9f..8bd0188a5cf4 100644 --- a/src/jsc/bindings/webcore/streams/JSReadStreamIntoSinkOperation.h +++ b/src/jsc/bindings/webcore/streams/JSReadStreamIntoSinkOperation.h @@ -1,5 +1,7 @@ // JSReadStreamIntoSinkOperation — the readStreamIntoSink async pump's state cell. Driven // entirely by [reaction-convention] reactions. +// OWNERSHIP: only created after the pump acquires the stream's reader, so any teardown that +// has one of these is acting on a stream the pump locked. // ROOTING: the acquired reader's visited m_pipeOperation back-edge points HERE (set at // acquire, cleared at teardown), so `Rust Strong → stream → reader → this → // sink / result` holds across the backpressure await. @@ -40,7 +42,7 @@ class JSReadStreamIntoSinkOperation final : public JSC::JSInternalFieldObjectImp NativeTransform, }; - static JSReadStreamIntoSinkOperation* create(JSC::VM&, JSC::Structure*); + static JSReadStreamIntoSinkOperation* create(JSC::VM&, JSC::Structure*, JSReadableStream*, JSReadableStreamDefaultReader*, JSC::JSObject* sink, JSC::JSPromise* result); static JSC::Structure* createStructure(JSC::VM&, JSC::JSGlobalObject*, JSC::JSValue prototype); DECLARE_INFO; @@ -66,10 +68,6 @@ class JSReadStreamIntoSinkOperation final : public JSC::JSInternalFieldObjectImp JSC::JSObject* pendingBatch() const { return uncheckedDowncast(fieldCell(Field::PendingBatch)); } JSTransformStream* nativeTransform() const { return uncheckedDowncast(fieldCell(Field::NativeTransform)); } - void setStream(JSC::VM& vm, JSReadableStream* stream) { internalField(Field::Stream).set(vm, this, stream); } - void setReader(JSC::VM& vm, JSReadableStreamDefaultReader* reader) { internalField(Field::Reader).set(vm, this, reader); } - void setSink(JSC::VM& vm, JSC::JSObject* sink) { internalField(Field::Sink).set(vm, this, sink); } - void setResult(JSC::VM& vm, JSC::JSPromise* result) { internalField(Field::Result).set(vm, this, result); } void setPendingBatch(JSC::VM& vm, JSC::JSObject* batch) { internalField(Field::PendingBatch).set(vm, this, batch); } void setNativeTransform(JSC::VM& vm, JSTransformStream* transform) { internalField(Field::NativeTransform).set(vm, this, transform); } @@ -87,7 +85,7 @@ class JSReadStreamIntoSinkOperation final : public JSC::JSInternalFieldObjectImp private: JSReadStreamIntoSinkOperation(JSC::VM&, JSC::Structure*); - void finishCreation(JSC::VM&); + void finishCreation(JSC::VM&, JSReadableStream*, JSReadableStreamDefaultReader*, JSC::JSObject* sink, JSC::JSPromise* result); JSC::JSCell* fieldCell(Field field) const { diff --git a/src/jsc/bindings/webcore/streams/JSReadableByteStreamController.cpp b/src/jsc/bindings/webcore/streams/JSReadableByteStreamController.cpp index 29d237bac93f..f7c7ec852f37 100644 --- a/src/jsc/bindings/webcore/streams/JSReadableByteStreamController.cpp +++ b/src/jsc/bindings/webcore/streams/JSReadableByteStreamController.cpp @@ -231,7 +231,7 @@ JSC_DEFINE_HOST_FUNCTION(jsReadableByteStreamControllerPrototype_inspectCustom, { auto& vm = JSC::getVM(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); - JSValue thisValue = callFrame->thisValue(); + JSValue thisValue = callFrame->thisValue().toThis(lexicalGlobalObject, JSC::ECMAMode::strict()); auto* thisObject = dynamicDowncast(thisValue); if (!thisObject) [[unlikely]] return JSValue::encode(thisValue); @@ -473,7 +473,7 @@ JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onRSByteControllerPullFulfilled, (J controller->m_pulling = false; if (controller->m_pullAgain) { controller->m_pullAgain = false; - readableByteStreamControllerCallPullIfNeeded(globalObject, controller); + readableByteStreamControllerCallPullIfNeeded(globalObject, controller, MayDefer::No); RETURN_IF_EXCEPTION(scope, {}); } return JSValue::encode(jsUndefined()); @@ -596,7 +596,7 @@ namespace WebStreams { using namespace JSC; using namespace WebCore; -void readableByteStreamControllerCallPullIfNeeded(JSGlobalObject* globalObject, JSReadableByteStreamController* controller) +void readableByteStreamControllerCallPullIfNeeded(JSGlobalObject* globalObject, JSReadableByteStreamController* controller, MayDefer mayDefer) { auto& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); @@ -608,9 +608,13 @@ void readableByteStreamControllerCallPullIfNeeded(JSGlobalObject* globalObject, } ASSERT(!controller->m_pullAgain); controller->m_pulling = true; + auto* runtime = JSStreamsRuntime::from(globalObject); + if (mayDefer == MayDefer::Yes && controller->m_algorithms.linksAnotherStream() && streamLinkMustDefer(vm)) [[unlikely]] { + controller->m_pullAgain = true; + return queueStreamsMicrotask(globalObject, runtime->onRSByteControllerPullFulfilled(), jsUndefined(), controller); + } JSPromise* pullPromise = performByteControllerPullAlgorithm(vm, globalObject, controller); RETURN_IF_EXCEPTION(scope, void()); - auto* runtime = JSStreamsRuntime::from(globalObject); // See readableStreamDefaultControllerCallPullIfNeeded. if (!pullPromise || pullPromise->status() == JSPromise::Status::Fulfilled) return queueStreamsMicrotask(globalObject, runtime->onRSByteControllerPullFulfilled(), jsUndefined(), controller); diff --git a/src/jsc/bindings/webcore/streams/JSReadableStream.cpp b/src/jsc/bindings/webcore/streams/JSReadableStream.cpp index 7c954ba95d3a..1222dfd927c5 100644 --- a/src/jsc/bindings/webcore/streams/JSReadableStream.cpp +++ b/src/jsc/bindings/webcore/streams/JSReadableStream.cpp @@ -392,7 +392,7 @@ JSC_DEFINE_HOST_FUNCTION(jsReadableStreamPrototype_inspectCustom, (JSGlobalObjec { auto& vm = JSC::getVM(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); - JSValue thisValue = callFrame->thisValue(); + JSValue thisValue = callFrame->thisValue().toThis(lexicalGlobalObject, JSC::ECMAMode::strict()); auto* thisObject = dynamicDowncast(thisValue); if (!thisObject) [[unlikely]] return JSValue::encode(thisValue); @@ -526,6 +526,10 @@ void JSReadableStream::materializeIfNeeded(JSGlobalObject* globalObject) const auto mode = m_bunMode; if (mode == BunStreamMode::Default) [[likely]] return; + // A native sink locked this stream without a reader (ReadableStream__lockNative) and didn't start it. + // Don't start its source here. The caller's acquire will fail on the lock. + if (m_lockedWithoutReader) + return; // Clear the mode BEFORE running the thunk so re-entrant consumers see it done. m_bunMode = BunStreamMode::Default; if (mode == BunStreamMode::DirectPending) diff --git a/src/jsc/bindings/webcore/streams/JSReadableStreamBYOBReader.cpp b/src/jsc/bindings/webcore/streams/JSReadableStreamBYOBReader.cpp index 828f127ae496..64743dd9f6c0 100644 --- a/src/jsc/bindings/webcore/streams/JSReadableStreamBYOBReader.cpp +++ b/src/jsc/bindings/webcore/streams/JSReadableStreamBYOBReader.cpp @@ -259,7 +259,7 @@ JSC_DEFINE_HOST_FUNCTION(jsReadableStreamBYOBReaderPrototype_inspectCustom, (JSG { auto& vm = JSC::getVM(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); - JSValue thisValue = callFrame->thisValue(); + JSValue thisValue = callFrame->thisValue().toThis(lexicalGlobalObject, JSC::ECMAMode::strict()); auto* thisObject = dynamicDowncast(thisValue); if (!thisObject) [[unlikely]] return JSValue::encode(thisValue); diff --git a/src/jsc/bindings/webcore/streams/JSReadableStreamBYOBRequest.cpp b/src/jsc/bindings/webcore/streams/JSReadableStreamBYOBRequest.cpp index ce6fa1475c9f..1151975bafb3 100644 --- a/src/jsc/bindings/webcore/streams/JSReadableStreamBYOBRequest.cpp +++ b/src/jsc/bindings/webcore/streams/JSReadableStreamBYOBRequest.cpp @@ -103,7 +103,7 @@ JSC_DEFINE_HOST_FUNCTION(jsReadableStreamBYOBRequestPrototype_inspectCustom, (JS { auto& vm = JSC::getVM(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); - JSValue thisValue = callFrame->thisValue(); + JSValue thisValue = callFrame->thisValue().toThis(lexicalGlobalObject, JSC::ECMAMode::strict()); auto* thisObject = dynamicDowncast(thisValue); if (!thisObject) [[unlikely]] return JSValue::encode(thisValue); diff --git a/src/jsc/bindings/webcore/streams/JSReadableStreamDefaultController.cpp b/src/jsc/bindings/webcore/streams/JSReadableStreamDefaultController.cpp index 9185bd6f5a2f..8c0335dd053c 100644 --- a/src/jsc/bindings/webcore/streams/JSReadableStreamDefaultController.cpp +++ b/src/jsc/bindings/webcore/streams/JSReadableStreamDefaultController.cpp @@ -170,7 +170,7 @@ JSC_DEFINE_HOST_FUNCTION(jsReadableStreamDefaultControllerPrototype_inspectCusto { auto& vm = JSC::getVM(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); - JSValue thisValue = callFrame->thisValue(); + JSValue thisValue = callFrame->thisValue().toThis(lexicalGlobalObject, JSC::ECMAMode::strict()); auto* thisObject = dynamicDowncast(thisValue); if (!thisObject) [[unlikely]] return JSValue::encode(thisValue); @@ -382,7 +382,7 @@ JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onRSDefaultControllerPullFulfilled, controller->m_pulling = false; if (controller->m_pullAgain) { controller->m_pullAgain = false; - readableStreamDefaultControllerCallPullIfNeeded(globalObject, controller); + readableStreamDefaultControllerCallPullIfNeeded(globalObject, controller, MayDefer::No); RETURN_IF_EXCEPTION(scope, {}); } return JSValue::encode(jsUndefined()); @@ -473,7 +473,7 @@ namespace WebStreams { using namespace JSC; using namespace WebCore; -void readableStreamDefaultControllerCallPullIfNeeded(JSGlobalObject* globalObject, JSReadableStreamDefaultController* controller) +void readableStreamDefaultControllerCallPullIfNeeded(JSGlobalObject* globalObject, JSReadableStreamDefaultController* controller, MayDefer mayDefer) { auto& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); @@ -485,9 +485,14 @@ void readableStreamDefaultControllerCallPullIfNeeded(JSGlobalObject* globalObjec } ASSERT(!controller->m_pullAgain); controller->m_pulling = true; + auto* runtime = JSStreamsRuntime::from(globalObject); + // The fulfillment handler sees m_pullAgain and runs this pull from its microtask (see streamLinkMustDefer). + if (mayDefer == MayDefer::Yes && controller->m_algorithms.linksAnotherStream() && streamLinkMustDefer(vm)) [[unlikely]] { + controller->m_pullAgain = true; + return queueStreamsMicrotask(globalObject, runtime->onRSDefaultControllerPullFulfilled(), jsUndefined(), controller); + } JSPromise* pullPromise = performDefaultControllerPullAlgorithm(vm, globalObject, controller); RETURN_IF_EXCEPTION(scope, void()); - auto* runtime = JSStreamsRuntime::from(globalObject); // A non-thenable return, or an already-fulfilled promise from an internal pull arm, // completed synchronously: queue the upon-fulfillment handler directly, saving the // wrapper promise and performPromiseThen reactions while keeping the spec's microtask diff --git a/src/jsc/bindings/webcore/streams/JSReadableStreamDefaultReader.cpp b/src/jsc/bindings/webcore/streams/JSReadableStreamDefaultReader.cpp index 5cef23824c0e..74b2d0d5d025 100644 --- a/src/jsc/bindings/webcore/streams/JSReadableStreamDefaultReader.cpp +++ b/src/jsc/bindings/webcore/streams/JSReadableStreamDefaultReader.cpp @@ -561,7 +561,7 @@ JSC_DEFINE_HOST_FUNCTION(jsReadableStreamDefaultReaderPrototype_inspectCustom, ( { auto& vm = JSC::getVM(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); - JSValue thisValue = callFrame->thisValue(); + JSValue thisValue = callFrame->thisValue().toThis(lexicalGlobalObject, JSC::ECMAMode::strict()); auto* thisObject = dynamicDowncast(thisValue); if (!thisObject) [[unlikely]] return JSValue::encode(thisValue); diff --git a/src/jsc/bindings/webcore/streams/JSStreamsRuntime.h b/src/jsc/bindings/webcore/streams/JSStreamsRuntime.h index 521392022391..fb4e512b658d 100644 --- a/src/jsc/bindings/webcore/streams/JSStreamsRuntime.h +++ b/src/jsc/bindings/webcore/streams/JSStreamsRuntime.h @@ -76,6 +76,7 @@ namespace WebCore { // Tee: context = the JSStreamTeeState, except onByteTeeReaderClosedRejected whose context // is an InternalFieldTuple{teeState, thisReader}. // The two *Microtask entries are the tee chunk-steps "queue a microtask" jobs. +// onTextDecodeCancelDeferred: context = an InternalFieldTuple{source reader, result promise}. #define FOR_EACH_WEB_STREAMS_REACTION_HANDLER_RS_OPERATIONS(V) \ V(onFromIterablePullFulfilled) \ V(onFromIterableCancelFulfilled) \ @@ -83,7 +84,15 @@ namespace WebCore { V(onDefaultTeeReaderClosedRejected) \ V(onByteTeeReadChunkMicrotask) \ V(onByteTeeReadIntoChunkMicrotask) \ - V(onByteTeeReaderClosedRejected) + V(onByteTeeReaderClosedRejected) \ + V(onTeeCancelDeferred) \ + V(onTextDecodeCancelDeferred) + +// owner: JSReadRequest.cpp. context = the JSReadRequest / JSReadIntoRequest whose steps run again. +#define FOR_EACH_WEB_STREAMS_REACTION_HANDLER_READ_REQUEST(V) \ + V(onReadRequestCloseStepsDeferred) \ + V(onReadRequestErrorStepsDeferred) \ + V(onReadIntoRequestCloseStepsDeferred) // owner: BunAsyncIterableSource.cpp. context = the JSAsyncIteratorSourceOperation, EXCEPT // onAsyncIterableSourceErrorRethrow, whose context is @@ -233,6 +242,7 @@ namespace WebCore { FOR_EACH_WEB_STREAMS_REACTION_HANDLER_RS_DEFAULT_CONTROLLER(V) \ FOR_EACH_WEB_STREAMS_REACTION_HANDLER_RS_BYTE_CONTROLLER(V) \ FOR_EACH_WEB_STREAMS_REACTION_HANDLER_RS_OPERATIONS(V) \ + FOR_EACH_WEB_STREAMS_REACTION_HANDLER_READ_REQUEST(V) \ FOR_EACH_WEB_STREAMS_REACTION_HANDLER_ASYNC_ITERATOR(V) \ FOR_EACH_WEB_STREAMS_REACTION_HANDLER_ASYNC_ITERABLE_SOURCE(V) \ FOR_EACH_WEB_STREAMS_REACTION_HANDLER_PIPE(V) \ diff --git a/src/jsc/bindings/webcore/streams/JSTextDecoderStream.cpp b/src/jsc/bindings/webcore/streams/JSTextDecoderStream.cpp index 23f129a2a09b..7ac7d4f3074e 100644 --- a/src/jsc/bindings/webcore/streams/JSTextDecoderStream.cpp +++ b/src/jsc/bindings/webcore/streams/JSTextDecoderStream.cpp @@ -4,6 +4,7 @@ #include "DOMClientIsoSubspaces.h" #include "DOMIsoSubspaces.h" #include "ErrorCode.h" +#include "JSBuffer.h" #include "JSDOMExceptionHandling.h" #include "JSDOMGlobalObjectInlines.h" #include "JSDOMWrapperCache.h" @@ -329,19 +330,21 @@ using namespace JSC; using WebCore::JSTextDecoderStream; // [AllowShared] BufferSource → (ptr, len); a detached buffer yields the empty sequence. -static std::optional> textDecoderStreamBytes(JSGlobalObject* globalObject, JSValue chunk) +static std::optional> textDecoderStreamBytes(JSGlobalObject* globalObject, JSValue chunk, bool& isShared) { auto& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); if (auto* view = dynamicDowncast(chunk)) { if (view->isDetached()) [[unlikely]] return std::span(); + isShared = view->isShared(); return std::span(static_cast(view->vector()), view->byteLength()); } if (auto* buffer = dynamicDowncast(chunk)) { auto* impl = buffer->impl(); if (!impl || impl->isDetached()) [[unlikely]] return std::span(); + isShared = impl->isShared(); return std::span(static_cast(impl->data()), impl->byteLength()); } Bun::ERR::INVALID_ARG_TYPE(scope, globalObject, "chunk"_s, "BufferSource"_s, chunk); @@ -370,9 +373,15 @@ JSPromise* textDecoderStreamTransform(JSGlobalObject* globalObject, JSTextDecode { return promiseFromSteps(globalObject, [&] -> JSPromise* { auto scope = DECLARE_THROW_SCOPE(getVM(globalObject)); - std::optional> bytes = textDecoderStreamBytes(globalObject, chunk); + bool isShared = false; + std::optional> bytes = textDecoderStreamBytes(globalObject, chunk, isShared); RETURN_IF_EXCEPTION(scope, nullptr); - decodeAndEnqueue(globalObject, stream, controller, bytes->data(), bytes->size(), true); + if (isShared) [[unlikely]] { + auto copy = Bun::copySharedBytes(globalObject, scope, *bytes); + RETURN_IF_EXCEPTION(scope, nullptr); + decodeAndEnqueue(globalObject, stream, controller, copy.span().data(), copy.span().size(), true); + } else + decodeAndEnqueue(globalObject, stream, controller, bytes->data(), bytes->size(), true); RETURN_IF_EXCEPTION(scope, nullptr); RELEASE_AND_RETURN(scope, promiseFulfilledWith(globalObject, JSC::jsUndefined())); }); diff --git a/src/jsc/bindings/webcore/streams/JSTransformStream.cpp b/src/jsc/bindings/webcore/streams/JSTransformStream.cpp index 8ea159364687..b5209f9f9986 100644 --- a/src/jsc/bindings/webcore/streams/JSTransformStream.cpp +++ b/src/jsc/bindings/webcore/streams/JSTransformStream.cpp @@ -187,7 +187,7 @@ JSC_DEFINE_HOST_FUNCTION(jsTransformStreamPrototype_inspectCustom, (JSGlobalObje { auto& vm = JSC::getVM(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); - JSValue thisValue = callFrame->thisValue(); + JSValue thisValue = callFrame->thisValue().toThis(lexicalGlobalObject, JSC::ECMAMode::strict()); auto* thisObject = dynamicDowncast(thisValue); if (!thisObject) [[unlikely]] return JSValue::encode(thisValue); diff --git a/src/jsc/bindings/webcore/streams/JSTransformStreamDefaultController.cpp b/src/jsc/bindings/webcore/streams/JSTransformStreamDefaultController.cpp index 9cf34b2757dd..e6dc84883ee7 100644 --- a/src/jsc/bindings/webcore/streams/JSTransformStreamDefaultController.cpp +++ b/src/jsc/bindings/webcore/streams/JSTransformStreamDefaultController.cpp @@ -147,7 +147,7 @@ JSC_DEFINE_HOST_FUNCTION(jsTransformStreamDefaultControllerPrototype_inspectCust { auto& vm = JSC::getVM(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); - JSValue thisValue = callFrame->thisValue(); + JSValue thisValue = callFrame->thisValue().toThis(lexicalGlobalObject, JSC::ECMAMode::strict()); auto* thisObject = dynamicDowncast(thisValue); if (!thisObject) [[unlikely]] return JSValue::encode(thisValue); diff --git a/src/jsc/bindings/webcore/streams/JSWritableStream.cpp b/src/jsc/bindings/webcore/streams/JSWritableStream.cpp index fdf3307a620d..e1babcbbab03 100644 --- a/src/jsc/bindings/webcore/streams/JSWritableStream.cpp +++ b/src/jsc/bindings/webcore/streams/JSWritableStream.cpp @@ -166,7 +166,7 @@ JSC_DEFINE_HOST_FUNCTION(jsWritableStreamPrototype_inspectCustom, (JSGlobalObjec { auto& vm = JSC::getVM(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); - JSValue thisValue = callFrame->thisValue(); + JSValue thisValue = callFrame->thisValue().toThis(lexicalGlobalObject, JSC::ECMAMode::strict()); auto* thisObject = dynamicDowncast(thisValue); if (!thisObject) [[unlikely]] return JSValue::encode(thisValue); diff --git a/src/jsc/bindings/webcore/streams/JSWritableStreamDefaultController.cpp b/src/jsc/bindings/webcore/streams/JSWritableStreamDefaultController.cpp index 2d5c0c7ad64d..b319f19c9036 100644 --- a/src/jsc/bindings/webcore/streams/JSWritableStreamDefaultController.cpp +++ b/src/jsc/bindings/webcore/streams/JSWritableStreamDefaultController.cpp @@ -172,7 +172,7 @@ JSC_DEFINE_HOST_FUNCTION(jsWritableStreamDefaultControllerPrototype_inspectCusto { auto& vm = JSC::getVM(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); - JSValue thisValue = callFrame->thisValue(); + JSValue thisValue = callFrame->thisValue().toThis(lexicalGlobalObject, JSC::ECMAMode::strict()); auto* thisObject = dynamicDowncast(thisValue); if (!thisObject) [[unlikely]] return JSValue::encode(thisValue); diff --git a/src/jsc/bindings/webcore/streams/JSWritableStreamDefaultWriter.cpp b/src/jsc/bindings/webcore/streams/JSWritableStreamDefaultWriter.cpp index bfdeaed7ea4a..8ce2fd7df6c6 100644 --- a/src/jsc/bindings/webcore/streams/JSWritableStreamDefaultWriter.cpp +++ b/src/jsc/bindings/webcore/streams/JSWritableStreamDefaultWriter.cpp @@ -67,34 +67,20 @@ JSPromise* writableStreamDefaultWriterCloseWithErrorPropagation(JSGlobalObject* void writableStreamDefaultWriterEnsureClosedPromiseRejected(JSGlobalObject* globalObject, JSWritableStreamDefaultWriter* writer, JSValue error) { - auto& vm = getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); auto* closedPromise = writer->m_closedPromise.get(); - if (closedPromise->status() == JSPromise::Status::Pending) { - rejectPromise(globalObject, closedPromise, error); - RETURN_IF_EXCEPTION(scope, ); - } else { - closedPromise = promiseRejectedWith(globalObject, error); - RETURN_IF_EXCEPTION(scope, ); - writer->m_closedPromise.set(vm, writer, closedPromise); - } - markPromiseAsHandled(vm, closedPromise); + if (closedPromise->status() == JSPromise::Status::Pending) + rejectPromiseAsHandled(globalObject, closedPromise, error); + else + writer->m_closedPromise.set(getVM(globalObject), writer, promiseRejectedWithAsHandled(globalObject, error)); } void writableStreamDefaultWriterEnsureReadyPromiseRejected(JSGlobalObject* globalObject, JSWritableStreamDefaultWriter* writer, JSValue error) { - auto& vm = getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); auto* readyPromise = writer->m_readyPromise.get(); - if (readyPromise && readyPromise->status() == JSPromise::Status::Pending) { - rejectPromise(globalObject, readyPromise, error); - RETURN_IF_EXCEPTION(scope, ); - } else { - readyPromise = promiseRejectedWith(globalObject, error); - RETURN_IF_EXCEPTION(scope, ); - writer->m_readyPromise.set(vm, writer, readyPromise); - } - markPromiseAsHandled(vm, readyPromise); + if (readyPromise && readyPromise->status() == JSPromise::Status::Pending) + rejectPromiseAsHandled(globalObject, readyPromise, error); + else + writer->m_readyPromise.set(getVM(globalObject), writer, promiseRejectedWithAsHandled(globalObject, error)); } // Provably-non-throwing leaf: reads members and does queue arithmetic only. @@ -121,10 +107,9 @@ void writableStreamDefaultWriterRelease(JSGlobalObject* globalObject, JSWritable ASSERT(stream); ASSERT(stream->m_writer.get() == writer); JSValue releasedError = Bun::createError(globalObject, Bun::ErrorCode::ERR_INVALID_STATE_TypeError, "Invalid state: Writer has been released"_s); - writableStreamDefaultWriterEnsureReadyPromiseRejected(globalObject, writer, releasedError); RETURN_IF_EXCEPTION(scope, ); + writableStreamDefaultWriterEnsureReadyPromiseRejected(globalObject, writer, releasedError); writableStreamDefaultWriterEnsureClosedPromiseRejected(globalObject, writer, releasedError); - RETURN_IF_EXCEPTION(scope, ); stream->m_writer.clear(); writer->m_stream.clear(); } @@ -286,7 +271,7 @@ JSC_DEFINE_HOST_FUNCTION(jsWritableStreamDefaultWriterPrototype_inspectCustom, ( { auto& vm = JSC::getVM(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); - JSValue thisValue = callFrame->thisValue(); + JSValue thisValue = callFrame->thisValue().toThis(lexicalGlobalObject, JSC::ECMAMode::strict()); auto* thisObject = dynamicDowncast(thisValue); if (!thisObject) [[unlikely]] return JSValue::encode(thisValue); diff --git a/src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp b/src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp index 05dbedda9385..f3a7fce023b1 100644 --- a/src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp +++ b/src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp @@ -6,6 +6,7 @@ #include "BunClientData.h" #include "BunStreamSource.h" +#include "JSBuffer.h" #include "JSDOMWrapperCache.h" #include "JSDirectStreamController.h" #include "JSDirectStreamSource.h" @@ -397,9 +398,7 @@ void readableStreamError(JSGlobalObject* globalObject, JSReadableStream* stream, } } Bun::attachAsyncStackFromPromise(globalObject, error, awaited); - rejectPromise(globalObject, reader->m_closedPromise.get(), error); - RETURN_IF_EXCEPTION(scope, void()); - markPromiseAsHandled(vm, reader->m_closedPromise.get()); + rejectPromiseAsHandled(globalObject, reader->m_closedPromise.get(), error); if (!reader->isBYOB()) RELEASE_AND_RETURN(scope, readableStreamDefaultReaderErrorReadRequests(globalObject, static_cast(reader), error)); RELEASE_AND_RETURN(scope, readableStreamBYOBReaderErrorReadIntoRequests(globalObject, static_cast(reader), error)); @@ -486,31 +485,60 @@ JSPromise* readableStreamCancel(JSGlobalObject* globalObject, JSReadableStream* return result; } +// ReadableStreamCancel for a cancel that a link deferred to a microtask. The cancel was requested first, so a later error doesn't reject it. +static JSPromise* readableStreamCancelDeferred(JSGlobalObject* globalObject, JSReadableStream* stream, JSValue reason) +{ + auto scope = DECLARE_THROW_SCOPE(getVM(globalObject)); + if (stream->m_state != ReadableStreamState::Errored) + RELEASE_AND_RETURN(scope, readableStreamCancel(globalObject, stream, reason)); + stream->m_disturbed = true; + RELEASE_AND_RETURN(scope, promiseFulfilledWith(globalObject, JSC::jsUndefined())); +} + // ReadableStreamReaderGenericInitialize(reader, stream) void readableStreamReaderGenericInitialize(JSGlobalObject* globalObject, JSReadableStreamReaderBase* reader, JSReadableStream* stream) { auto& vm = getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); - reader->m_stream.set(vm, reader, stream); - stream->m_reader.set(vm, stream, reader); + // Link the reader and the stream last, because the stream settles its linked reader's promise. + JSPromise* closedPromise = nullptr; switch (stream->m_state) { case ReadableStreamState::Readable: - reader->m_closedPromise.set(vm, reader, JSPromise::create(vm, globalObject->promiseStructure())); + closedPromise = JSPromise::create(vm, globalObject->promiseStructure()); + break; + case ReadableStreamState::Closed: + closedPromise = promiseFulfilledWith(globalObject, JSC::jsUndefined()); + break; + case ReadableStreamState::Errored: + closedPromise = promiseRejectedWithAsHandled(globalObject, stream->m_storedError.get()); + break; + } + reader->m_closedPromise.set(vm, reader, closedPromise); + reader->m_stream.set(vm, reader, stream); + stream->m_reader.set(vm, stream, reader); +} + +// Bun: drop the native handle's event-loop ref when its consumer releases the lock. +static void releaseNativeSourceRef(JSGlobalObject* globalObject, JSReadableStream* stream) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + if (stream->m_controllerKind != ControllerKind::Default) return; - case ReadableStreamState::Closed: { - auto* closedPromise = promiseFulfilledWith(globalObject, JSC::jsUndefined()); - RETURN_IF_EXCEPTION(scope, void()); - reader->m_closedPromise.set(vm, reader, closedPromise); + auto* controller = defaultControllerOf(stream); + if (controller->m_algorithms.kind != SourceKind::Native) return; - } - case ReadableStreamState::Errored: { - auto* closedPromise = promiseRejectedWith(globalObject, stream->m_storedError.get()); - RETURN_IF_EXCEPTION(scope, void()); - reader->m_closedPromise.set(vm, reader, closedPromise); - markPromiseAsHandled(vm, closedPromise); + auto* handle = uncheckedDowncast(controller->m_algorithms.algorithmContext.get())->handle(); + if (!handle) return; - } - } + JSValue updateRef = handle->getIfPropertyExists(globalObject, builtinNames(vm).updateRefPublicName()); + RETURN_IF_EXCEPTION(scope, void()); + if (!updateRef || !updateRef.isCallable()) + return; + MarkedArgumentBuffer args; + args.append(jsBoolean(false)); + ASSERT(!args.hasOverflowed()); + scope.release(); + JSC::call(globalObject, updateRef, JSC::getCallData(updateRef), handle, args); } // ReadableStreamReaderGenericRelease(reader) @@ -522,56 +550,41 @@ void readableStreamReaderGenericRelease(JSGlobalObject* globalObject, JSReadable ASSERT(stream); ASSERT(stream->m_reader.get() == reader); + // Do everything that can throw first, so the release steps don't need exception checks between them. JSObject* releaseError = Bun::createError(globalObject, Bun::ErrorCode::ERR_INVALID_STATE_TypeError, "Invalid state: Reader released"_s); RETURN_IF_EXCEPTION(scope, void()); - if (stream->m_state == ReadableStreamState::Readable) { - rejectPromise(globalObject, reader->m_closedPromise.get(), releaseError); - RETURN_IF_EXCEPTION(scope, void()); - } else { - auto* rejected = promiseRejectedWith(globalObject, releaseError); - RETURN_IF_EXCEPTION(scope, void()); - reader->m_closedPromise.set(vm, reader, rejected); + JSPromise* pendingDirectRead = nullptr; + JSObject* pendingReadError = nullptr; + if (stream->m_controllerKind == ControllerKind::Direct) { + pendingDirectRead = uncheckedDowncast(stream->m_controller.get())->m_pendingRead.get(); + if (pendingDirectRead) { + pendingReadError = Bun::createError(globalObject, Bun::ErrorCode::ERR_INVALID_STATE_TypeError, "Invalid state: Releasing reader"_s); + RETURN_IF_EXCEPTION(scope, void()); + } } - markPromiseAsHandled(vm, reader->m_closedPromise.get()); + releaseNativeSourceRef(globalObject, stream); + RETURN_IF_EXCEPTION(scope, void()); + + if (stream->m_state == ReadableStreamState::Readable) + rejectPromiseAsHandled(globalObject, reader->m_closedPromise.get(), releaseError); + else + reader->m_closedPromise.set(vm, reader, promiseRejectedWithAsHandled(globalObject, releaseError)); switch (stream->m_controllerKind) { case ControllerKind::None: case ControllerKind::NativeSink: break; - case ControllerKind::Direct: { + case ControllerKind::Direct: // A direct stream's in-flight read lives on the controller (not in the reader's // read-request queue), so releasing the reader must settle it here. - auto* controller = uncheckedDowncast(stream->m_controller.get()); - if (auto* pendingRead = controller->m_pendingRead.get()) { - controller->m_pendingRead.clear(); - JSObject* pendingReadError = Bun::createError(globalObject, Bun::ErrorCode::ERR_INVALID_STATE_TypeError, "Invalid state: Releasing reader"_s); - RETURN_IF_EXCEPTION(scope, void()); - pendingRead->reject(vm, pendingReadError); - RETURN_IF_EXCEPTION(scope, void()); + if (pendingDirectRead) { + uncheckedDowncast(stream->m_controller.get())->m_pendingRead.clear(); + pendingDirectRead->reject(vm, pendingReadError); } break; - } - case ControllerKind::Default: { - auto* controller = defaultControllerOf(stream); - controller->releaseSteps(); - // Bun: drop the native handle's event-loop ref when its consumer releases the lock. - if (controller->m_algorithms.kind == SourceKind::Native) { - const auto* adapter = uncheckedDowncast(controller->m_algorithms.algorithmContext.get()); - if (auto* handle = adapter->handle()) { - JSValue updateRef = handle->getIfPropertyExists(globalObject, builtinNames(vm).updateRefPublicName()); - RETURN_IF_EXCEPTION(scope, void()); - if (updateRef && updateRef.isCallable()) { - auto callData = JSC::getCallData(updateRef); - MarkedArgumentBuffer args; - args.append(jsBoolean(false)); - ASSERT(!args.hasOverflowed()); - JSC::call(globalObject, updateRef, callData, handle, args); - RETURN_IF_EXCEPTION(scope, void()); - } - } - } + case ControllerKind::Default: + defaultControllerOf(stream)->releaseSteps(); break; - } case ControllerKind::Byte: byteControllerOf(stream)->releaseSteps(); break; @@ -1023,6 +1036,13 @@ JSPromise* textDecodeCancelAlgorithm(JSGlobalObject* globalObject, JSReadableStr auto* reader = dynamicDowncast(controller->m_algorithms.algorithmContext.get()); if (!reader || !reader->m_stream) RELEASE_AND_RETURN(scope, promiseFulfilledWith(globalObject, JSC::jsUndefined())); + if (reader->m_stream->m_state == ReadableStreamState::Readable && streamLinkMustDefer(vm)) [[unlikely]] { + // The caller clears algorithmContext once this returns, so pass the reader to the job. + auto* result = JSPromise::create(vm, globalObject->promiseStructure()); + auto* context = InternalFieldTuple::create(vm, globalObject->internalFieldTupleStructure(), reader, result); + queueReactionJob(vm, globalObject, JSStreamsRuntime::from(globalObject)->onTextDecodeCancelDeferred(), reason, context); + return result; + } auto* result = readableStreamReaderGenericCancel(globalObject, reader, reason); RETURN_IF_EXCEPTION(scope, nullptr); readableStreamDefaultReaderRelease(globalObject, reader); @@ -1030,6 +1050,21 @@ JSPromise* textDecodeCancelAlgorithm(JSGlobalObject* globalObject, JSReadableStr return result; } +static void textDecodeCancelDeferred(JSGlobalObject* globalObject, JSValue reason, InternalFieldTuple* context) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* reader = uncheckedDowncast(context->getInternalField(0)); + JSValue cancelResult = jsUndefined(); + if (auto* stream = reader->m_stream.get()) { + cancelResult = readableStreamCancelDeferred(globalObject, stream, reason); + RETURN_IF_EXCEPTION(scope, void()); + readableStreamDefaultReaderRelease(globalObject, reader); + RETURN_IF_EXCEPTION(scope, void()); + } + RELEASE_AND_RETURN(scope, resolvePromise(globalObject, uncheckedDowncast(context->getInternalField(1)), cancelResult)); +} + void textDecodeReadRequestChunkSteps(JSGlobalObject* globalObject, JSReadableStreamDefaultController* controller, JSValue chunk) { auto& vm = getVM(globalObject); @@ -1039,12 +1074,17 @@ void textDecodeReadRequestChunkSteps(JSGlobalObject* globalObject, JSReadableStr // WebIDL "get a copy of the bytes held by the buffer source": a detached // buffer is still a BufferSource whose bytes are the empty sequence. std::span bytes; + bool isShared = false; if (auto* view = dynamicDowncast(chunk)) { - if (!view->isDetached()) + if (!view->isDetached()) { bytes = view->span(); + isShared = view->isShared(); + } } else if (auto* buffer = dynamicDowncast(chunk)) { - if (buffer->impl() && !buffer->impl()->isDetached()) + if (buffer->impl() && !buffer->impl()->isDetached()) { bytes = buffer->impl()->span(); + isShared = buffer->impl()->isShared(); + } } else { auto* error = createTypeError(globalObject, "Body.textStream() received a chunk that is not a BufferSource"_s); RETURN_IF_EXCEPTION(scope, void()); @@ -1066,7 +1106,13 @@ void textDecodeReadRequestChunkSteps(JSGlobalObject* globalObject, JSReadableStr } return; } - auto* decoded = streamingUTF8Decode(globalObject, bytes, controller->m_algorithms.textDecodeState, /* flush */ false); + JSString* decoded; + if (isShared) [[unlikely]] { + auto copy = Bun::copySharedBytes(globalObject, scope, bytes); + RETURN_IF_EXCEPTION(scope, void()); + decoded = streamingUTF8Decode(globalObject, copy.span(), controller->m_algorithms.textDecodeState, /* flush */ false); + } else + decoded = streamingUTF8Decode(globalObject, bytes, controller->m_algorithms.textDecodeState, /* flush */ false); RETURN_IF_EXCEPTION(scope, void()); if (!decoded || !decoded->length()) { // No output from this chunk (held back as an incomplete sequence). Arm @@ -1133,6 +1179,10 @@ JSPromise* defaultTeeCancelAlgorithm(JSGlobalObject* globalObject, JSStreamTeeSt if ((!branch && teeState->m_canceled2) || (branch && teeState->m_canceled1)) { JSArray* compositeReason = constructArrayPair(globalObject, teeState->reason1(), teeState->reason2()); RETURN_IF_EXCEPTION(scope, nullptr); + if (teeState->stream()->m_state == ReadableStreamState::Readable && streamLinkMustDefer(vm)) [[unlikely]] { + queueReactionJob(vm, globalObject, JSStreamsRuntime::from(globalObject)->onTeeCancelDeferred(), compositeReason, teeState); + return teeState->cancelPromise(); + } auto* cancelResult = readableStreamCancel(globalObject, teeState->stream(), compositeReason); RETURN_IF_EXCEPTION(scope, nullptr); resolvePromise(globalObject, teeState->cancelPromise(), cancelResult); @@ -1141,6 +1191,15 @@ JSPromise* defaultTeeCancelAlgorithm(JSGlobalObject* globalObject, JSStreamTeeSt return teeState->cancelPromise(); } +static void teeCancelDeferred(JSGlobalObject* globalObject, JSValue compositeReason, JSStreamTeeState* teeState) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* cancelResult = readableStreamCancelDeferred(globalObject, teeState->stream(), compositeReason); + RETURN_IF_EXCEPTION(scope, void()); + RELEASE_AND_RETURN(scope, resolvePromise(globalObject, teeState->cancelPromise(), cancelResult)); +} + // Spec chunk steps 3.2 (an abrupt cloneResult): error both branches and cancel the source. template static EncodedJSValue teeAbortWithError(JSGlobalObject* globalObject, JSStreamTeeState* teeState, Controller* controller1, Controller* controller2, JSValue thrown, ErrorController errorController) @@ -1572,4 +1631,16 @@ JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onByteTeeReaderClosedRejected, (JSG return Streams::byteTeeReaderClosedRejected(globalObject, callFrame->argument(0), uncheckedDowncast(callFrame->argument(1))); } +JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onTextDecodeCancelDeferred, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto* context = uncheckedDowncast(callFrame->argument(1)); + return Streams::enterStreams(globalObject, [&] { Streams::textDecodeCancelDeferred(globalObject, callFrame->argument(0), context); }, [&](JSValue error) { Streams::rejectPromise(globalObject, uncheckedDowncast(context->getInternalField(1)), error); }); +} + +JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onTeeCancelDeferred, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto* teeState = uncheckedDowncast(callFrame->argument(1)); + return Streams::enterStreams(globalObject, [&] { Streams::teeCancelDeferred(globalObject, callFrame->argument(0), teeState); }, [&](JSValue error) { Streams::rejectPromise(globalObject, teeState->cancelPromise(), error); }); +} + } // namespace WebCore diff --git a/src/jsc/bindings/webcore/streams/StreamQueue.h b/src/jsc/bindings/webcore/streams/StreamQueue.h index 18548163013f..a168d3dd194c 100644 --- a/src/jsc/bindings/webcore/streams/StreamQueue.h +++ b/src/jsc/bindings/webcore/streams/StreamQueue.h @@ -69,6 +69,24 @@ struct ByteQueueEntry { struct SourceAlgorithmSlots { // Which arm runs pull/cancel. SourceKind kind { SourceKind::Nothing }; + // True if that arm pulls and cancels another stream natively (see streamLinkMustDefer). + bool linksAnotherStream() const + { + switch (kind) { + case SourceKind::TeeBranch: + case SourceKind::ByteTeeBranch: + case SourceKind::TextDecode: + return true; + case SourceKind::JavaScript: + case SourceKind::Nothing: + case SourceKind::Transform: + case SourceKind::FromIterable: + case SourceKind::CrossRealm: + case SourceKind::Native: + return false; + } + RELEASE_ASSERT_NOT_REACHED(); + } // TeeBranch / ByteTeeBranch only: which branch this controller is (0 or 1). uint8_t teeBranchIndex { 0 }; // TextDecode kind only: the inline streaming-UTF-8 decode state. diff --git a/src/jsc/bindings/webcore/streams/StreamsForward.h b/src/jsc/bindings/webcore/streams/StreamsForward.h index 13d95fa8ed8d..2cc8d1108213 100644 --- a/src/jsc/bindings/webcore/streams/StreamsForward.h +++ b/src/jsc/bindings/webcore/streams/StreamsForward.h @@ -200,6 +200,12 @@ enum class ReadIntoRequestKind : uint8_t { ByteTee, // the byte tee's BYOB read-into request: context = the JSStreamTeeState }; +// A step that was already deferred to a microtask passes No, because it has to make progress (see streamLinkMustDefer). +enum class MayDefer : bool { + No, + Yes, +}; + // Bun `type:"direct"` // The 3 direct sink flavors carried by ONE JSDirectStreamController. diff --git a/src/jsc/bindings/webcore/streams/WebStreamsExports.cpp b/src/jsc/bindings/webcore/streams/WebStreamsExports.cpp index 425e35a43e31..470f890bc075 100644 --- a/src/jsc/bindings/webcore/streams/WebStreamsExports.cpp +++ b/src/jsc/bindings/webcore/streams/WebStreamsExports.cpp @@ -224,6 +224,17 @@ static WriteBarrier* lockedStreamSlot(JSCell* handle) return nullptr; } +static void lockNative(JSC::VM& vm, JSReadableStream* stream) +{ + stream->m_disturbed = true; + stream->m_lockedWithoutReader = true; + JSValue handle = stream->m_nativePtr.get(); + if (handle.isEmpty() || !handle.isCell()) + return; + if (auto* slot = lockedStreamSlot(handle.asCell())) + slot->set(vm, handle.asCell(), stream); +} + // A native sink (fetch body / S3 / FileSink) has attached directly without a reader. // Mark the stream disturbed+locked so .locked, .getReader(), and the body-mixin // disturbed checks behave as they do after readStreamIntoSink acquires a reader. @@ -232,13 +243,19 @@ extern "C" void ReadableStream__lockNative(JSC::EncodedJSValue possibleReadableS auto* stream = dynamicDowncast(JSValue::decode(possibleReadableStream)); if (!stream) [[unlikely]] return; - stream->m_disturbed = true; - stream->m_lockedWithoutReader = true; - JSValue handle = stream->m_nativePtr.get(); - if (handle.isEmpty() || !handle.isCell()) - return; - if (auto* slot = lockedStreamSlot(handle.asCell())) - slot->set(JSC::getVM(globalObject), handle.asCell(), stream); + lockNative(JSC::getVM(globalObject), stream); +} + +// Like ReadableStream__lockNative, for a sink that's about to connect itself to the native source. It only +// locks a stream that hasn't started and isn't locked, because a started source already feeds a controller. +// Otherwise it returns false without changing anything, and the caller falls back to readStreamIntoSink. +extern "C" bool ReadableStream__tryLockNative(JSC::EncodedJSValue possibleReadableStream, Zig::GlobalObject* globalObject) +{ + auto* stream = dynamicDowncast(JSValue::decode(possibleReadableStream)); + if (!stream || stream->m_bunMode != BunStreamMode::NativePending || isReadableStreamLocked(stream)) + return false; + lockNative(JSC::getVM(globalObject), stream); + return true; } // The source of a stream that ReadableStream__lockNative locked dropped its sink: error the stream with `reason`, or close it if `reason` is empty. diff --git a/src/jsc/bindings/webcore/streams/WebStreamsInspectCustom.cpp b/src/jsc/bindings/webcore/streams/WebStreamsInspectCustom.cpp index 3cb0d8daeebb..fb56da6a039a 100644 --- a/src/jsc/bindings/webcore/streams/WebStreamsInspectCustom.cpp +++ b/src/jsc/bindings/webcore/streams/WebStreamsInspectCustom.cpp @@ -41,7 +41,8 @@ EncodedJSValue customInspect(JSGlobalObject* lexicalGlobalObject, CallFrame* cal for (size_t i = 0; i < names.size(); ++i) { JSValue v = options->get(lexicalGlobalObject, names[i]); RETURN_IF_EXCEPTION(scope, {}); - opts->putDirect(vm, names[i], v, 0); + opts->putDirectMayBeIndex(lexicalGlobalObject, names[i], v); + RETURN_IF_EXCEPTION(scope, {}); } JSValue optionsDepth = options->get(lexicalGlobalObject, Identifier::fromString(vm, "depth"_s)); RETURN_IF_EXCEPTION(scope, {}); diff --git a/src/jsc/bindings/webcore/streams/WebStreamsInternals.h b/src/jsc/bindings/webcore/streams/WebStreamsInternals.h index a4a015b708c2..fee997e682fa 100644 --- a/src/jsc/bindings/webcore/streams/WebStreamsInternals.h +++ b/src/jsc/bindings/webcore/streams/WebStreamsInternals.h @@ -33,6 +33,7 @@ #include #include #include "helpers.h" +#include "StringSizeLimit.h" #include namespace WebCore { @@ -42,14 +43,8 @@ class AbortSignal; namespace Bun { namespace WebStreams { -// Building a string past this limit would abort the process inside WTF; text consumers -// check it and throw a catchable out-of-memory error instead. Mirrors the predicate -// Bun's string constructors use (helpers.h), including the synthetic limit that -// `bun:internal-for-testing` can lower. -inline bool exceedsStringLimit(size_t length) -{ - return length > Bun__stringSyntheticAllocationLimit || length > WTF::StringImpl::MaxLength; -} +// Text consumers check the string length limit and throw a catchable out-of-memory error. +using Bun::exceedsStringLimit; // Reduce noise: every class name below is a WebCore JS cell (StreamsForward.h). using WebCore::JSCrossRealmTransformState; @@ -235,6 +230,10 @@ void resolvePromise(JSC::JSGlobalObject*, JSC::JSPromise*, JSC::JSValue); // use void rejectPromise(JSC::JSGlobalObject*, JSC::JSPromise*, JSC::JSValue); // userJS: no — WebStreamsMisc.cpp // "Set promise.[[PromiseIsHandled]] to true" void markPromiseAsHandled(JSC::VM&, JSC::JSPromise*); // userJS: no — WebStreamsMisc.cpp +// Rejects as handled in one step. A termination between reject and mark would report the rejection as unhandled. +void rejectPromiseAsHandled(JSC::JSGlobalObject*, JSC::JSPromise*, JSC::JSValue); // userJS: no, never throws — WebStreamsMisc.cpp +// "a promise rejected with r" whose [[PromiseIsHandled]] is true from the start. +JSC::JSPromise* promiseRejectedWithAsHandled(JSC::JSGlobalObject*, JSC::JSValue); // userJS: no, never throws — WebStreamsMisc.cpp // {value,done} results: use JSC::createIteratorResultObject // (; VM-cached structure). @@ -279,7 +278,7 @@ JSReadableStreamBYOBReader* acquireReadableStreamBYOBReader(JSC::JSGlobalObject* void setUpReadableStreamDefaultReader(JSC::JSGlobalObject*, JSReadableStreamDefaultReader*, JSReadableStream*); // userJS: no — ReadableStreamOperations.cpp void setUpReadableStreamBYOBReader(JSC::JSGlobalObject*, JSReadableStreamBYOBReader*, JSReadableStream*); // userJS: no — ReadableStreamOperations.cpp JSC::JSPromise* readableStreamReaderGenericCancel(JSC::JSGlobalObject*, JSReadableStreamReaderBase*, JSC::JSValue reason); // userJS: yes — ReadableStreamOperations.cpp -void readableStreamReaderGenericInitialize(JSC::JSGlobalObject*, JSReadableStreamReaderBase*, JSReadableStream*); // userJS: no — ReadableStreamOperations.cpp +void readableStreamReaderGenericInitialize(JSC::JSGlobalObject*, JSReadableStreamReaderBase*, JSReadableStream*); // userJS: no, never throws — ReadableStreamOperations.cpp void readableStreamReaderGenericRelease(JSC::JSGlobalObject*, JSReadableStreamReaderBase*); // userJS: no (also runs Bun's native-handle updateRef(false) gate) — ReadableStreamOperations.cpp // Stream-level state ops. @@ -363,6 +362,8 @@ void readableStreamDefaultReaderRead(JSC::JSGlobalObject*, JSReadableStreamDefau JSReadableStreamDefaultController* teeBranchDefaultController(JSReadableStream* branch); // userJS: no — ReadableStreamOperations.cpp JSReadableByteStreamController* teeBranchByteController(JSReadableStream* branch); // userJS: no — ReadableStreamOperations.cpp void queueStreamsMicrotask(JSC::JSGlobalObject*, JSC::JSFunction* handler, JSC::JSValue value, JSC::JSValue context); // userJS: no — WebStreamsMisc.cpp +// True if a chain of tees or text streams has used too much stack for the next link. The link then continues from a microtask. +bool streamLinkMustDefer(JSC::VM&); // userJS: no (pure) — WebStreamsMisc.cpp JSC::JSValue readableStreamDefaultReaderTryReadFromQueue(JSC::JSGlobalObject*, JSReadableStreamDefaultReader*); // userJS: yes (a drained queue can pull) — JSReadableStreamDefaultReader.cpp void readableStreamDefaultReaderRelease(JSC::JSGlobalObject*, JSReadableStreamDefaultReader*); // userJS: yes (error-steps dispatch) — JSReadableStreamDefaultReader.cpp void readableStreamDefaultReaderErrorReadRequests(JSC::JSGlobalObject*, JSReadableStreamDefaultReader*, JSC::JSValue error); // userJS: yes — JSReadableStreamDefaultReader.cpp @@ -391,7 +392,7 @@ void readableStreamBYOBReaderErrorReadIntoRequests(JSC::JSGlobalObject*, JSReada // JSReadableStreamDefaultController.cpp -void readableStreamDefaultControllerCallPullIfNeeded(JSC::JSGlobalObject*, JSReadableStreamDefaultController*); // userJS: yes (user pull) — JSReadableStreamDefaultController.cpp +void readableStreamDefaultControllerCallPullIfNeeded(JSC::JSGlobalObject*, JSReadableStreamDefaultController*, MayDefer = MayDefer::Yes); // userJS: yes (user pull) — JSReadableStreamDefaultController.cpp bool readableStreamDefaultControllerShouldCallPull(JSReadableStreamDefaultController*); // userJS: no — JSReadableStreamDefaultController.cpp void readableStreamDefaultControllerClearAlgorithms(JSReadableStreamDefaultController*); // userJS: no — JSReadableStreamDefaultController.cpp void readableStreamDefaultControllerClose(JSC::JSGlobalObject*, JSReadableStreamDefaultController*); // userJS: yes — JSReadableStreamDefaultController.cpp @@ -403,7 +404,7 @@ bool readableStreamDefaultControllerCanCloseOrEnqueue(JSReadableStreamDefaultCon // JSReadableByteStreamController.cpp -void readableByteStreamControllerCallPullIfNeeded(JSC::JSGlobalObject*, JSReadableByteStreamController*); // userJS: yes (user pull) — JSReadableByteStreamController.cpp +void readableByteStreamControllerCallPullIfNeeded(JSC::JSGlobalObject*, JSReadableByteStreamController*, MayDefer = MayDefer::Yes); // userJS: yes (user pull) — JSReadableByteStreamController.cpp bool readableByteStreamControllerShouldCallPull(JSReadableByteStreamController*); // userJS: no — JSReadableByteStreamController.cpp void readableByteStreamControllerClearAlgorithms(JSReadableByteStreamController*); // userJS: no — JSReadableByteStreamController.cpp void readableByteStreamControllerClearPendingPullIntos(JSReadableByteStreamController*); // userJS: no — JSReadableByteStreamController.cpp @@ -463,15 +464,15 @@ bool writableStreamCloseQueuedOrInFlight(JSWritableStream*); // userJS: no — W void writableStreamDealWithRejection(JSC::JSGlobalObject*, JSWritableStream*, JSC::JSValue error); // userJS: yes — WritableStreamOperations.cpp void writableStreamStartErroring(JSC::JSGlobalObject*, JSWritableStream*, JSC::JSValue reason); // userJS: yes — WritableStreamOperations.cpp void writableStreamFinishErroring(JSC::JSGlobalObject*, JSWritableStream*); // userJS: yes (user abort algorithm) — WritableStreamOperations.cpp -void writableStreamFinishInFlightWrite(JSC::JSGlobalObject*, JSWritableStream*); // userJS: no — WritableStreamOperations.cpp +void writableStreamFinishInFlightWrite(JSC::JSGlobalObject*, JSWritableStream*); // userJS: no, never throws — WritableStreamOperations.cpp void writableStreamFinishInFlightWriteWithError(JSC::JSGlobalObject*, JSWritableStream*, JSC::JSValue error); // userJS: yes — WritableStreamOperations.cpp -void writableStreamFinishInFlightClose(JSC::JSGlobalObject*, JSWritableStream*); // userJS: no — WritableStreamOperations.cpp +void writableStreamFinishInFlightClose(JSC::JSGlobalObject*, JSWritableStream*); // userJS: no, never throws — WritableStreamOperations.cpp void writableStreamFinishInFlightCloseWithError(JSC::JSGlobalObject*, JSWritableStream*, JSC::JSValue error); // userJS: yes — WritableStreamOperations.cpp bool writableStreamHasOperationMarkedInFlight(JSWritableStream*); // userJS: no — WritableStreamOperations.cpp void writableStreamMarkCloseRequestInFlight(JSC::VM&, JSWritableStream*); // userJS: no — WritableStreamOperations.cpp void writableStreamMarkFirstWriteRequestInFlight(JSC::VM&, JSWritableStream*); // userJS: no — WritableStreamOperations.cpp -void writableStreamRejectCloseAndClosedPromiseIfNeeded(JSC::JSGlobalObject*, JSWritableStream*); // userJS: no — WritableStreamOperations.cpp -void writableStreamUpdateBackpressure(JSC::JSGlobalObject*, JSWritableStream*, bool backpressure); // userJS: no — WritableStreamOperations.cpp +void writableStreamRejectCloseAndClosedPromiseIfNeeded(JSC::JSGlobalObject*, JSWritableStream*); // userJS: no, never throws — WritableStreamOperations.cpp +void writableStreamUpdateBackpressure(JSC::JSGlobalObject*, JSWritableStream*, bool backpressure); // userJS: no, never throws — WritableStreamOperations.cpp void setUpWritableStreamDefaultController(JSC::JSGlobalObject*, JSWritableStream*, JSWritableStreamDefaultController*, JSC::JSValue startResult, double highWaterMark); // userJS: yes (thenable startResult) — WritableStreamOperations.cpp void setUpWritableStreamDefaultControllerFromUnderlyingSink(JSC::JSGlobalObject*, JSWritableStream*, JSC::JSValue underlyingSink, const UnderlyingSinkDict&, double highWaterMark, JSC::JSObject* sizeAlgorithm); // userJS: yes (invokes the user `start`) — WritableStreamOperations.cpp @@ -480,8 +481,8 @@ void setUpWritableStreamDefaultControllerFromUnderlyingSink(JSC::JSGlobalObject* JSC::JSPromise* writableStreamDefaultWriterAbort(JSC::JSGlobalObject*, JSWritableStreamDefaultWriter*, JSC::JSValue reason); // userJS: yes — JSWritableStreamDefaultWriter.cpp JSC::JSPromise* writableStreamDefaultWriterClose(JSC::JSGlobalObject*, JSWritableStreamDefaultWriter*); // userJS: yes — JSWritableStreamDefaultWriter.cpp JSC::JSPromise* writableStreamDefaultWriterCloseWithErrorPropagation(JSC::JSGlobalObject*, JSWritableStreamDefaultWriter*); // userJS: yes — JSWritableStreamDefaultWriter.cpp -void writableStreamDefaultWriterEnsureClosedPromiseRejected(JSC::JSGlobalObject*, JSWritableStreamDefaultWriter*, JSC::JSValue error); // userJS: no — JSWritableStreamDefaultWriter.cpp -void writableStreamDefaultWriterEnsureReadyPromiseRejected(JSC::JSGlobalObject*, JSWritableStreamDefaultWriter*, JSC::JSValue error); // userJS: no — JSWritableStreamDefaultWriter.cpp +void writableStreamDefaultWriterEnsureClosedPromiseRejected(JSC::JSGlobalObject*, JSWritableStreamDefaultWriter*, JSC::JSValue error); // userJS: no, never throws — JSWritableStreamDefaultWriter.cpp +void writableStreamDefaultWriterEnsureReadyPromiseRejected(JSC::JSGlobalObject*, JSWritableStreamDefaultWriter*, JSC::JSValue error); // userJS: no, never throws — JSWritableStreamDefaultWriter.cpp std::optional writableStreamDefaultWriterGetDesiredSize(JSWritableStreamDefaultWriter*); // userJS: no (nullopt = spec null) — JSWritableStreamDefaultWriter.cpp void writableStreamDefaultWriterRelease(JSC::JSGlobalObject*, JSWritableStreamDefaultWriter*); // userJS: no — JSWritableStreamDefaultWriter.cpp JSC::JSPromise* writableStreamDefaultWriterWrite(JSC::JSGlobalObject*, JSWritableStreamDefaultWriter*, JSC::JSValue chunk); // userJS: yes (user size() FIRST, then re-checks [[stream]]) — JSWritableStreamDefaultWriter.cpp diff --git a/src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp b/src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp index d1ae2de1c4ec..50807f6a2016 100644 --- a/src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp +++ b/src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp @@ -21,6 +21,8 @@ #include #include #include +#include +#include namespace Bun { namespace WebStreams { @@ -171,6 +173,17 @@ void queueStreamsMicrotask(JSGlobalObject* globalObject, JSFunction* handler, JS globalObject->vm().queueMicrotask(WTF::move(task)); } +bool streamLinkMustDefer(JSC::VM& vm) +{ + // Leave room for the source's pull() or cancel() at the end of a chain. A JS call throws RangeError at the soft limit. +#if ASAN_ENABLED + static constexpr size_t headroom = 384 * KB; +#else + static constexpr size_t headroom = 128 * KB; +#endif + return reinterpret_cast(currentStackPointer()) < reinterpret_cast(vm.softStackLimit()) + headroom; +} + bool canTransferArrayBuffer(JSC::ArrayBuffer& buffer) { return !buffer.isDetached() && buffer.isDetachable(); @@ -465,6 +478,19 @@ void markPromiseAsHandled(VM&, JSPromise* promise) promise->markAsHandled(); } +void rejectPromiseAsHandled(JSGlobalObject* globalObject, JSPromise* promise, JSValue reason) +{ + promise->rejectAsHandled(getVM(globalObject), reason); +} + +JSPromise* promiseRejectedWithAsHandled(JSGlobalObject* globalObject, JSValue reason) +{ + auto& vm = getVM(globalObject); + auto* promise = JSPromise::create(vm, globalObject->promiseStructure()); + promise->rejectAsHandled(vm, reason); + return promise; +} + // The stream-level closed promise. The Pending guard makes every settle site unconditionally // safe: a terminal transition can only run once, but the promise may already have been created // in a terminal state by webStreamClosedPromise(). diff --git a/src/jsc/bindings/webcore/streams/WritableStreamOperations.cpp b/src/jsc/bindings/webcore/streams/WritableStreamOperations.cpp index 4cdf30a77d0d..f2814abb3715 100644 --- a/src/jsc/bindings/webcore/streams/WritableStreamOperations.cpp +++ b/src/jsc/bindings/webcore/streams/WritableStreamOperations.cpp @@ -141,51 +141,36 @@ void setUpWritableStreamDefaultWriter(JSGlobalObject* globalObject, JSWritableSt throwException(globalObject, scope, Bun::createError(globalObject, Bun::ErrorCode::ERR_INVALID_STATE_TypeError, "Invalid state: WritableStream is locked"_s)); return; } - writer->m_stream.set(vm, writer, stream); - stream->m_writer.set(vm, stream, writer); - + // Link the writer and the stream last, because the stream settles its linked writer's promises. + JSPromise* ready = nullptr; + JSPromise* closed = nullptr; switch (stream->m_state) { - case WritableStreamState::Writable: { + case WritableStreamState::Writable: if (!writableStreamCloseQueuedOrInFlight(stream) && stream->m_backpressure) - writer->m_readyPromise.set(vm, writer, JSPromise::create(vm, globalObject->promiseStructure())); - else { - JSPromise* ready = promiseFulfilledWith(globalObject, JSC::jsUndefined()); - RETURN_IF_EXCEPTION(scope, ); - writer->m_readyPromise.set(vm, writer, ready); - } - writer->m_closedPromise.set(vm, writer, JSPromise::create(vm, globalObject->promiseStructure())); - return; - } - case WritableStreamState::Erroring: { - JSPromise* ready = promiseRejectedWith(globalObject, stream->m_storedError.get()); - RETURN_IF_EXCEPTION(scope, ); - markPromiseAsHandled(vm, ready); - writer->m_readyPromise.set(vm, writer, ready); - writer->m_closedPromise.set(vm, writer, JSPromise::create(vm, globalObject->promiseStructure())); - return; - } - case WritableStreamState::Closed: { - JSPromise* ready = promiseFulfilledWith(globalObject, JSC::jsUndefined()); - RETURN_IF_EXCEPTION(scope, ); - writer->m_readyPromise.set(vm, writer, ready); - JSPromise* closed = promiseFulfilledWith(globalObject, JSC::jsUndefined()); - RETURN_IF_EXCEPTION(scope, ); - writer->m_closedPromise.set(vm, writer, closed); - return; - } + ready = JSPromise::create(vm, globalObject->promiseStructure()); + else + ready = promiseFulfilledWith(globalObject, JSC::jsUndefined()); + closed = JSPromise::create(vm, globalObject->promiseStructure()); + break; + case WritableStreamState::Erroring: + ready = promiseRejectedWithAsHandled(globalObject, stream->m_storedError.get()); + closed = JSPromise::create(vm, globalObject->promiseStructure()); + break; + case WritableStreamState::Closed: + ready = promiseFulfilledWith(globalObject, JSC::jsUndefined()); + closed = promiseFulfilledWith(globalObject, JSC::jsUndefined()); + break; case WritableStreamState::Errored: { JSValue storedError = stream->m_storedError.get(); - JSPromise* ready = promiseRejectedWith(globalObject, storedError); - RETURN_IF_EXCEPTION(scope, ); - markPromiseAsHandled(vm, ready); - writer->m_readyPromise.set(vm, writer, ready); - JSPromise* closed = promiseRejectedWith(globalObject, storedError); - RETURN_IF_EXCEPTION(scope, ); - markPromiseAsHandled(vm, closed); - writer->m_closedPromise.set(vm, writer, closed); - return; + ready = promiseRejectedWithAsHandled(globalObject, storedError); + closed = promiseRejectedWithAsHandled(globalObject, storedError); + break; } } + writer->m_readyPromise.set(vm, writer, ready); + writer->m_closedPromise.set(vm, writer, closed); + writer->m_stream.set(vm, writer, stream); + stream->m_writer.set(vm, stream, writer); } JSPromise* writableStreamAbort(JSGlobalObject* globalObject, JSWritableStream* stream, JSValue reason) @@ -245,7 +230,6 @@ JSPromise* writableStreamClose(JSGlobalObject* globalObject, JSWritableStream* s // Materialize-then-resolve so a later `.ready` read sees fulfilled even when the lazy // slot was null (close() does not clear [[backpressure]]). resolvePromise(globalObject, writer->readyPromise(globalObject), jsUndefined()); - RETURN_IF_EXCEPTION(scope, nullptr); } writableStreamDefaultControllerClose(globalObject, stream->m_controller.get()); RETURN_IF_EXCEPTION(scope, nullptr); @@ -307,10 +291,8 @@ void writableStreamStartErroring(JSGlobalObject* globalObject, JSWritableStream* stream->m_state = WritableStreamState::Erroring; stream->m_storedError.set(vm, stream, reason); - if (auto* writer = stream->m_writer.get()) { + if (auto* writer = stream->m_writer.get()) writableStreamDefaultWriterEnsureReadyPromiseRejected(globalObject, writer, reason); - RETURN_IF_EXCEPTION(scope, ); - } // The in-flight write may be a native codec chunk still being drained into the readable, // which nothing on this side would ever finish; give it up so the erroring can complete. // An in-flight close (a flush) is left to finish: a close in progress wins over the abort. @@ -337,10 +319,8 @@ void writableStreamFinishErroring(JSGlobalObject* globalObject, JSWritableStream JSValue storedError = stream->m_storedError.get(); // Rejecting runs no user JS, so nothing can mutate the deque under this loop. - for (auto& writeRequest : stream->m_writeRequests) { + for (auto& writeRequest : stream->m_writeRequests) rejectPromise(globalObject, writeRequest.get(), storedError); - RETURN_IF_EXCEPTION(scope, ); - } { WTF::Locker locker { stream->cellLock() }; stream->m_writeRequests.clear(); @@ -356,7 +336,6 @@ void writableStreamFinishErroring(JSGlobalObject* globalObject, JSWritableStream if (wasAlreadyErroring) { rejectPromise(globalObject, abortPromise, storedError); - RETURN_IF_EXCEPTION(scope, ); RELEASE_AND_RETURN(scope, writableStreamRejectCloseAndClosedPromiseIfNeeded(globalObject, stream)); } @@ -370,11 +349,8 @@ void writableStreamFinishErroring(JSGlobalObject* globalObject, JSWritableStream void writableStreamFinishInFlightWrite(JSGlobalObject* globalObject, JSWritableStream* stream) { - auto& vm = getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); ASSERT(stream->m_inFlightWriteRequest); resolvePromise(globalObject, stream->m_inFlightWriteRequest.get(), jsUndefined()); - RETURN_IF_EXCEPTION(scope, ); stream->m_inFlightWriteRequest.clear(); } @@ -384,7 +360,6 @@ void writableStreamFinishInFlightWriteWithError(JSGlobalObject* globalObject, JS auto scope = DECLARE_THROW_SCOPE(vm); ASSERT(stream->m_inFlightWriteRequest); rejectPromise(globalObject, stream->m_inFlightWriteRequest.get(), error); - RETURN_IF_EXCEPTION(scope, ); stream->m_inFlightWriteRequest.clear(); ASSERT(stream->m_state == WritableStreamState::Writable || stream->m_state == WritableStreamState::Erroring); RELEASE_AND_RETURN(scope, writableStreamDealWithRejection(globalObject, stream, error)); @@ -393,10 +368,8 @@ void writableStreamFinishInFlightWriteWithError(JSGlobalObject* globalObject, JS void writableStreamFinishInFlightClose(JSGlobalObject* globalObject, JSWritableStream* stream) { auto& vm = getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); ASSERT(stream->m_inFlightCloseRequest); resolvePromise(globalObject, stream->m_inFlightCloseRequest.get(), jsUndefined()); - RETURN_IF_EXCEPTION(scope, ); stream->m_inFlightCloseRequest.clear(); WritableStreamState state = stream->m_state; @@ -405,16 +378,13 @@ void writableStreamFinishInFlightClose(JSGlobalObject* globalObject, JSWritableS stream->m_storedError.clear(); if (stream->m_pendingAbortRequest.promise) { resolvePromise(globalObject, stream->m_pendingAbortRequest.promise.get(), jsUndefined()); - RETURN_IF_EXCEPTION(scope, ); clearPendingAbortRequest(stream); } } stream->m_state = WritableStreamState::Closed; resolveStreamClosedPromise(vm, stream); - if (auto* writer = stream->m_writer.get()) { + if (auto* writer = stream->m_writer.get()) resolvePromise(globalObject, writer->m_closedPromise.get(), jsUndefined()); - RETURN_IF_EXCEPTION(scope, ); - } ASSERT(!stream->m_pendingAbortRequest.promise); ASSERT(!stream->m_storedError); } @@ -425,12 +395,10 @@ void writableStreamFinishInFlightCloseWithError(JSGlobalObject* globalObject, JS auto scope = DECLARE_THROW_SCOPE(vm); ASSERT(stream->m_inFlightCloseRequest); rejectPromise(globalObject, stream->m_inFlightCloseRequest.get(), error); - RETURN_IF_EXCEPTION(scope, ); stream->m_inFlightCloseRequest.clear(); ASSERT(stream->m_state == WritableStreamState::Writable || stream->m_state == WritableStreamState::Erroring); if (stream->m_pendingAbortRequest.promise) { rejectPromise(globalObject, stream->m_pendingAbortRequest.promise.get(), error); - RETURN_IF_EXCEPTION(scope, ); clearPendingAbortRequest(stream); } RELEASE_AND_RETURN(scope, writableStreamDealWithRejection(globalObject, stream, error)); @@ -463,37 +431,27 @@ void writableStreamMarkFirstWriteRequestInFlight(VM& vm, JSWritableStream* strea void writableStreamRejectCloseAndClosedPromiseIfNeeded(JSGlobalObject* globalObject, JSWritableStream* stream) { - auto& vm = getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); ASSERT(stream->m_state == WritableStreamState::Errored); JSValue storedError = stream->m_storedError.get(); if (stream->m_closeRequest) { ASSERT(!stream->m_inFlightCloseRequest); rejectPromise(globalObject, stream->m_closeRequest.get(), storedError); - RETURN_IF_EXCEPTION(scope, ); stream->m_closeRequest.clear(); } - if (auto* writer = stream->m_writer.get()) { - rejectPromise(globalObject, writer->m_closedPromise.get(), storedError); - RETURN_IF_EXCEPTION(scope, ); - markPromiseAsHandled(vm, writer->m_closedPromise.get()); - } + if (auto* writer = stream->m_writer.get()) + rejectPromiseAsHandled(globalObject, writer->m_closedPromise.get(), storedError); } void writableStreamUpdateBackpressure(JSGlobalObject* globalObject, JSWritableStream* stream, bool backpressure) { - auto& vm = getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); ASSERT(stream->m_state == WritableStreamState::Writable); ASSERT(!writableStreamCloseQueuedOrInFlight(stream)); auto* writer = stream->m_writer.get(); if (writer && backpressure != stream->m_backpressure) { if (backpressure) writer->m_readyPromise.clear(); - else if (auto* ready = writer->m_readyPromise.get()) { + else if (auto* ready = writer->m_readyPromise.get()) resolvePromise(globalObject, ready, jsUndefined()); - RETURN_IF_EXCEPTION(scope, ); - } } stream->m_backpressure = backpressure; } @@ -555,29 +513,21 @@ namespace WebCore { JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onWSAbortStepsFulfilled, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { - auto& vm = JSC::getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); auto* context = uncheckedDowncast(callFrame->uncheckedArgument(1)); auto* abortRequestPromise = uncheckedDowncast(context->getInternalField(0)); auto* stream = uncheckedDowncast(context->getInternalField(1)); Bun::WebStreams::resolvePromise(globalObject, abortRequestPromise, JSC::jsUndefined()); - RETURN_IF_EXCEPTION(scope, {}); Bun::WebStreams::writableStreamRejectCloseAndClosedPromiseIfNeeded(globalObject, stream); - RETURN_IF_EXCEPTION(scope, {}); return JSC::JSValue::encode(JSC::jsUndefined()); } JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onWSAbortStepsRejected, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { - auto& vm = JSC::getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); auto* context = uncheckedDowncast(callFrame->uncheckedArgument(1)); auto* abortRequestPromise = uncheckedDowncast(context->getInternalField(0)); auto* stream = uncheckedDowncast(context->getInternalField(1)); Bun::WebStreams::rejectPromise(globalObject, abortRequestPromise, callFrame->argument(0)); - RETURN_IF_EXCEPTION(scope, {}); Bun::WebStreams::writableStreamRejectCloseAndClosedPromiseIfNeeded(globalObject, stream); - RETURN_IF_EXCEPTION(scope, {}); return JSC::JSValue::encode(JSC::jsUndefined()); } diff --git a/src/jsc/bindings/webcrypto/CryptoAlgorithmAES_CBCOpenSSL.cpp b/src/jsc/bindings/webcrypto/CryptoAlgorithmAES_CBCOpenSSL.cpp index 793b97da88da..b0c46c0cd18e 100644 --- a/src/jsc/bindings/webcrypto/CryptoAlgorithmAES_CBCOpenSSL.cpp +++ b/src/jsc/bindings/webcrypto/CryptoAlgorithmAES_CBCOpenSSL.cpp @@ -31,6 +31,7 @@ #include "CryptoAlgorithmAesCbcCfbParams.h" #include "CryptoKeyAES.h" #include "OpenSSLCryptoUniquePtr.h" +#include "OpenSSLUtilities.h" #include namespace WebCore { @@ -49,7 +50,7 @@ static const EVP_CIPHER* aesAlgorithm(size_t keySize) return nullptr; } -static std::optional> cryptEncrypt(const Vector& key, const Vector& iv, Vector&& plainText) +static std::optional> cryptEncrypt(const Vector& key, const Vector& iv, const Vector& plainText) { const EVP_CIPHER* algorithm = aesAlgorithm(key.size()); if (!algorithm) @@ -64,7 +65,9 @@ static std::optional> cryptEncrypt(const Vector& key, c size_t plainSize = plainText.size(); const size_t cipherTextLen = roundUpToMultipleOf(EVP_CIPHER_block_size(algorithm), plainSize + 1); - Vector cipherText(cipherTextLen); + Vector cipherText; + if (!tryAllocateOutputBuffer(cipherText, cipherTextLen)) [[unlikely]] + return std::nullopt; // Initialize the encryption operation if (1 != EVP_EncryptInit_ex(ctx.get(), algorithm, nullptr, key.begin(), iv.begin())) @@ -90,7 +93,9 @@ static std::optional> cryptDecrypt(const Vector& key, c EvpCipherCtxPtr ctx; size_t cipherSize = cipherText.size(); - Vector plainText(cipherSize); + Vector plainText; + if (!tryAllocateOutputBuffer(plainText, cipherSize)) [[unlikely]] + return std::nullopt; int len; int plainTextLen; @@ -119,7 +124,7 @@ static std::optional> cryptDecrypt(const Vector& key, c ExceptionOr> CryptoAlgorithmAES_CBC::platformEncrypt(const CryptoAlgorithmAesCbcCfbParams& parameters, const CryptoKeyAES& key, const Vector& plainText, Padding) { - auto output = cryptEncrypt(key.key(), parameters.ivVector(), Vector(plainText)); + auto output = cryptEncrypt(key.key(), parameters.ivVector(), plainText); if (!output) return Exception { OperationError }; return WTF::move(*output); diff --git a/src/jsc/bindings/webcrypto/CryptoAlgorithmAES_CFBOpenSSL.cpp b/src/jsc/bindings/webcrypto/CryptoAlgorithmAES_CFBOpenSSL.cpp index 3cab769697c8..3f5e4a2df47a 100644 --- a/src/jsc/bindings/webcrypto/CryptoAlgorithmAES_CFBOpenSSL.cpp +++ b/src/jsc/bindings/webcrypto/CryptoAlgorithmAES_CFBOpenSSL.cpp @@ -51,7 +51,9 @@ static std::optional> cfb8(const Vector& key, const Vec memcpy(shiftRegister, iv.begin(), AES_BLOCK_SIZE); size_t shift = 0; - Vector output(input.size()); + Vector output; + if (!tryAllocateOutputBuffer(output, input.size())) [[unlikely]] + return std::nullopt; uint8_t encryptedBlock[AES_BLOCK_SIZE]; for (size_t i = 0; i < output.size(); i++) { @@ -74,7 +76,7 @@ static std::optional> cfb8(const Vector& key, const Vec return output; } -static std::optional> cryptEncrypt(const Vector& key, const Vector& iv, Vector&& plainText) +static std::optional> cryptEncrypt(const Vector& key, const Vector& iv, const Vector& plainText) { return cfb8(key, iv, plainText, true); } @@ -86,7 +88,7 @@ static std::optional> cryptDecrypt(const Vector& key, c ExceptionOr> CryptoAlgorithmAES_CFB::platformEncrypt(const CryptoAlgorithmAesCbcCfbParams& parameters, const CryptoKeyAES& key, const Vector& plainText) { - auto output = cryptEncrypt(key.key(), parameters.ivVector(), Vector(plainText)); + auto output = cryptEncrypt(key.key(), parameters.ivVector(), plainText); if (!output) return Exception { OperationError }; return WTF::move(*output); diff --git a/src/jsc/bindings/webcrypto/CryptoAlgorithmAES_CTROpenSSL.cpp b/src/jsc/bindings/webcrypto/CryptoAlgorithmAES_CTROpenSSL.cpp index fbbebef8d337..3c7eb6cad3a4 100644 --- a/src/jsc/bindings/webcrypto/CryptoAlgorithmAES_CTROpenSSL.cpp +++ b/src/jsc/bindings/webcrypto/CryptoAlgorithmAES_CTROpenSSL.cpp @@ -31,6 +31,7 @@ #include "CryptoAlgorithmAesCtrParams.h" #include "CryptoKeyAES.h" #include "OpenSSLCryptoUniquePtr.h" +#include "OpenSSLUtilities.h" #include namespace WebCore { @@ -78,7 +79,9 @@ static std::optional> crypt(int operation, const Vector if (capacity < blocks) headSize = capacity * blockSize; - Vector outputText(inputText.size()); + Vector outputText; + if (!tryAllocateOutputBuffer(outputText, inputText.size())) [[unlikely]] + return std::nullopt; // First part { // Initialize the encryption(decryption) operation diff --git a/src/jsc/bindings/webcrypto/CryptoAlgorithmAES_GCMOpenSSL.cpp b/src/jsc/bindings/webcrypto/CryptoAlgorithmAES_GCMOpenSSL.cpp index 55a84260e44e..3248daaf960d 100644 --- a/src/jsc/bindings/webcrypto/CryptoAlgorithmAES_GCMOpenSSL.cpp +++ b/src/jsc/bindings/webcrypto/CryptoAlgorithmAES_GCMOpenSSL.cpp @@ -31,6 +31,7 @@ #include "CryptoAlgorithmAesGcmParams.h" #include "CryptoKeyAES.h" #include "OpenSSLCryptoUniquePtr.h" +#include "OpenSSLUtilities.h" #include namespace WebCore { @@ -63,7 +64,9 @@ static std::optional> cryptEncrypt(const Vector& key, c if (additionalData.size() > static_cast(std::numeric_limits::max())) return std::nullopt; - Vector cipherText(plainText.size() + tagLength); + Vector cipherText; + if (!tryAllocateOutputBuffer(cipherText, plainText.size() + tagLength)) [[unlikely]] + return std::nullopt; size_t tagOffset = plainText.size(); // Create and initialize the context @@ -128,7 +131,9 @@ static std::optional> cryptDecrypt(const Vector& key, c int cipherTextLen = cipherText.size() - tagLength; - Vector plainText(cipherText.size()); + Vector plainText; + if (!tryAllocateOutputBuffer(plainText, cipherText.size())) [[unlikely]] + return std::nullopt; Vector tag { std::span { cipherText.begin() + cipherTextLen, tagLength } }; // Create and initialize the context diff --git a/src/jsc/bindings/webcrypto/CryptoAlgorithmAES_KWOpenSSL.cpp b/src/jsc/bindings/webcrypto/CryptoAlgorithmAES_KWOpenSSL.cpp index 0ac8358ee6c7..17a61391650f 100644 --- a/src/jsc/bindings/webcrypto/CryptoAlgorithmAES_KWOpenSSL.cpp +++ b/src/jsc/bindings/webcrypto/CryptoAlgorithmAES_KWOpenSSL.cpp @@ -42,7 +42,9 @@ static std::optional> cryptWrapKey(const Vector& key, c if (!aesKey.setKey(key, AES_ENCRYPT)) return std::nullopt; - Vector cipherText(data.size() + 8); + Vector cipherText; + if (!tryAllocateOutputBuffer(cipherText, data.size() + 8)) [[unlikely]] + return std::nullopt; if (AES_wrap_key(aesKey.key(), nullptr, cipherText.begin(), data.begin(), data.size()) < 0) return std::nullopt; @@ -58,7 +60,9 @@ static std::optional> cryptUnwrapKey(const Vector& key, if (!aesKey.setKey(key, AES_DECRYPT)) return std::nullopt; - Vector plainText(data.size() - 8); + Vector plainText; + if (!tryAllocateOutputBuffer(plainText, data.size() - 8)) [[unlikely]] + return std::nullopt; if (AES_unwrap_key(aesKey.key(), nullptr, plainText.begin(), data.begin(), data.size()) < 0) return std::nullopt; diff --git a/src/jsc/bindings/webcrypto/CryptoAlgorithmChaCha20Poly1305OpenSSL.cpp b/src/jsc/bindings/webcrypto/CryptoAlgorithmChaCha20Poly1305OpenSSL.cpp index d158cfc4df3d..37d010c1987a 100644 --- a/src/jsc/bindings/webcrypto/CryptoAlgorithmChaCha20Poly1305OpenSSL.cpp +++ b/src/jsc/bindings/webcrypto/CryptoAlgorithmChaCha20Poly1305OpenSSL.cpp @@ -30,6 +30,7 @@ #include "CryptoAlgorithmAeadParams.h" #include "CryptoKeyRaw.h" +#include "OpenSSLUtilities.h" #include #include @@ -39,11 +40,14 @@ static constexpr size_t tagSize = 16; static std::optional> aeadSeal(const Vector& key, const Vector& nonce, const Vector& plainText, const Vector& additionalData) { + Vector cipherText; + if (!tryAllocateOutputBuffer(cipherText, plainText.size() + tagSize)) [[unlikely]] + return std::nullopt; + EVP_AEAD_CTX ctx; if (!EVP_AEAD_CTX_init(&ctx, EVP_aead_chacha20_poly1305(), key.begin(), key.size(), tagSize, nullptr)) return std::nullopt; - Vector cipherText(plainText.size() + tagSize); size_t outLength = 0; bool ok = EVP_AEAD_CTX_seal(&ctx, cipherText.begin(), &outLength, cipherText.size(), nonce.begin(), nonce.size(), plainText.begin(), plainText.size(), @@ -58,11 +62,14 @@ static std::optional> aeadSeal(const Vector& key, const static std::optional> aeadOpen(const Vector& key, const Vector& nonce, const Vector& cipherText, const Vector& additionalData) { + Vector plainText; + if (!tryAllocateOutputBuffer(plainText, cipherText.size())) [[unlikely]] + return std::nullopt; + EVP_AEAD_CTX ctx; if (!EVP_AEAD_CTX_init(&ctx, EVP_aead_chacha20_poly1305(), key.begin(), key.size(), tagSize, nullptr)) return std::nullopt; - Vector plainText(cipherText.size()); size_t outLength = 0; bool ok = EVP_AEAD_CTX_open(&ctx, plainText.begin(), &outLength, plainText.size(), nonce.begin(), nonce.size(), cipherText.begin(), cipherText.size(), diff --git a/src/jsc/bindings/webcrypto/CryptoAlgorithmHMAC.cpp b/src/jsc/bindings/webcrypto/CryptoAlgorithmHMAC.cpp index c7932cd594ac..29ef8533b0f6 100644 --- a/src/jsc/bindings/webcrypto/CryptoAlgorithmHMAC.cpp +++ b/src/jsc/bindings/webcrypto/CryptoAlgorithmHMAC.cpp @@ -233,6 +233,10 @@ void CryptoAlgorithmHMAC::exportKey(CryptoKeyFormat format, Ref&& key break; case CryptoKeyFormat::Jwk: { JsonWebKey jwk = hmacKey.exportJwk(); + if (jwk.k.isNull()) { + exceptionCallback(OperationError, ""_s); + return; + } switch (hmacKey.hashAlgorithmIdentifier()) { case CryptoAlgorithmIdentifier::SHA_1: jwk.alg = String(ALG1); diff --git a/src/jsc/bindings/webcrypto/CryptoKeyOKP.cpp b/src/jsc/bindings/webcrypto/CryptoKeyOKP.cpp index 99ad463232d8..5c56a3d12d1a 100644 --- a/src/jsc/bindings/webcrypto/CryptoKeyOKP.cpp +++ b/src/jsc/bindings/webcrypto/CryptoKeyOKP.cpp @@ -221,11 +221,6 @@ ExceptionOr CryptoKeyOKP::exportJwk() const return result; } -bool CryptoKeyOKP::isValidOKPAlgorithm(CryptoAlgorithmIdentifier algorithm) -{ - return algorithm == CryptoAlgorithmIdentifier::Ed25519; -} - auto CryptoKeyOKP::algorithm() const -> KeyAlgorithm { CryptoKeyAlgorithm result; diff --git a/src/jsc/bindings/webcrypto/CryptoKeyOKP.h b/src/jsc/bindings/webcrypto/CryptoKeyOKP.h index e2e3590a953c..7d87e38817ba 100644 --- a/src/jsc/bindings/webcrypto/CryptoKeyOKP.h +++ b/src/jsc/bindings/webcrypto/CryptoKeyOKP.h @@ -61,7 +61,6 @@ class CryptoKeyOKP final : public CryptoKey { NamedCurve namedCurve() const { return m_curve; } - static bool isValidOKPAlgorithm(CryptoAlgorithmIdentifier); static KeyMaterial ed25519PublicFromPrivate(const KeyMaterial& privateKey); static KeyMaterial x25519PublicFromPrivate(const KeyMaterial& privateKey); static KeyMaterial ed25519PrivateFromSeed(KeyMaterial&& seed); diff --git a/src/jsc/bindings/webcrypto/JSCryptoKey.cpp b/src/jsc/bindings/webcrypto/JSCryptoKey.cpp index 744bbce8dbe0..48f7335824de 100644 --- a/src/jsc/bindings/webcrypto/JSCryptoKey.cpp +++ b/src/jsc/bindings/webcrypto/JSCryptoKey.cpp @@ -272,7 +272,7 @@ JSC_DEFINE_HOST_FUNCTION(jsCryptoKeyPrototype_inspectCustom, (JSGlobalObject * l { auto& vm = JSC::getVM(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); - JSValue thisValue = callFrame->thisValue(); + JSValue thisValue = callFrame->thisValue().toThis(lexicalGlobalObject, JSC::ECMAMode::strict()); auto* thisObject = dynamicDowncast(thisValue); if (!thisObject) [[unlikely]] return JSValue::encode(thisValue); diff --git a/src/jsc/bindings/webcrypto/OpenSSLUtilities.h b/src/jsc/bindings/webcrypto/OpenSSLUtilities.h index 2f0a7c2eb1ea..2a6ece1a4704 100644 --- a/src/jsc/bindings/webcrypto/OpenSSLUtilities.h +++ b/src/jsc/bindings/webcrypto/OpenSSLUtilities.h @@ -27,6 +27,7 @@ #include "CryptoAlgorithmIdentifier.h" #include "OpenSSLCryptoUniquePtr.h" +#include "VectorSizeLimit.h" #include #include #include @@ -37,6 +38,15 @@ namespace WebCore { +// Use this for output sizes derived from user data. Vector(size_t) CRASH()es above the cap. +[[nodiscard]] inline bool tryAllocateOutputBuffer(Vector& buffer, size_t size) +{ + ASSERT(buffer.isEmpty()); + if (size > Bun::maxVectorSize()) [[unlikely]] + return false; + return buffer.tryGrow(size); +} + const EVP_MD* digestAlgorithm(CryptoAlgorithmIdentifier hashFunction); std::optional> calculateDigest(const EVP_MD* algorithm, const Vector& message); diff --git a/src/jsc/bindings/webcrypto/SubtleCrypto.cpp b/src/jsc/bindings/webcrypto/SubtleCrypto.cpp index 74ee546a358b..fe10412fd841 100644 --- a/src/jsc/bindings/webcrypto/SubtleCrypto.cpp +++ b/src/jsc/bindings/webcrypto/SubtleCrypto.cpp @@ -56,8 +56,10 @@ #include "CryptoKeyAKP.h" #include "CryptoKeyEC.h" #include "CryptoKeyRSA.h" +#include "BunString.h" #include "ErrorCode.h" #include "JSDOMExceptionHandling.h" +#include "VectorSizeLimit.h" #include "node/crypto/CryptoUtil.h" #include #include @@ -1431,16 +1433,21 @@ void SubtleCrypto::wrapKey(JSC::JSGlobalObject& state, KeyFormat format, CryptoK promise->reject(Exception { ExistingExceptionError }); return; } - CString jwkUTF8String = jwkString.utf8(StrictConversion); - bytes.append(jwkUTF8String.span()); - // AES-KW (RFC 3394) can only wrap plaintext whose length is a multiple of // 8 bytes. A serialized JWK usually isn't, so pad it with trailing spaces, // which JSON.parse ignores when the key is unwrapped. This matches Node.js. - if (wrappingKey->algorithmIdentifier() == CryptoAlgorithmIdentifier::AES_KW) { - while (bytes.size() % 8) - bytes.append(' '); + auto jwkUTF8 = Bun::UTF8View::tryCreate(jwkString); + size_t paddedSize = jwkUTF8 ? jwkUTF8->bytes().size() : 0; + if (wrappingKey->algorithmIdentifier() == CryptoAlgorithmIdentifier::AES_KW) + paddedSize = roundUpToMultipleOf(8, paddedSize); + if (!jwkUTF8 || paddedSize > Bun::maxVectorSize() || !bytes.tryReserveInitialCapacity(paddedSize)) [[unlikely]] { + weakThis->m_pendingPromises.remove(index); + promise->reject(OperationError, "Input data is too large"_s); + return; } + bytes.append(jwkUTF8->bytes()); + while (bytes.size() < paddedSize) + bytes.append(' '); } } diff --git a/src/jsc/bindings/wrapAnsi.cpp b/src/jsc/bindings/wrapAnsi.cpp index cde60f7f48f1..5cda1b8d577d 100644 --- a/src/jsc/bindings/wrapAnsi.cpp +++ b/src/jsc/bindings/wrapAnsi.cpp @@ -1,6 +1,7 @@ #include "root.h" #include "wrapAnsi.h" #include "ANSIHelpers.h" +#include "StringSizeLimit.h" #include "VectorSizeLimit.h" #include @@ -661,25 +662,20 @@ template // Main Implementation // ============================================================================ -// nullopt: a row, or the list of rows, could not grow. +// Returns nullopt if a row, the list of rows or the result couldn't grow. template static std::optional wrapAnsiImpl(std::span input, size_t columns, const WrapAnsiOptions& options) { - if (columns == 0 || input.empty()) { - // Return copy of input - StringBuilder result; - result.reserveCapacity(input.size()); - for (auto c : input) - result.append(static_cast(c)); - return result.toString(); - } + if (columns == 0 || input.empty()) + return WTF::String(input); // Process each line separately. \n, a bare \r and a \r\n pair each // break a line and are emitted as one \n — callers (Claude Code's // wrap-text) map wrapped output back to the original text by relying on // every \r becoming a break. - StringBuilder result; - result.reserveCapacity(input.size() + input.size() / 10); + // Each row reopens the active hyperlink, so the result can be much longer than the input. + StringBuilder result { OverflowPolicy::RecordOverflow }; + result.reserveCapacity(cappedStringBuilderReserve(input.size() + input.size() / 10)); const Char* lineStart = input.data(); const Char* const dataEnd = input.data() + input.size(); @@ -717,6 +713,8 @@ static std::optional wrapAnsiImpl(std::span input, size lineStart = (*lineEnd == '\r' && lineEnd + 1 != dataEnd && *(lineEnd + 1) == '\n') ? lineEnd + 2 : lineEnd + 1; } + if (result.hasOverflowed() || exceedsStringLimit(result.length())) [[unlikely]] + return std::nullopt; return result.toString(); } diff --git a/src/jsc/bindings/wtf-bindings.cpp b/src/jsc/bindings/wtf-bindings.cpp index 26c27c2e8cc4..fb2e3eaceedb 100644 --- a/src/jsc/bindings/wtf-bindings.cpp +++ b/src/jsc/bindings/wtf-bindings.cpp @@ -1,5 +1,6 @@ #include "root.h" #include "wtf-bindings.h" +#include "helpers.h" #include "BunTTYState.h" #include #include @@ -246,13 +247,17 @@ extern "C" double Bun__parseDateTimeString(const Latin1Character* string, size_t } namespace Bun { -String base64URLEncodeToString(Vector data) +String base64URLEncodeToString(const Vector& data) { auto size = data.size(); size_t encodedLength = ((size * 4) + 2) / 3; if (!encodedLength) return String(); + // String::createUninitialized CRASH()es above String::MaxLength. + if (encodedLength > std::min(Bun__stringSyntheticAllocationLimit, static_cast(String::MaxLength))) [[unlikely]] + return String(); + std::span ptr; auto result = String::createUninitialized(encodedLength, ptr); diff --git a/src/jsc/bindings/wtf-bindings.h b/src/jsc/bindings/wtf-bindings.h index 092bada040a3..ca791438e9d8 100644 --- a/src/jsc/bindings/wtf-bindings.h +++ b/src/jsc/bindings/wtf-bindings.h @@ -8,6 +8,7 @@ class VM; } namespace Bun { -String base64URLEncodeToString(Vector data); +// Returns a null String if the encoded length exceeds the max string length. +String base64URLEncodeToString(const Vector& data); size_t toISOString(JSC::VM& vm, double date, char buffer[64]); } diff --git a/src/jsc/hot_reloader.rs b/src/jsc/hot_reloader.rs index 5650ac5b85ff..83c086196842 100644 --- a/src/jsc/hot_reloader.rs +++ b/src/jsc/hot_reloader.rs @@ -362,7 +362,7 @@ pub struct NewHotReloader { pub(crate) main: MainFile, #[cfg(not(windows))] - pub(crate) tombstones: StringHashMap<*mut Fs::EntriesOption>, + pub(crate) tombstones: StringHashMap<*mut Fs::DirEntry>, /// See [`HotReloaderCtx::reload_handle`]. pub(crate) reload_handle: Option, @@ -724,11 +724,12 @@ where let watcher = match Watcher::init(reloader, unsafe { (*this).watcher_top_level_dir() }) { Ok(w) => w, Err(err) => { - bun_core::handle_error_return_trace(&err); - Output::panic(format_args!( - "Failed to enable File Watcher: {}", + bun_core::pretty_errorln!( + "error: Failed to enable File Watcher: {}", err.name() - )); + ); + Output::flush(); + bun_core::Global::exit(1); } }; @@ -755,15 +756,31 @@ where } #[cfg(not(windows))] - fn put_tombstone(&mut self, key: &[u8], value: *mut Fs::EntriesOption) { + fn put_tombstone(&mut self, key: &[u8], value: *mut Fs::DirEntry) { self.tombstones.put(key, value).expect("unreachable"); } #[cfg(not(windows))] - fn get_tombstone(&mut self, key: &[u8]) -> Option<*mut Fs::EntriesOption> { + fn get_tombstone(&mut self, key: &[u8]) -> Option<*mut Fs::DirEntry> { self.tombstones.get(key).copied() } + /// Returns `key`'s cached listing, or `None` if it isn't cached or the cached result is a read error. + /// Resolver threads rewrite the cache under `entries_mutex`. The `DirEntry` is never freed. + #[cfg(not(windows))] + fn cached_dir_entry( + rfs: &mut Fs::file_system::RealFS, + key: &[u8], + ) -> Option<*mut Fs::DirEntry> { + let _entries_lock = rfs.entries_mutex.lock_guard(); + match rfs.entries.get(key) { + Some(Fs::EntriesOption::Entries(existing)) => { + Some(std::ptr::from_mut::(*existing)) + } + _ => None, + } + } + pub(crate) fn on_error(_: &mut Self, err: &bun_sys::Error) { // `bun_sys::Error::name()` does the errno→tag-name lookup. Output::err(err.name(), "Watcher crashed", ()); @@ -838,6 +855,7 @@ where unsafe { (*ctx).flush_evictions() }; }); let fs: &mut FileSystem = FileSystem::instance(); + let top_level_dir = fs.top_level_dir(); let rfs: &mut Fs::file_system::RealFS = &mut fs.fs; #[cfg(windows)] let _ = (changed_files, parents, file_descriptors, rfs); @@ -860,8 +878,9 @@ where match kind { bun_watcher::Kind::File => { - if event.op.contains(WatchOp::DELETE) - || (event.op.contains(WatchOp::RENAME) && IS_KQUEUE) + if !bun_watcher::WATCHES_BY_PATH + && (event.op.contains(WatchOp::DELETE) + || (event.op.contains(WatchOp::RENAME) && IS_KQUEUE)) { // SAFETY: the Watcher outlives this call (it owns the // Reloader that calls us); `remove_at_index::` @@ -881,11 +900,8 @@ where if self.verbose { Self::debug(format_args!( "File changed: {}", - // Note: `fs.relative_to(file_path)` would borrow `&*fs` - // while `rfs = &mut fs.fs` is live; inline the body so the - // split-borrow on `fs.top_level_dir` is visible to borrowck. bstr::BStr::new(bun_paths::resolve_path::relative( - fs.top_level_dir, + top_level_dir, file_path )) )); @@ -932,7 +948,7 @@ where #[cfg(not(windows))] { let mut affected_buf: [&[u8]; 128] = [b"".as_slice(); 128]; - let mut entries_option: Option<*mut Fs::EntriesOption> = None; + let mut entries_option: Option<*mut Fs::DirEntry> = None; // Note: the affected-name element type differs by // platform (kqueue vs inotify). Split into two locals; @@ -943,9 +959,7 @@ where let affected_len: usize = 'brk: { if IS_KQUEUE { - // SAFETY: hot-reload runs single-threaded on the JS thread; - // no other live `&mut EntriesOption` for this key here. - if let Some(existing) = rfs.entries.get(file_path) { + if let Some(existing) = Self::cached_dir_entry(rfs, file_path) { self.put_tombstone(file_path, existing); entries_option = Some(existing); } else if let Some(existing) = self.get_tombstone(file_path) { @@ -1036,7 +1050,7 @@ where }; if affected_len > 0 && !IS_KQUEUE { - if let Some(existing) = rfs.entries.get(file_path) { + if let Some(existing) = Self::cached_dir_entry(rfs, file_path) { self.put_tombstone(file_path, existing); entries_option = Some(existing); } else if let Some(existing) = self.get_tombstone(file_path) { @@ -1099,13 +1113,13 @@ where if let Some(dir_ent) = entries_option { // SAFETY: dir_ent points into rfs.entries (or a tombstoned copy); // both outlive this loop iteration. Shared access only — - // `entries()` takes `&self` and per-entry mutation below goes + // `get()` takes `&self` and per-entry mutation below goes // through the entry's own mutex + cells. let dir_ent = unsafe { &*dir_ent }; let mut last_file_hash: bun_watcher::HashType = bun_watcher::HashType::MAX; - for i in 0..affected_len { + 'names: for i in 0..affected_len { let changed_name: &[u8] = if IS_KQUEUE { affected_kqueue[i] } else { @@ -1141,7 +1155,7 @@ where // (EntryStore-owned). let looked_up = { let _entries_lock = rfs.entries_mutex.lock_guard(); - dir_ent.entries().get(changed_name) + dir_ent.get(changed_name) }; if let Some(file_ent) = looked_up { // reset the file descriptor @@ -1156,7 +1170,7 @@ where core::sync::atomic::Ordering::Release, ); } - path_string = ent.abs_path; + path_string = ent.abs_path(); file_hash = Watcher::get_hash(path_string.as_bytes()); for (entry_id, hash) in hashes.iter().enumerate() { if *hash == file_hash { @@ -1198,6 +1212,12 @@ where } else { let file_path_without_trailing_slash = strings::trim_right(file_path, &[SEP]); + if file_path_without_trailing_slash.len() + + changed_name.len() + >= _on_file_update_path_buf.len() + { + continue 'names; + } _on_file_update_path_buf [0..file_path_without_trailing_slash.len()] .copy_from_slice(file_path_without_trailing_slash); @@ -1234,7 +1254,7 @@ where Self::debug(format_args!( "File change: {}", bstr::BStr::new(bun_paths::resolve_path::relative( - fs.top_level_dir, + top_level_dir, abs_path, )) )); @@ -1247,7 +1267,7 @@ where Self::debug(format_args!( "Dir change: {} (affecting {})", bstr::BStr::new(bun_paths::resolve_path::relative( - fs.top_level_dir, + top_level_dir, file_path )), affected_len @@ -1334,7 +1354,7 @@ impl<'a> HotReloaderCtx for bun_bundler::BundleV2<'a> { } fn watcher_top_level_dir(&self) -> &'static [u8] { - FileSystem::get().top_level_dir + FileSystem::get().top_level_dir() } fn install_bun_watcher( diff --git a/src/jsc/modules/BunJSCModule.h b/src/jsc/modules/BunJSCModule.h index 3a4efff23aa8..da51a37f6aa4 100644 --- a/src/jsc/modules/BunJSCModule.h +++ b/src/jsc/modules/BunJSCModule.h @@ -457,6 +457,7 @@ JSC_DEFINE_HOST_FUNCTION(functionNeverInlineFunction, } extern "C" bool Bun__mkdirp(JSC::JSGlobalObject*, const char*); +extern "C" void Bun__VirtualMachine__setSamplingProfilerDirectory(void* bunVM, const BunString* directory); JSC_DECLARE_HOST_FUNCTION(functionStartSamplingProfiler); JSC_DEFINE_HOST_FUNCTION(functionStartSamplingProfiler, @@ -474,7 +475,12 @@ JSC_DEFINE_HOST_FUNCTION(functionStartSamplingProfiler, auto path = directoryValue.toWTFString(globalObject); RETURN_IF_EXCEPTION(scope, {}); if (!path.isEmpty()) { - StringPrintStream pathOut; + if (path.contains(static_cast(0))) { + throwVMError( + globalObject, scope, + createTypeError(globalObject, "directory must not contain null bytes"_s)); + return {}; + } auto pathCString = toUTF8CString(String(path)); if (!Bun__mkdirp(globalObject, pathCString.legacyCStringPointer())) { throwVMError( @@ -483,8 +489,9 @@ JSC_DEFINE_HOST_FUNCTION(functionStartSamplingProfiler, return {}; } - Options::samplingProfilerPath() = pathCString.data(); - samplingProfiler.registerForReportAtExit(); + // JSC::Options is read-only after Config::finalize, so store the directory on the VM. + auto directory = Bun::toString(path); + Bun__VirtualMachine__setSamplingProfilerDirectory(bunVM(globalObject), &directory); } } if (sampleValue.isNumber()) { @@ -513,9 +520,7 @@ JSC_DEFINE_HOST_FUNCTION(functionSamplingProfilerStackTraces, createError(globalObject, "Sampling profiler was never started"_s))); WTF::String jsonString = vm.samplingProfiler()->stackTracesAsJSON()->toJSONString(); - JSC::EncodedJSValue result = JSC::JSValue::encode(JSONParse(globalObject, jsonString)); - scope.releaseAssertNoException(); - return result; + RELEASE_AND_RETURN(scope, JSC::JSValue::encode(JSONParse(globalObject, jsonString))); } JSC_DECLARE_HOST_FUNCTION(functionGetRandomSeed); @@ -618,10 +623,14 @@ JSC_DEFINE_HOST_FUNCTION(functionGetProtectedObjects, { auto& vm = globalObject->vm(); MarkedArgumentBuffer list; - vm.heap.forEachProtectedCell( - [&](JSCell* cell) { list.append(cell); }); + // Skip protected cells that aren't objects (unlinked code blocks, structures). They aren't JavaScript values. + auto appendObject = [&](JSCell* cell) { + if (cell->isObject()) + list.append(cell); + }; + vm.heap.forEachProtectedCell(appendObject); for (auto* block = WebCore::clientData(vm)->m_strongRootBlockHead; block; block = block->next()) - block->forEachOccupiedCell([&](JSCell* cell) { list.append(cell); }); + block->forEachOccupiedCell(appendObject); RELEASE_ASSERT(!list.hasOverflowed()); return JSC::JSValue::encode(constructArray( globalObject, static_cast(nullptr), list)); diff --git a/src/jsc/modules/NodeBufferModule.cpp b/src/jsc/modules/NodeBufferModule.cpp index 50ec39550c81..91252877cc73 100644 --- a/src/jsc/modules/NodeBufferModule.cpp +++ b/src/jsc/modules/NodeBufferModule.cpp @@ -181,6 +181,14 @@ BUN_DEFINE_HOST_FUNCTION(jsBufferTranscode, if (length == 0) RELEASE_AND_RETURN(scope, JSValue::encode(WebCore::createEmptyBuffer(globalObject))); + // Another thread can write to shared bytes between the passes below. The copy is a Buffer so that no return path has to free it. + if (view->isShared()) [[unlikely]] { + auto* copy = WebCore::createUninitializedBuffer(globalObject, length); + RETURN_IF_EXCEPTION(scope, {}); + memcpy(copy->typedVector(), view->typedVector(), length); + view = copy; + } + JSC::EnsureStillAliveScope keepAlive { view }; const std::span input { view->typedVector(), length }; const auto* data = reinterpret_cast(input.data()); @@ -210,7 +218,6 @@ BUN_DEFINE_HOST_FUNCTION(jsBufferTranscode, result = WebCore::createUninitializedBuffer(globalObject, expected * 2); RETURN_IF_EXCEPTION(scope, {}); const size_t actual = simdutf::convert_utf8_to_utf16le(data, length, reinterpret_cast(result->typedVector())); - // A shared source can change between the two passes. This keeps unwritten bytes out of the result; it does not bound the writes. if (actual == 0 || actual != expected) { errorCode = U_INVALID_CHAR_FOUND_ERRNO; errorName = "U_INVALID_CHAR_FOUND"_s; diff --git a/src/jsc/modules/NodeBufferModule.h b/src/jsc/modules/NodeBufferModule.h index fcf346bcdfe7..12b228858050 100644 --- a/src/jsc/modules/NodeBufferModule.h +++ b/src/jsc/modules/NodeBufferModule.h @@ -194,17 +194,17 @@ DEFINE_NATIVE_MODULE(NodeBuffer) put(atobI, atobV); put(btoaI, btoaV); - auto* transcode = JSC::JSFunction::create(vm, globalObject, 3, "transcode"_s, jsBufferTranscode, ImplementationVisibility::Public, NoIntrinsic, jsBufferTranscode); + auto* transcode = JSC::JSFunction::create(vm, globalObject, 3, "transcode"_s, jsBufferTranscode, ImplementationVisibility::Public, NoIntrinsic); put(JSC::Identifier::fromString(vm, "transcode"_s), transcode); - auto* resolveObjectURL = JSC::JSFunction::create(vm, globalObject, 1, "resolveObjectURL"_s, jsFunctionResolveObjectURL, ImplementationVisibility::Public, NoIntrinsic, jsFunctionResolveObjectURL); + auto* resolveObjectURL = JSC::JSFunction::create(vm, globalObject, 1, "resolveObjectURL"_s, jsFunctionResolveObjectURL, ImplementationVisibility::Public, NoIntrinsic); put(JSC::Identifier::fromString(vm, "resolveObjectURL"_s), resolveObjectURL); - put(JSC::Identifier::fromString(vm, "isAscii"_s), JSC::JSFunction::create(vm, globalObject, 1, "isAscii"_s, jsBufferConstructorFunction_isAscii, ImplementationVisibility::Public, NoIntrinsic, jsBufferConstructorFunction_isAscii)); + put(JSC::Identifier::fromString(vm, "isAscii"_s), JSC::JSFunction::create(vm, globalObject, 1, "isAscii"_s, jsBufferConstructorFunction_isAscii, ImplementationVisibility::Public, NoIntrinsic)); - put(JSC::Identifier::fromString(vm, "isUtf8"_s), JSC::JSFunction::create(vm, globalObject, 1, "isUtf8"_s, jsBufferConstructorFunction_isUtf8, ImplementationVisibility::Public, NoIntrinsic, jsBufferConstructorFunction_isUtf8)); + put(JSC::Identifier::fromString(vm, "isUtf8"_s), JSC::JSFunction::create(vm, globalObject, 1, "isUtf8"_s, jsBufferConstructorFunction_isUtf8, ImplementationVisibility::Public, NoIntrinsic)); } } // namespace Zig diff --git a/src/jsc/modules/NodeModuleModule.cpp b/src/jsc/modules/NodeModuleModule.cpp index 41b35810522f..d70619b0f2b1 100644 --- a/src/jsc/modules/NodeModuleModule.cpp +++ b/src/jsc/modules/NodeModuleModule.cpp @@ -22,6 +22,7 @@ #include "ZigGlobalObject.h" #include "headers.h" #include "ErrorCode.h" +#include "VectorSizeLimit.h" #include "BunString.h" #include "GeneratedNodeModuleModule.h" @@ -367,7 +368,11 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionResolveFileName, if (scope.exception()) return; - paths.append(Bun::toStringRef(pathStr)); + BunString pathRef = Bun::toStringRef(pathStr); + if (!Bun::tryAppendWithinLimit(paths, pathRef)) [[unlikely]] { + pathRef.deref(); + throwOutOfMemoryError(lexicalGlobalObject, scope); + } }); if (scope.exception()) { @@ -618,8 +623,12 @@ JSC_DEFINE_CUSTOM_SETTER(setterRequireFunction, static JSValue getModuleCacheObject(VM& vm, JSObject* moduleObject) { - return uncheckedDowncast(moduleObject->globalObject()) - ->lazyRequireCacheObject(); + // See getBuiltinModulesObject for why this needs a top exception scope. + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + JSObject* cache = uncheckedDowncast(moduleObject->globalObject()) + ->lazyRequireCacheObject(); + RETURN_IF_EXCEPTION(scope, {}); + return cache; } static JSValue getModuleExtensionsObject(VM& vm, JSObject* moduleObject) @@ -651,6 +660,8 @@ static JSValue getSourceMapFunction(VM& vm, JSObject* moduleObject) static JSValue getBuiltinModulesObject(VM& vm, JSObject* moduleObject) { + // reifyAllStaticProperties calls PropertyCallback builders without checking for exceptions, so check here. + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); MarkedArgumentBuffer args; args.ensureCapacity(countof(builtinModuleNames)); @@ -659,7 +670,9 @@ static JSValue getBuiltinModulesObject(VM& vm, JSObject* moduleObject) } auto* globalObject = defaultGlobalObject(moduleObject->globalObject()); - return JSC::constructArray(globalObject, static_cast(nullptr), JSC::ArgList(args)); + auto* builtinModules = JSC::constructArray(globalObject, static_cast(nullptr), JSC::ArgList(args)); + RETURN_IF_EXCEPTION(scope, {}); + return builtinModules; } static JSValue getConstantsObject(VM& vm, JSObject* moduleObject) @@ -687,9 +700,13 @@ static JSValue getConstantsObject(VM& vm, JSObject* moduleObject) static JSValue getGlobalPathsObject(VM& vm, JSObject* moduleObject) { - return JSC::constructEmptyArray( + // See getBuiltinModulesObject for why this needs a top exception scope. + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + auto* globalPaths = JSC::constructEmptyArray( moduleObject->globalObject(), static_cast(nullptr), 0); + RETURN_IF_EXCEPTION(scope, {}); + return globalPaths; } // Like the _resolveFilename / runMain setters: writing back the default (e.g. copying Module's statics onto a @@ -1191,17 +1208,6 @@ void addNodeModuleConstructorProperties(JSC::VM& vm, init.set(requireESM); }); - globalObject->m_lazyRequireCacheObject.initLater( - [](const Zig::GlobalObject::Initializer& init) { - JSC::VM& vm = init.vm; - JSC::JSGlobalObject* globalObject = init.owner; - - auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); - JSValue result = Bun::createRequireCacheObject(globalObject, uncheckedDowncast(globalObject)->requireMap()); - ASSERT_UNUSED(scope, !scope.exception()); - init.set(result.toObject(globalObject)); - }); - globalObject->m_lazyRequireExtensionsObject.initLater( [](const Zig::GlobalObject::Initializer& init) { JSC::VM& vm = init.vm; diff --git a/src/jsc/modules/NodeSqliteModule.h b/src/jsc/modules/NodeSqliteModule.h index d9ab6c2aa199..647ccc2d4532 100644 --- a/src/jsc/modules/NodeSqliteModule.h +++ b/src/jsc/modules/NodeSqliteModule.h @@ -29,7 +29,7 @@ DEFINE_NATIVE_MODULE(NodeSqlite) // ourselves. { auto id = JSC::Identifier::fromString(vm, "backup"_s); - put(id, JSC::JSFunction::create(vm, globalObject, 2, id.string(), Bun::jsNodeSqliteBackup, JSC::ImplementationVisibility::Public, JSC::NoIntrinsic, Bun::jsNodeSqliteBackup)); + put(id, JSC::JSFunction::create(vm, globalObject, 2, id.string(), Bun::jsNodeSqliteBackup, JSC::ImplementationVisibility::Public, JSC::NoIntrinsic)); } RETURN_NATIVE_MODULE(); diff --git a/src/jsc/modules/ObjectModule.cpp b/src/jsc/modules/ObjectModule.cpp index 4311d5bcbf56..45d95280c869 100644 --- a/src/jsc/modules/ObjectModule.cpp +++ b/src/jsc/modules/ObjectModule.cpp @@ -1,15 +1,23 @@ #include "ObjectModule.h" +#include namespace Zig { -JSC::SyntheticSourceProvider::SyntheticSourceGenerator -generateObjectModuleSourceCode(JSC::JSGlobalObject* globalObject, - JSC::JSObject* object) + +// `generator` references `value` without rooting it. A root would leak the realm, because the value can reach the +// module registry, which references the source. The weak map keeps the value alive only as long as the source. +static JSC::JSSourceCode* createSourceCode(GlobalObject* globalObject, JSC::JSValue value, JSC::SyntheticSourceProvider::SyntheticSourceGenerator&& generator, WTF::String&& sourceURL) +{ + auto& vm = JSC::getVM(globalObject); + auto* sourceCode = JSC::JSSourceCode::create(vm, + JSC::SourceCode(JSC::SyntheticSourceProvider::create(WTF::move(generator), JSC::SourceOrigin(), WTF::move(sourceURL)))); + if (value.isCell()) + globalObject->moduleSourceValueMap()->set(vm, sourceCode, value); + return sourceCode; +} + +JSC::JSSourceCode* createObjectModuleSourceCode(GlobalObject* globalObject, JSC::JSObject* object, WTF::String&& sourceURL) { - gcProtectNullTolerant(object); - return [object](JSC::JSGlobalObject* lexicalGlobalObject, - JSC::Identifier moduleKey, - Vector& exportNames, - JSC::MarkedArgumentBuffer& exportValues) -> void { + JSC::SyntheticSourceProvider::SyntheticSourceGenerator generator = [object](JSC::JSGlobalObject* lexicalGlobalObject, JSC::Identifier moduleKey, Vector& exportNames, JSC::MarkedArgumentBuffer& exportValues) -> void { auto& vm = JSC::getVM(lexicalGlobalObject); auto throwScope = DECLARE_THROW_SCOPE(vm); GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); @@ -19,7 +27,6 @@ generateObjectModuleSourceCode(JSC::JSGlobalObject* globalObject, PrivateSymbolMode::Exclude); object->methodTable()->getOwnPropertyNames(object, globalObject, properties, DontEnumPropertiesMode::Exclude); RETURN_IF_EXCEPTION(throwScope, void()); - gcUnprotectNullTolerant(object); for (auto& entry : properties.releaseData()->propertyNameVector()) { JSValue value = object->get(globalObject, entry); @@ -28,17 +35,12 @@ generateObjectModuleSourceCode(JSC::JSGlobalObject* globalObject, exportValues.append(value); } }; + return createSourceCode(globalObject, object, WTF::move(generator), WTF::move(sourceURL)); } -JSC::SyntheticSourceProvider::SyntheticSourceGenerator -generateObjectModuleSourceCodeForJSON(JSC::JSGlobalObject* globalObject, - JSC::JSObject* object) +static JSC::JSSourceCode* createObjectModuleSourceCodeForJSON(GlobalObject* globalObject, JSC::JSObject* object, WTF::String&& sourceURL) { - gcProtectNullTolerant(object); - return [object](JSC::JSGlobalObject* lexicalGlobalObject, - JSC::Identifier moduleKey, - Vector& exportNames, - JSC::MarkedArgumentBuffer& exportValues) -> void { + JSC::SyntheticSourceProvider::SyntheticSourceGenerator generator = [object](JSC::JSGlobalObject* lexicalGlobalObject, JSC::Identifier moduleKey, Vector& exportNames, JSC::MarkedArgumentBuffer& exportValues) -> void { auto& vm = JSC::getVM(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); GlobalObject* globalObject = reinterpret_cast(lexicalGlobalObject); @@ -48,7 +50,6 @@ generateObjectModuleSourceCodeForJSON(JSC::JSGlobalObject* globalObject, PrivateSymbolMode::Exclude); object->getPropertyNames(globalObject, properties, DontEnumPropertiesMode::Exclude); RETURN_IF_EXCEPTION(scope, void()); - gcUnprotectNullTolerant(object); exportNames.append(vm.propertyNames->defaultKeyword); exportValues.append(object); @@ -65,40 +66,27 @@ generateObjectModuleSourceCodeForJSON(JSC::JSGlobalObject* globalObject, exportValues.append(value); } }; + return createSourceCode(globalObject, object, WTF::move(generator), WTF::move(sourceURL)); } -JSC::SyntheticSourceProvider::SyntheticSourceGenerator -generateJSValueModuleSourceCode(JSC::JSGlobalObject* globalObject, - JSC::JSValue value) +JSC::JSSourceCode* createJSValueModuleSourceCode(GlobalObject* globalObject, JSC::JSValue value, WTF::String&& sourceURL) { + if (value.isObject() && !JSC::isJSArray(value)) + return createObjectModuleSourceCodeForJSON(globalObject, value.getObject(), WTF::move(sourceURL)); - if (value.isObject() && !JSC::isJSArray(value)) { - return generateObjectModuleSourceCodeForJSON(globalObject, - value.getObject()); - } - - return generateJSValueExportDefaultObjectSourceCode(globalObject, value); + return createJSValueExportDefaultObjectSourceCode(globalObject, value, WTF::move(sourceURL)); } -JSC::SyntheticSourceProvider::SyntheticSourceGenerator -generateJSValueExportDefaultObjectSourceCode(JSC::JSGlobalObject* globalObject, - JSC::JSValue value) +JSC::JSSourceCode* createJSValueExportDefaultObjectSourceCode(GlobalObject* globalObject, JSC::JSValue value, WTF::String&& sourceURL) { - if (value.isCell()) - gcProtectNullTolerant(value.asCell()); - return [value](JSC::JSGlobalObject* lexicalGlobalObject, - JSC::Identifier moduleKey, - Vector& exportNames, - JSC::MarkedArgumentBuffer& exportValues) -> void { + JSC::SyntheticSourceProvider::SyntheticSourceGenerator generator = [value](JSC::JSGlobalObject* lexicalGlobalObject, JSC::Identifier moduleKey, Vector& exportNames, JSC::MarkedArgumentBuffer& exportValues) -> void { auto& vm = JSC::getVM(lexicalGlobalObject); exportNames.append(vm.propertyNames->defaultKeyword); exportValues.append(value); const Identifier& esModuleMarker = vm.propertyNames->__esModule; exportNames.append(esModuleMarker); exportValues.append(jsBoolean(true)); - - if (value.isCell()) - gcUnprotectNullTolerant(value.asCell()); }; + return createSourceCode(globalObject, value, WTF::move(generator), WTF::move(sourceURL)); } } // namespace Zig diff --git a/src/jsc/modules/ObjectModule.h b/src/jsc/modules/ObjectModule.h index 9e4807a8c4d9..b4629dfbbaa5 100644 --- a/src/jsc/modules/ObjectModule.h +++ b/src/jsc/modules/ObjectModule.h @@ -2,22 +2,20 @@ #include "../bindings/ZigGlobalObject.h" #include +#include namespace Zig { -JSC::SyntheticSourceProvider::SyntheticSourceGenerator -generateObjectModuleSourceCode(JSC::JSGlobalObject* globalObject, - JSC::JSObject* object); -JSC::SyntheticSourceProvider::SyntheticSourceGenerator -generateObjectModuleSourceCodeForJSON(JSC::JSGlobalObject* globalObject, - JSC::JSObject* object); +// These create the source for a synthetic module that exports a JS value. The loader can instantiate the +// source zero or more times, so the value lives as long as the JSSourceCode. -JSC::SyntheticSourceProvider::SyntheticSourceGenerator -generateJSValueModuleSourceCode(JSC::JSGlobalObject* globalObject, - JSC::JSValue value); +// Exports the own enumerable string-keyed properties of `exports`. +JSC::JSSourceCode* createObjectModuleSourceCode(GlobalObject*, JSC::JSObject* exports, WTF::String&& sourceURL); -JSC::SyntheticSourceProvider::SyntheticSourceGenerator -generateJSValueExportDefaultObjectSourceCode(JSC::JSGlobalObject* globalObject, - JSC::JSValue value); +// Exports `value` as the default export. If it's a non-array object, its properties are also exported. +JSC::JSSourceCode* createJSValueModuleSourceCode(GlobalObject*, JSC::JSValue value, WTF::String&& sourceURL); + +// Exports `value` as the default export and nothing else. +JSC::JSSourceCode* createJSValueExportDefaultObjectSourceCode(GlobalObject*, JSC::JSValue value, WTF::String&& sourceURL); } // namespace Zig diff --git a/src/jsc/modules/UTF8ValidateModule.h b/src/jsc/modules/UTF8ValidateModule.h index e5d12b93a250..cb26c385367a 100644 --- a/src/jsc/modules/UTF8ValidateModule.h +++ b/src/jsc/modules/UTF8ValidateModule.h @@ -14,8 +14,7 @@ generateNativeModule_UTF8Validate(JSC::JSGlobalObject* globalObject, exportNames.append(vm.propertyNames->defaultKeyword); exportValues.append(JSC::JSFunction::create( vm, globalObject, 1, "utf8Validate"_s, jsBufferConstructorFunction_isUtf8, - ImplementationVisibility::Public, NoIntrinsic, - jsBufferConstructorFunction_isUtf8)); + ImplementationVisibility::Public, NoIntrinsic)); } } // namespace Zig diff --git a/src/jsc/modules/_NativeModule.h b/src/jsc/modules/_NativeModule.h index 0f5b73c41893..c7652606e1b2 100644 --- a/src/jsc/modules/_NativeModule.h +++ b/src/jsc/modules/_NativeModule.h @@ -113,7 +113,7 @@ if (!value) { \ auto *function = JSC::JSFunction::create( \ vm, globalObject, 1, name.string(), ptr, \ - JSC::ImplementationVisibility::Public, JSC::NoIntrinsic, ptr); \ + JSC::ImplementationVisibility::Public, JSC::NoIntrinsic); \ /* Match `put`: only populate the shared object on first build; a \ user-deleted property on the cached object stays deleted. */ \ if (!defaultObjectWasCached) \ diff --git a/src/jsc/node_path.rs b/src/jsc/node_path.rs index b30e2ff57578..351b4aa45aae 100644 --- a/src/jsc/node_path.rs +++ b/src/jsc/node_path.rs @@ -83,17 +83,14 @@ impl<'a> PathLike<'a> { impl PathLike<'static> { /// For a path a `Blob` store keeps (dropped on any thread): a JS-backed - /// string becomes a private copy never handed to JS; a buffer stays - /// pinned and is GC-protected for good. + /// string becomes a private copy never handed to JS, and a buffer's bytes + /// are copied and its pin is released here, on the JS thread. pub fn thread_isolated_copy(self) -> Self { match self { Self::String(s) | Self::ThreadIsolatedString(s) => { Self::ThreadIsolatedString(s.thread_isolated_copy()) } - Self::Buffer(b) => { - b.value.protect(); - Self::Buffer(b) - } + Self::Buffer(b) => Self::owned(b.slice().to_vec()), Self::Utf8(s) => Self::Utf8(s), } } diff --git a/src/jsc/resolver_jsc.rs b/src/jsc/resolver_jsc.rs index 2428a104b946..dc9cb1ff271c 100644 --- a/src/jsc/resolver_jsc.rs +++ b/src/jsc/resolver_jsc.rs @@ -44,11 +44,9 @@ extern "C" fn node_module_paths_js_value( } else { utf8.slice() }; - let mut buf = bun_paths::path_buffer_pool::get(); - - let mut full_path: &[u8] = resolve_path::join_abs_string_buf::( + // Like Node's `_nodeModulePaths`, this is pure string manipulation, so there's no length limit. + let mut full_path: &[u8] = resolve_path::join_abs_string::( bun_paths::fs::FileSystem::instance().top_level_dir(), - &mut **buf, &[base_path], ); let root_index: usize = { diff --git a/src/jsc/virtual_machine_exports.rs b/src/jsc/virtual_machine_exports.rs index 3c5579f86bd8..3eea15b5d2d2 100644 --- a/src/jsc/virtual_machine_exports.rs +++ b/src/jsc/virtual_machine_exports.rs @@ -57,6 +57,30 @@ pub fn exit_during_uncaught_exception(this: &mut VirtualMachine) { this.exit_on_uncaught_exception = true; } +fn absolute_directory(directory: &BunString) -> Box<[u8]> { + let directory = directory.to_owned_slice(); + let directory = if bun_paths::is_absolute(&directory) { + directory + } else { + let mut cwd_buf = bun_paths::path_buffer_pool::get(); + match bun_sys::getcwd_z(&mut cwd_buf) { + Ok(cwd) => bun_paths::resolve_path::join::(&[ + cwd.as_bytes(), + &directory, + ]) + .to_vec(), + Err(_) => directory, + } + }; + directory.into_boxed_slice() +} + +/// Makes the path absolute now, because the report is written at exit and the working directory may change before then. +// HOST_EXPORT(Bun__VirtualMachine__setSamplingProfilerDirectory, c) +pub fn set_sampling_profiler_directory(this: &mut VirtualMachine, directory: &BunString) { + this.sampling_profiler_directory = Some(absolute_directory(directory)); +} + // `Bun__Process__send` lives in `bun_runtime::ipc_host` (its body — via // `do_send` — names the `bun_runtime::Listener` type; LAYERING). @@ -239,8 +263,8 @@ pub fn get_verbose_fetch_value() -> i32 { } } -// `Bun__addBakeSourceProviderSourceMap` / `Bun__addDevServerSourceProvider` / -// `Bun__removeDevServerSourceProvider` live in +// `Bun__{add,remove}BakeSourceProviderSourceMap` / +// `Bun__{add,remove}DevServerSourceProvider` live in // `bun_runtime::bake::source_provider_exports` (their callers are bake's C++ // source providers; LAYERING). diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index af66983452bd..d8b2f4e161bc 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -133,7 +133,7 @@ struct WorkerVmInit { enum EntryOutcome { Continue, - /// The entry module rejected and no handler took it: the worker exits. + /// The entry module rejected, and either no handler caught it or the handler stopped the worker. Stop, } @@ -884,6 +884,11 @@ impl WebWorker { // so buffered postMessageToThread deliveries drain and the sender's // Atomics.waitAsync settles. WebWorker__entrySettled re-calls it as a no-op. WebWorker__entrySettled(vm.global()); + // The hook runs JS, which can request termination. If it did, stop before the start sequence's GC. + if self.has_requested_terminate() { + self.flush_logs(vm); + return self.shutdown(); + } // The entry's evaluation outcome is checked once now and then after every // loop turn: a rejection (immediate, or a top-level await rejecting @@ -908,7 +913,7 @@ impl WebWorker { (*promise).result(vm.jsc_vm()), is_rejection, ); - if handled { + if handled && !self.has_requested_terminate() { EntryOutcome::Continue } else { EntryOutcome::Stop @@ -916,8 +921,8 @@ impl WebWorker { } }; if let EntryOutcome::Stop = observe_entry(vm) { - // exit_code is already 1 from uncaught_exception; re-setting it here - // would clobber a process.on('exit') change to process.exitCode. + // exit_code is already set (1, or the handler's process.exit() code). Setting it again + // here would clobber a process.on('exit') change to process.exitCode. return self.shutdown(); } // A still-pending entry promise is an unsettled top-level await: as in @@ -1137,7 +1142,7 @@ impl WebWorker { fn on_unhandled_rejection( vm: &mut VirtualMachine, - global_object: &JSGlobalObject, + _: &JSGlobalObject, error_instance_or_exception: JSValue, ) { // Prevent recursion @@ -1151,6 +1156,9 @@ fn on_unhandled_rejection( return; } + // The realm that raised the error may be a node:vm context. + let global_object = vm.global(); + let mut error_instance = error_instance_or_exception .to_error() .unwrap_or(error_instance_or_exception); diff --git a/src/libdeflate_sys/libdeflate.rs b/src/libdeflate_sys/libdeflate.rs index 3f0a88c7a35f..19431b451b4f 100644 --- a/src/libdeflate_sys/libdeflate.rs +++ b/src/libdeflate_sys/libdeflate.rs @@ -76,6 +76,20 @@ bun_opaque::opaque_ffi! { pub struct Compressor; } +/// libdeflate's one-shot compressors return 0 when the output buffer is too small. +#[inline] +fn compress_result(read: usize, written: usize) -> Result { + Result { + read, + written, + status: if written == 0 { + Status::InsufficientSpace + } else { + Status::Success + }, + } +} + impl Compressor { pub(crate) fn alloc(compression_level: c_int) -> *mut Compressor { libdeflate_alloc_compressor(compression_level) @@ -93,11 +107,7 @@ impl Compressor { output.len(), ) }; - Result { - read: input.len(), - written, - status: Status::Success, - } + compress_result(input.len(), written) } pub fn max_bytes_needed(&mut self, input: &[u8], encoding: Encoding) -> usize { @@ -142,11 +152,7 @@ impl Compressor { Encoding::Gzip => libdeflate_gzip_compress(self, in_ptr, in_len, out_ptr, out_len), } }; - Result { - read: in_len, - written, - status: Status::Success, - } + compress_result(in_len, written) } /// Compress `input` onto the end of `out`, reserving the bound first; `Err` if that allocation fails. @@ -178,11 +184,7 @@ impl Compressor { output.len(), ) }; - Result { - read: input.len(), - written: result, - status: Status::Success, - } + compress_result(input.len(), result) } pub(crate) fn gzip(&mut self, input: &[u8], output: &mut [u8]) -> Result { @@ -196,11 +198,7 @@ impl Compressor { output.len(), ) }; - Result { - read: input.len(), - written: result, - status: Status::Success, - } + compress_result(input.len(), result) } } diff --git a/src/libuv_sys/libuv.rs b/src/libuv_sys/libuv.rs index 9175e67e2677..19eaf5b60d6d 100644 --- a/src/libuv_sys/libuv.rs +++ b/src/libuv_sys/libuv.rs @@ -15,10 +15,12 @@ clippy::missing_safety_doc )] -use core::cell::{Cell, UnsafeCell}; +use core::cell::Cell; use core::ffi::{c_char, c_int, c_long, c_uint, c_ulong, c_ushort, c_void}; use core::mem::MaybeUninit; +use core::time::Duration; use core::{fmt, mem, ptr}; +use std::time::Instant; // ────────────────────────────────────────────────────────────────────────── // Debug log scope (`bun.Output.scoped(.uv, .hidden)`). This crate is leaf @@ -362,23 +364,11 @@ pub struct Loop { } pub type uv_loop_t = Loop; -// `Loop::get()` escapes a raw pointer into this TLS slot out of the -// `LocalKey::with` closure and hands it to libuv for the thread lifetime. -// That is sound only because the slot has NO destructor: with no `Drop`, std -// registers no TLS dtor, so the `LocalKey` storage outlives every per-thread -// caller and the escaped address stays valid until thread exit. Guard the -// no-Drop invariant at compile time so adding `impl Drop for Loop` (or -// wrapping the cell) fails loudly here rather than becoming silent UB. -const _: () = assert!(!core::mem::needs_drop::()); -const _: () = assert!(!core::mem::needs_drop::>>()); - thread_local! { - /// Static TLS storage (zero-alloc). `MaybeUninit` because the slot is - /// only valid after `uv_loop_init`. - static THREADLOCAL_LOOP_DATA: UnsafeCell> = - const { UnsafeCell::new(MaybeUninit::uninit()) }; - /// `threadlocal var threadlocal_loop: ?*Loop = null` — null until `get()` - /// initializes `THREADLOCAL_LOOP_DATA`. + /// Points to this thread's loop. `get()` heap-allocates it and + /// `close_thread_loop` frees it after `uv_loop_close` succeeds. libuv's + /// loop registry points to it until then (`uv__wake_all_loops` walks the + /// registry on system resume), so it can't live in thread-local storage. static THREADLOCAL_LOOP: Cell<*mut Loop> = const { Cell::new(ptr::null_mut()) }; } @@ -411,13 +401,9 @@ impl Loop { if !existing.is_null() { return existing; } - // SAFETY: TLS slot is per-thread; no aliasing. `uv_loop_init` - // accepts uninitialized storage (it zero-fills internally). - // Escaping the pointer past `.with()` is intentional: the slot is - // const-initialized POD with no TLS destructor (static-asserted - // above), so its address is stable for the thread lifetime. - let ptr_ = THREADLOCAL_LOOP_DATA.with(|data| unsafe { (*data.get()).as_mut_ptr() }); - // SAFETY: `ptr_` is `sizeof(Loop)` TLS storage owned by this thread. + // `uv_loop_init` accepts uninitialized storage (it zero-fills internally). + let ptr_ = bun_core::heap::into_raw(Box::::new_uninit()).cast::(); + // SAFETY: `ptr_` is `sizeof(Loop)` heap storage owned by this thread. if let Some(err) = unsafe { uv_loop_init(ptr_) }.raw_errno() { panic!("Failed to initialize libuv loop: errno {err}"); } @@ -461,13 +447,17 @@ impl Loop { /// pre/check/async/timer, closed by us_loop_free and freed by their close /// callbacks when the loop next turns. pub fn close_thread_loop() { + /// Caps how long `terminate()` and process exit wait for a close that + /// never completes. A healthy teardown finishes in milliseconds. + const CLOSE_THREAD_LOOP_DEADLINE: Duration = Duration::from_secs(10); THREADLOCAL_LOOP.with(|slot| { let loop_ = slot.get(); if loop_.is_null() { return; } // SAFETY: `loop_` is the live per-thread loop initialized in `get()`. - if let Some(err) = unsafe { uv_loop_close(loop_) }.raw_errno() { + let mut rc = unsafe { uv_loop_close(loop_) }; + if let Some(err) = rc.raw_errno() { // Only EBUSY means handles are still linked; walk + close any not // already closing, run to flush close callbacks and endgames, then // close again (must succeed). `uv_loop_close` documents no other @@ -479,20 +469,23 @@ impl Loop { // (owners are freed only after this returns). unsafe { uv_walk(loop_, Some(log_unclosed_cb), ptr::null_mut()) }; unsafe { uv_walk(loop_, Some(close_walk_cb), ptr::null_mut()) }; - // Everything is closing now; only close callbacks / endgames - // remain. Turn the loop without blocking until they have run — - // RunMode::Default would also wait on ref'd-but-idle state - // (Bun's virtual keep-alive count lives in active_handles) and - // never return. - let mut rc = ReturnCode::ZERO; - for _ in 0..64 { + // Poll with NoWait until the loop closes. A closing uv_poll_t + // is only unlinked after the kernel posts its canceled AFD + // poll's completion to the IOCP, and one NoWait turn dequeues + // at most 128 completions. RunMode::Default could block + // forever because Bun's virtual keep-alive count is part of + // active_handles. + let started = Instant::now(); + loop { // SAFETY: this thread's initialised loop; nothing else drives it. let _ = unsafe { uv_run(loop_, RunMode::NoWait) }; // SAFETY: as above. rc = unsafe { uv_loop_close(loop_) }; - if rc == ReturnCode::ZERO { + if rc == ReturnCode::ZERO || started.elapsed() >= CLOSE_THREAD_LOOP_DEADLINE + { break; } + std::thread::sleep(Duration::from_millis(1)); } debug_assert_eq!( rc, @@ -502,6 +495,12 @@ impl Loop { } } slot.set(ptr::null_mut()); + if rc == ReturnCode::ZERO { + // SAFETY: `get()` allocated it, `uv_loop_close` unregistered it, + // and nothing else references it. + unsafe { bun_core::heap::destroy(loop_.cast::>()) }; + } + // Otherwise libuv's registry still points to the loop, so leak it. }); } diff --git a/src/lint/eslint/rules/no_useless_assignment.rs b/src/lint/eslint/rules/no_useless_assignment.rs index 079b50c50454..9845c4164ea7 100644 --- a/src/lint/eslint/rules/no_useless_assignment.rs +++ b/src/lint/eslint/rules/no_useless_assignment.rs @@ -76,6 +76,8 @@ fn extract_identifiers_from_pattern<'a>(pattern: Expr<'a>, mut visit: impl FnMut while let Some(pattern) = stack.pop() { match pattern.kind() { ExprKind::Ident(_) => visit(pattern), + // In parentheses ESTree has an expression, not a pattern. + _ if pattern.is_parenthesized() => {} ExprKind::Object(properties) => stack.extend(properties.iter().filter_map(Prop::value)), ExprKind::Array(elements) => stack.extend(elements), ExprKind::Spread(argument) => stack.push(argument), @@ -105,7 +107,9 @@ fn get_code_path_start_scope<'a>( scope: Scope<'a>, code_path_start_scopes: &IdSet>, ) -> Option> { - scope.chain().find(|it| code_path_start_scopes.contains(*it)) + scope + .chain() + .find(|it| code_path_start_scopes.contains(*it)) } /// True if `reference` is in a decorator of a parameter of a function nested in `scope`'s code path. Such a decorator diff --git a/src/md/ansi_renderer.rs b/src/md/ansi_renderer.rs index 84c9bd722ea4..147e7de37960 100644 --- a/src/md/ansi_renderer.rs +++ b/src/md/ansi_renderer.rs @@ -2751,11 +2751,7 @@ fn resolve_local_image_path(src: &[u8], base_dir: Option<&[u8]>) -> Option) -> Option return None, } }; - let joined = - bun_paths::resolve_path::join_abs_string::(base, &[&decoded]); - let abs = Box::<[u8]>::from(joined); + // If the path is too long, the caller falls back to alt text. + let mut abs_buf = bun_paths::path_buffer_pool::get(); + let abs_z = bun_paths::resolve_path::join_abs_string_buf_z_checked::( + base, + &mut abs_buf[..], + &[&decoded], + )?; // Stat instead of plain exists() so a directory like `./assets/` gets // rejected. bun.sys.exists wraps access(path, F_OK) which returns true // for any entry, including directories — and emitKittyImageFile sets // q=2 so the terminal silently drops directory paths without falling // through to alt text. - let mut zbuf = bun_paths::path_buffer_pool::get(); - let abs_z = bun_paths::resolve_path::z(&abs, &mut zbuf); match bun_sys::stat(abs_z) { Ok(s) => { if !bun_sys::S::ISREG(s.st_mode as _) { @@ -2783,7 +2781,7 @@ fn resolve_local_image_path(src: &[u8], base_dir: Option<&[u8]>) -> Option return None, } - Some(abs) + Some(Box::<[u8]>::from(abs_z.as_bytes())) } // ======================================== diff --git a/src/node_path/lib.rs b/src/node_path/lib.rs index e1136cee3970..ba557287ed38 100644 --- a/src/node_path/lib.rs +++ b/src/node_path/lib.rs @@ -2331,13 +2331,10 @@ pub fn resolve_windows_t<'a, T: PathCharCwd>( { // Translated from the following JS code: // path = `${resolvedDevice}\\`; - buf_size = resolved_device_len; - memmove(&mut buf2[0..buf_size], &tmp_buf[0..resolved_device_len]); - buf_offset = buf_size; - buf_size += 1; - buf2[buf_offset] = T::from_u8(CHAR_BACKWARD_SLASH); - path_ptr = buf2.as_ptr(); - path_len = buf_size; + // Append to the device in tmp_buf because buf2 backs resolvedTail. + tmp_buf[resolved_device_len] = T::from_u8(CHAR_BACKWARD_SLASH); + path_ptr = tmp_buf.as_ptr(); + path_len = resolved_device_len + 1; } } @@ -2514,8 +2511,7 @@ pub fn resolve_windows_t<'a, T: PathCharCwd>( } buf_size = slice_len; if slice_len > 0 { - // path may alias buf2 (drive-mismatch fallback); use ptr::copy. - // SAFETY: handles overlap. + // SAFETY: path has len elements and buf2 has room for the tail. unsafe { core::ptr::copy(path_ptr.add(root_end), buf2.as_mut_ptr(), slice_len); } diff --git a/src/options_types/compile_target.rs b/src/options_types/compile_target.rs index e4ca61a31d94..2742e9d5e025 100644 --- a/src/options_types/compile_target.rs +++ b/src/options_types/compile_target.rs @@ -193,38 +193,42 @@ impl CompileTarget { version_str: &'a ZStr, _env: &mut bun_dotenv::Loader, needs_download: &mut bool, - ) -> &'a ZStr { + ) -> Result<&'a ZStr, crate::Error> { if self.is_default() { 'brk: { let Ok(self_exe_path) = bun_core::self_exe_path() else { break 'brk; }; + if self_exe_path.len() >= buf.len() { + return Err(crate::Error::BufferTooSmall); + } buf[..self_exe_path.len()].copy_from_slice(self_exe_path.as_bytes()); buf[self_exe_path.len()] = 0; *needs_download = false; // SAFETY: buf[self_exe_path.len()] == 0 written above - return ZStr::from_buf(&buf[..], self_exe_path.len()); + return Ok(ZStr::from_buf(&buf[..], self_exe_path.len())); } } if bun_sys::exists_at(Fd::cwd(), version_str) { *needs_download = false; - return version_str; + return Ok(version_str); } // T1 fallback ignores `_env` (full env-override chain lives in bun_install). let cache_dir = bun_sys::fetch_cache_directory_path(); - let dest = path::resolve_path::join_abs_string_buf_z::( + let dest = path::resolve_path::join_abs_string_buf_z_checked::( path::fs::FileSystem::instance().top_level_dir(), &mut buf[..], &[cache_dir.as_slice(), version_str.as_bytes()], - ); + ) + .ok_or(crate::Error::BufferTooSmall)?; if bun_sys::exists_at(Fd::cwd(), dest) { *needs_download = false; } - dest + Ok(dest) } // `download_to_path` moved up to `bun_standalone_graph` so it can name diff --git a/src/parsers/json.rs b/src/parsers/json.rs index 19ec1e1b6bc9..2a49d633eec6 100644 --- a/src/parsers/json.rs +++ b/src/parsers/json.rs @@ -792,14 +792,24 @@ pub enum ValueLocation<'p> { impl ValueLocation<'_> { /// First byte of the value, falling back to the nearest key/container location. + /// Iterates instead of recursing so deeply nested arrays can't overflow the stack. pub fn resolve(&self, contents: &[u8]) -> bun_ast::Loc { - match self { - ValueLocation::Property(key_loc) => property_value_loc_or_key(contents, *key_loc), - ValueLocation::ArrayItem(array, index) => { - let array_loc = array.resolve(contents); - array_item_loc(contents, array_loc, *index).unwrap_or(array_loc) + let mut indices: Vec = Vec::new(); + let mut cur = self; + let key_loc = loop { + match cur { + ValueLocation::Property(key_loc) => break *key_loc, + ValueLocation::ArrayItem(array, index) => { + indices.push(*index); + cur = array; + } } + }; + let mut loc = property_value_loc_or_key(contents, key_loc); + for &index in indices.iter().rev() { + loc = array_item_loc(contents, loc, index).unwrap_or(loc); } + loc } } diff --git a/src/parsers/json5.rs b/src/parsers/json5.rs index ea98e9ddd80d..505aebae6734 100644 --- a/src/parsers/json5.rs +++ b/src/parsers/json5.rs @@ -307,6 +307,13 @@ pub enum ExternalError { } bun_core::impl_tag_error!(ExternalError); +/// The parser already logged the error, like every other `SyntaxError`. +impl From for ExternalError { + fn from(_: bun_ast::SourceTooLarge) -> Self { + ExternalError::SyntaxError + } +} + impl From for crate::Error { fn from(e: ExternalError) -> Self { match e { @@ -356,6 +363,7 @@ impl<'a> JSON5Parser<'a> { } pub fn parse(source: &'a Source, log: &mut Log, bump: &'a Bump) -> Result { + source.check_parseable_len(log, "JSON5 document")?; let mut parser = JSON5Parser { source: source.contents.as_ref(), pos: 0, diff --git a/src/paths/Path.rs b/src/paths/Path.rs index 0566935f0f8d..b0956c4e8440 100644 --- a/src/paths/Path.rs +++ b/src/paths/Path.rs @@ -900,11 +900,21 @@ impl } #[inline] - pub(crate) fn clear(&mut self) { + pub fn clear(&mut self) { self._buf.set_length(0); } pub fn append(&mut self, input: &[C]) -> options::Result<()> { + self.append_with(CheckLength::from_u8(CHECK), input) + } + + /// [`Self::append`] that returns `Err(MaxPathExceeded)` on overflow whatever `CHECK` is. + pub fn try_append(&mut self, input: &[C]) -> options::Result<()> { + self.append_with(CheckLength::CheckForGreaterThanMaxPath, input) + } + + #[inline(always)] + fn append_with(&mut self, check: CheckLength, input: &[C]) -> options::Result<()> { let needs_sep = self.len() > 0 && match PathSeparators::from_u8(SEP_OPT) { PathSeparators::Any => { @@ -941,7 +951,7 @@ impl return Ok(()); } - if CheckLength::from_u8(CHECK) == CheckLength::CheckForGreaterThanMaxPath { + if check == CheckLength::CheckForGreaterThanMaxPath { if self.len() + trimmed.len() + (needs_sep as usize) >= U::MAX_PATH { return Err(PathError::MaxPathExceeded); } @@ -958,7 +968,7 @@ impl return Ok(()); } - if CheckLength::from_u8(CHECK) == CheckLength::CheckForGreaterThanMaxPath { + if check == CheckLength::CheckForGreaterThanMaxPath { if self.len() + trimmed.len() + (needs_sep as usize) >= U::MAX_PATH { return Err(PathError::MaxPathExceeded); } @@ -993,7 +1003,7 @@ impl return Ok(()); } - if CheckLength::from_u8(CHECK) == CheckLength::CheckForGreaterThanMaxPath { + if check == CheckLength::CheckForGreaterThanMaxPath { if self.len() + trimmed.len() + (needs_sep as usize) >= U::MAX_PATH { return Err(PathError::MaxPathExceeded); } @@ -1006,6 +1016,19 @@ impl } pub fn append_fmt(&mut self, args: core::fmt::Arguments<'_>) -> options::Result<()> { + self.append_fmt_with(CheckLength::from_u8(CHECK), args) + } + + /// [`Self::append_fmt`] that returns `Err(MaxPathExceeded)` on overflow whatever `CHECK` is. + pub fn try_append_fmt(&mut self, args: core::fmt::Arguments<'_>) -> options::Result<()> { + self.append_fmt_with(CheckLength::CheckForGreaterThanMaxPath, args) + } + + fn append_fmt_with( + &mut self, + check: CheckLength, + args: core::fmt::Arguments<'_>, + ) -> options::Result<()> { // TODO: there's probably a better way to do this. needed for trimming slashes let mut temp: Path = Path::init(); @@ -1021,7 +1044,7 @@ impl &u8::buffer_as_slice(&temp._buf.pooled)[..written] } Err(_) => { - if CheckLength::from_u8(CHECK) == CheckLength::CheckForGreaterThanMaxPath { + if check == CheckLength::CheckForGreaterThanMaxPath { return Err(PathError::MaxPathExceeded); } unreachable!(); @@ -1029,7 +1052,7 @@ impl } }; - self.append(input) + self.append_with(check, input) } fn assert_joinable(&self) { @@ -1081,6 +1104,14 @@ impl Ok(()) } + /// [`Self::append_join`] that returns `Err(MaxPathExceeded)` on overflow whatever `CHECK` is. + pub fn try_append_join(&mut self, part: &[C]) -> options::Result<()> { + if self.len() + 1 + part.len() >= U::MAX_PATH { + return Err(PathError::MaxPathExceeded); + } + self.append_join(part) + } + pub fn append_join(&mut self, part: &[C]) -> options::Result<()> { self.assert_joinable(); diff --git a/src/paths/lib.rs b/src/paths/lib.rs index 6f815e5712f1..3523a150f35e 100644 --- a/src/paths/lib.rs +++ b/src/paths/lib.rs @@ -365,38 +365,6 @@ pub use path::{ options as path_options, }; -/// Generic surface for the `buf` parameter on path-builder helpers -/// (`appendStorePath`, `appendGlobalStoreEntryPath`, etc.). Callers pass -/// `Path` for arbitrary const params, so expose the -/// three operations the helpers need behind a trait and blanket-impl it for -/// every monomorphisation. -pub trait PathLike { - fn clear(&mut self); - fn append(&mut self, bytes: &[u8]); - fn append_fmt(&mut self, args: core::fmt::Arguments<'_>); -} -// Bound to `CheckLength::ASSUME` only. This prevents check-mode callers from -// silently swallowing `MaxPathExceeded` through the duck-typed surface; they -// must use `Path::append`/`?` directly. -impl PathLike - for path::Path -{ - #[inline] - fn clear(&mut self) { - path::Path::clear(self) - } - #[inline] - fn append(&mut self, bytes: &[u8]) { - use path::options::AssumeOk as _; - path::Path::append(self, bytes).assume_ok() - } - #[inline] - fn append_fmt(&mut self, args: core::fmt::Arguments<'_>) { - use path::options::AssumeOk as _; - path::Path::append_fmt(self, args).assume_ok() - } -} - /// Width-generic dirname /// (POSIX `/` on Unix, disk-designator-aware on Windows). Backed by /// `path::dirname_generic`. diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs index 39d6d4638b63..3f6574f11a2e 100644 --- a/src/paths/resolve_path.rs +++ b/src/paths/resolve_path.rs @@ -17,14 +17,50 @@ thread_local! { const { UnsafeCell::new([0u8; PARSER_JOIN_INPUT_BUFFER_LEN]) }; static PARSER_BUFFER: UnsafeCell<[u8; PARSER_BUFFER_LEN]> = const { UnsafeCell::new([0u8; PARSER_BUFFER_LEN]) }; + // One per family of thread-local helpers, see `tl_long_result`. + static PARSER_JOIN_INPUT_LONG: LongResults = const { UnsafeCell::new(Vec::new()) }; + static JOIN_LONG: LongResults = const { UnsafeCell::new(Vec::new()) }; + static RELATIVE_LONG: LongResults = const { UnsafeCell::new(Vec::new()) }; } -/// Output capacity of [`join_abs_string`] / [`join_abs_string_z`]. +/// Fixed output capacity of [`join_abs_string`] / [`join_abs_string_z`]. A longer result goes to the heap. const PARSER_JOIN_INPUT_BUFFER_LEN: usize = 4096; /// Output capacity of [`normalize_string`]. const PARSER_BUFFER_LEN: usize = 1024; +/// The blocks that have held a family's long results. Only the last one, the largest, is written. +type LongResults = UnsafeCell>>; + +/// Keeps `result`, which the caller computed in heap scratch because it may not fit the family's +/// fixed buffer. It replaces the family's previous long result in place, like a short result in the +/// fixed buffer. A block that is too small stays allocated until the thread exits, so a caller that +/// still holds an earlier result reads stale bytes and never freed ones. +#[inline(never)] +fn tl_long_result( + slot: &'static std::thread::LocalKey, + result: &[u8], +) -> &'static [u8] { + slot.with(|cell| { + // SAFETY: as in `tl_buf_mut`. + let blocks: &'static mut Vec> = unsafe { &mut *cell.get() }; + if blocks.last().is_none_or(|block| block.len() < result.len()) { + blocks.push(vec![0u8; result.len().next_power_of_two()]); + } + let last = blocks.len() - 1; + let kept = &mut blocks[last][..result.len()]; + kept.copy_from_slice(result); + &*kept + }) +} + +/// Keeps the code for a long result out of line, so that a short one only pays for the length check. +#[cold] +#[inline(never)] +fn cold(long_result: impl FnOnce() -> R) -> R { + long_result() +} + /// Project `&'static mut` into a thread-local `UnsafeCell<[u8; N]>` scratch /// buffer. One `unsafe` site for all `PARSER_BUFFER` / `PARSER_JOIN_INPUT_BUFFER` /// / `JOIN_BUF` accessors (nonnull-asref reduction: 6 sites → 1). @@ -134,7 +170,8 @@ fn get_if_exists_longest_common_path_generic<'a, P: PlatformT>( } _ => { let mut string_index: usize = 1; - while string_index < input.len() { + // `index -= 1` below needs a byte in every input. + while min_length > 0 && string_index < input.len() { while index < min_length { if P::P == Platform::Windows { if !input[0][index].eq_ignore_ascii_case(&input[string_index][index]) { @@ -270,7 +307,8 @@ fn longest_common_path_generic<'a, P: PlatformT>(input: &[&'a [u8]]) -> &'a [u8] } let mut string_index: usize = 1; - while string_index < input.len() { + // `index -= 1` below needs a byte in every input. + while min_length > 0 && string_index < input.len() { while index < min_length { if P::P == Platform::Windows { if !input[0][index].eq_ignore_ascii_case(&input[string_index][index]) { @@ -585,6 +623,18 @@ pub fn relative_normalized<'a, P: PlatformT, const ALWAYS_COPY: bool>( from: &'a [u8], to: &'a [u8], ) -> &'a [u8] { + let mut needed = relative_result_capacity(from.len(), to.len()); + if needed > MAX_PATH_BYTES { + needed = relative_result_capacity(count_separators(from), to.len()); + } + if needed > MAX_PATH_BYTES { + return cold(move || { + tl_long_result( + &RELATIVE_LONG, + relative_normalized_buf::(&mut vec![0u8; needed], from, to), + ) + }); + } // SAFETY: thread-local scratch; single live borrow per thread. relative_normalized_buf::( RELATIVE_TO_COMMON_PATH_BUF.with(lazy_path_buf), @@ -662,7 +712,26 @@ pub fn relative_platform_buf<'a, P: PlatformT, const ALWAYS_COPY: bool>( // two `&mut` borrows below are disjoint. let relative_from_buf = RELATIVE_FROM_BUF.with(lazy_path_buf); let relative_to_buf = RELATIVE_TO_BUF.with(lazy_path_buf); + relative_platform_in::( + buf, + &mut relative_from_buf[..], + &mut relative_to_buf[..], + relative_cwd::

(from, to), + from, + to, + ) +} +/// [`relative_platform_buf`] with the caller's buffers for the normalized `from` and `to`, and +/// the result of [`relative_cwd`]. +fn relative_platform_in<'a, P: PlatformT, const ALWAYS_COPY: bool>( + buf: &'a mut [u8], + relative_from_buf: &'a mut [u8], + relative_to_buf: &'a mut [u8], + cwd: &'a [u8], + from: &[u8], + to: &[u8], +) -> &'a [u8] { let normalized_from: &[u8] = if P::P.is_absolute(from) { 'brk: { if P::P == Platform::Loose && cfg!(windows) { @@ -690,11 +759,7 @@ pub fn relative_platform_buf<'a, P: PlatformT, const ALWAYS_COPY: bool>( // then join into relative_from_buf. Safe because normalized_to is computed // afterwards (overwrites relative_to_buf anyway). let norm_len = normalize_string_buf::(from, &mut relative_to_buf[..]).len(); - join_abs_string_buf::

( - Fs::FileSystem::instance().top_level_dir(), - relative_from_buf, - &[&relative_to_buf[..norm_len]], - ) + join_abs_string_buf::

(cwd, relative_from_buf, &[&relative_to_buf[..norm_len]]) }; let normalized_to: &[u8] = if P::P.is_absolute(to) { @@ -722,11 +787,7 @@ pub fn relative_platform_buf<'a, P: PlatformT, const ALWAYS_COPY: bool>( // output buffer, untouched until the final relative_normalized_buf call // and disjoint from both threadlocals), then join into relative_to_buf. let norm_len = normalize_string_buf::(to, buf).len(); - join_abs_string_buf::

( - Fs::FileSystem::instance().top_level_dir(), - relative_to_buf, - &[&buf[..norm_len]], - ) + join_abs_string_buf::

(cwd, relative_to_buf, &[&buf[..norm_len]]) }; relative_normalized_buf::(buf, normalized_from, normalized_to) @@ -736,14 +797,90 @@ pub fn relative_platform( from: &[u8], to: &[u8], ) -> &'static [u8] { - // SAFETY: thread-local scratch; single live borrow per thread. - relative_platform_buf::( + let cwd = relative_cwd::

(from, to); + let from_needed = relative_operand_needed::

(cwd, from); + let to_needed = relative_operand_needed::

(cwd, to); + // A path has at most as many separators as bytes. Count them only if that bound does not fit. + let mut needed = relative_result_capacity(from_needed, to_needed); + if needed > MAX_PATH_BYTES { + needed = relative_result_capacity(relative_operand_separators::

(cwd, from), to_needed) + .max(from_needed + 1); + } + if needed > MAX_PATH_BYTES { + return cold(move || { + tl_long_result( + &RELATIVE_LONG, + relative_platform_in::( + &mut vec![0u8; needed], + &mut vec![0u8; from_needed + 1], + // A relative `from` is normalized here first. + &mut vec![0u8; to_needed.max(from_needed) + 1], + cwd, + from, + to, + ), + ) + }); + } + relative_platform_in::( RELATIVE_TO_COMMON_PATH_BUF.with(lazy_path_buf), + RELATIVE_FROM_BUF.with(lazy_path_buf), + RELATIVE_TO_BUF.with(lazy_path_buf), + cwd, from, to, ) } +/// What `relative` resolves a relative operand against. It is read once per call, because +/// `process.chdir()` on another thread must not change it between the bound and the join. +#[inline(always)] +fn relative_cwd(from: &[u8], to: &[u8]) -> &'static [u8] { + if P::P.is_absolute(from) && P::P.is_absolute(to) { + return b""; + } + Fs::FileSystem::instance().top_level_dir() +} + +/// The most bytes of a buffer that `relative` uses for `path`. +#[inline] +fn relative_operand_needed(cwd: &[u8], path: &[u8]) -> usize { + if !P::P.is_absolute(path) { + // One byte more when normalized, then joined as `join_abs_string_buf_checked` bounds it. + join_abs_needed(cwd.len(), &[path]) + 2 + } else if P::P == Platform::Posix { + // The root is byte 0 and the normalized rest follows it. + path.len() + } else { + // Byte 0 stays free, and `C:\..` or a bare UNC volume normalizes to one byte more. + path.len() + 2 + } +} + +/// The most separators that `path` has after `relative` made it absolute and normalized it. +fn relative_operand_separators(cwd: &[u8], path: &[u8]) -> usize { + if P::P.is_absolute(path) { + // `C:\..` or a bare UNC volume gains one. + count_separators(path) + 1 + } else { + // The join adds one, and a root if `cwd` has none. + count_separators(cwd) + count_separators(path) + 2 + } +} + +/// Normalizing turns `\` into a separator on every platform. +#[cold] +#[inline(never)] +fn count_separators(path: &[u8]) -> usize { + strings::count_char(path, SEP_POSIX) + strings::count_char(path, SEP_WINDOWS) +} + +/// Bounds a `relative_to_common_path` result: a `../` per component of `from`, then `to`. +#[inline] +fn relative_result_capacity(from_separators: usize, to_len: usize) -> usize { + (from_separators + 1) * 3 + to_len +} + pub fn relative_alloc(from: &[u8], to: &[u8]) -> Result, bun_alloc::AllocError> { let result = relative_platform::(from, to); Ok(Box::<[u8]>::from(result)) @@ -1304,6 +1441,23 @@ pub fn normalize_buf_z<'a, P: PlatformT>(str: &[u8], buf: &'a mut [u8]) -> &'a m unsafe { ZStr::from_raw_mut(buf.as_mut_ptr(), len) } } +/// [`normalize_buf`], `None` when the result does not fit `buf`. +pub fn normalize_buf_checked<'a, P: PlatformT>( + str: &[u8], + buf: &'a mut [u8], +) -> Option<&'a mut [u8]> { + // Normalizing grows a path by at most one byte (`""` -> `.`, `C:` -> `C:.`). + let needed = str.len() + 1; + if needed <= buf.len() { + return Some(normalize_buf::

(str, buf)); + } + let mut scratch = vec![0u8; needed]; + let normalized = normalize_buf::

(str, &mut scratch); + let out = buf.get_mut(..normalized.len())?; + out.copy_from_slice(normalized); + Some(out) +} + /// [`normalize_buf_z`] into `buf` when the result fits, otherwise into `spill` (grown as needed). pub fn normalize_buf_z_spill<'a, P: PlatformT>( buf: &'a mut [u8], @@ -1404,6 +1558,16 @@ pub fn join_abs<'a, P: PlatformT>(cwd: &'a [u8], part: &[u8]) -> &'a [u8] { // result borrows the thread-local buffer ('static) OR returns `cwd` // directly when `parts.is_empty()`. Return tied to `cwd`'s lifetime ('static: 'a). pub fn join_abs_string<'a, P: PlatformT>(cwd: &'a [u8], parts: &[&[u8]]) -> &'a [u8] { + let needed = join_abs_needed(cwd.len(), parts) + nt_prefix_len::

(); + // Without parts the result is `cwd` itself. + if needed > PARSER_JOIN_INPUT_BUFFER_LEN && !parts.is_empty() { + return cold(move || { + tl_long_result( + &PARSER_JOIN_INPUT_LONG, + join_abs_string_buf::

(cwd, &mut vec![0u8; needed], parts), + ) + }); + } PARSER_JOIN_INPUT_BUFFER.with(|b| join_abs_string_buf::

(cwd, tl_buf_mut(b), parts)) } @@ -1436,9 +1600,19 @@ pub fn join_abs_string_spill<'a, P: PlatformT>( /// /// Returned path is stored in a temporary buffer. It must be copied if it needs to be stored. pub fn join_abs_string_z<'a, P: PlatformT>(cwd: &'a [u8], parts: &[&[u8]]) -> &'a ZStr { + let needed = join_abs_needed(cwd.len(), parts) + nt_prefix_len::

() + 1; + if needed > PARSER_JOIN_INPUT_BUFFER_LEN { + return cold(move || { + ZStr::from_slice_with_nul(tl_long_result( + &PARSER_JOIN_INPUT_LONG, + join_abs_string_buf_z::

(cwd, &mut vec![0u8; needed], parts).as_bytes_with_nul(), + )) + }); + } PARSER_JOIN_INPUT_BUFFER.with(|b| join_abs_string_buf_z::

(cwd, tl_buf_mut(b), parts)) } +/// Fixed output capacity of [`join`] / [`join_z`]. A longer result goes to the heap. const JOIN_BUF_LEN: usize = 4096; thread_local! { @@ -1447,10 +1621,28 @@ thread_local! { } pub fn join(parts: &[&[u8]]) -> &'static [u8] { + let needed = join_needed(parts); + if needed > JOIN_BUF_LEN { + return cold(move || { + tl_long_result( + &JOIN_LONG, + join_string_buf::

(&mut vec![0u8; needed], parts), + ) + }); + } JOIN_BUF.with(|b| join_string_buf::

(tl_buf_mut(b), parts)) } pub fn join_z(parts: &[&[u8]]) -> &'static ZStr { + let needed = join_needed(parts); + if needed > JOIN_BUF_LEN { + return cold(move || { + ZStr::from_slice_with_nul(tl_long_result( + &JOIN_LONG, + join_z_buf::

(&mut vec![0u8; needed], parts).as_bytes_with_nul(), + )) + }); + } JOIN_BUF.with(|b| join_z_buf::

(tl_buf_mut(b), parts)) } @@ -1459,6 +1651,22 @@ fn join_needed(parts: &[&[u8]]) -> usize { parts.iter().map(|p| p.len() + 1).sum::() + 1 } +/// [`join_z_buf`], `None` when the result and its NUL do not fit `buf`. +pub fn join_z_buf_checked<'a, P: PlatformT>( + buf: &'a mut [u8], + parts: &[&[u8]], +) -> Option<&'a ZStr> { + let needed = join_needed(parts); + if needed <= buf.len() { + return Some(join_z_buf::

(buf, parts)); + } + let mut scratch = vec![0u8; needed]; + let joined = join_z_buf::

(&mut scratch, parts).as_bytes_with_nul(); + let out = buf.get_mut(..joined.len())?; + out.copy_from_slice(joined); + Some(ZStr::from_slice_with_nul(out)) +} + /// [`join_z_buf`] into `buf` when the result fits, otherwise into `spill` /// (grown as needed). `spill` is untouched in the common case. pub fn join_z_buf_spill<'a, P: PlatformT>( @@ -1490,20 +1698,14 @@ pub fn join_z_spill<'a, P: PlatformT>(spill: &'a mut Vec, parts: &[&[u8]]) - } pub fn join_z_buf<'a, P: PlatformT>(buf: &'a mut [u8], parts: &[&[u8]]) -> &'a ZStr { - // reshaped for borrowck — capture buf base ptr before sub-borrow - let buf_base = buf.as_mut_ptr(); + let buf_base = buf.as_ptr() as usize; let buf_len = buf.len(); let (start_offset, len) = { let joined = join_string_buf::

(&mut buf[..buf_len - 1], parts); - ( - (joined.as_ptr() as usize) - (buf_base as usize), - joined.len(), - ) + (joined.as_ptr() as usize - buf_base, joined.len()) }; - debug_assert!(start_offset + len < buf_len); buf[start_offset + len] = 0; - // SAFETY: NUL written at buf[start_offset + len]; slice is within buf - unsafe { ZStr::from_raw(buf_base.add(start_offset), len) } + ZStr::from_slice_with_nul(&buf[start_offset..=start_offset + len]) } pub fn join_string_buf<'a, P: PlatformT>(buf: &'a mut [u8], parts: &[&[u8]]) -> &'a [u8] { @@ -1578,20 +1780,7 @@ fn join_string_buf_t_same<'a, T: PathChar, P: PlatformT>( } pub fn join_string_buf_z<'a, P: PlatformT>(buf: &'a mut [u8], parts: &[&[u8]]) -> &'a ZStr { - // reshaped for borrowck — capture buf base ptr before sub-borrow - let buf_base = buf.as_mut_ptr(); - let buf_len = buf.len(); - let (start_offset, len) = { - let joined = join_string_buf_t::(&mut buf[..buf_len - 1], parts); - ( - (joined.as_ptr() as usize) - (buf_base as usize), - joined.len(), - ) - }; - debug_assert!(start_offset + len < buf_len); - buf[start_offset + len] = 0; - // SAFETY: NUL written at buf[start_offset + len]; slice is within buf - unsafe { ZStr::from_raw(buf_base.add(start_offset), len) } + join_z_buf::

(buf, parts) } fn join_string_buf_t<'a, T: PathChar, P: PlatformT>(buf: &'a mut [T], parts: &[&[u8]]) -> &'a [T] { @@ -1647,6 +1836,12 @@ fn join_abs_needed(cwd_len: usize, parts: &[&[u8]]) -> usize { parts.iter().map(|p| p.len() + 1).sum::() + cwd_len + 2 } +/// `_join_abs_string_buf` puts `\\?\` in front of an NT path. +#[inline] +fn nt_prefix_len() -> usize { + if matches!(P::P, Platform::Nt) { 4 } else { 0 } +} + /// Scratch buffer for `_join_abs_string_buf`'s unnormalized concatenation. /// Draws from the /// thread-local `path_buffer_pool` for the common case and only heap-allocates @@ -2562,6 +2757,13 @@ mod tests { h.iter().rposition(|&b| b == c).unwrap_or(n) } + #[unsafe(no_mangle)] + unsafe extern "C" fn highway_count_char(h: *const u8, n: usize, c: u8) -> usize { + // SAFETY: test stub; callers pass a valid (ptr, len) pair. + let h = unsafe { core::slice::from_raw_parts(h, n) }; + h.iter().filter(|&&b| b == c).count() + } + /// `usize::MAX` when `nd` does not occur, like the kernel. #[unsafe(no_mangle)] unsafe extern "C" fn highway_memrmem16( @@ -2739,6 +2941,76 @@ mod tests { assert_eq!(spill.len(), 2); } + #[test] + fn thread_local_helpers_return_results_longer_than_their_buffers() { + let name = vec![b'a'; PARSER_JOIN_INPUT_BUFFER_LEN.max(JOIN_BUF_LEN)]; + let path = [b"/work/", &name[..]].concat(); + let path_z = [&path[..], b"\0"].concat(); + + assert_eq!( + join_abs_string::(b"/work", &[b"x/..", &name]), + &path[..] + ); + assert_eq!( + join_abs_string_z::(b"/work", &[&name]).as_bytes_with_nul(), + &path_z[..] + ); + assert_eq!(join::(&[b"/work", &name]), &path[..]); + assert_eq!( + join_z::(&[b"/work", &name]).as_bytes_with_nul(), + &path_z[..] + ); + } + + #[test] + fn a_long_result_stays_readable_after_a_longer_one() { + let first_name = vec![b'a'; PARSER_JOIN_INPUT_BUFFER_LEN]; + let second_name = vec![b'b'; PARSER_JOIN_INPUT_BUFFER_LEN * 4]; + let first = join_abs_string::(b"/", &[&first_name]); + let second = join_abs_string::(b"/", &[&second_name]); + assert_eq!(&first[1..], &first_name[..]); + assert_eq!(&second[1..], &second_name[..]); + } + + /// Takes an address because borrowing the buffer ends the borrow of a result in it. + fn is_in_fixed_relative_buffer(address: usize) -> bool { + let start = relative_to_common_path_buf().addr(); + (start..start + MAX_PATH_BYTES).contains(&address) + } + + #[test] + fn relative_uses_the_fixed_buffer_until_the_bound_of_the_result_exceeds_it() { + let from = b"/a".repeat(100); + let separators = relative_operand_separators::(b"", &from); + let room = MAX_PATH_BYTES - relative_result_capacity(separators, 0); + for (to_len, fits) in [(room, true), (room + 1, false)] { + let mut to = b"/".to_vec(); + to.resize(to_len, b'b'); + let expected = [&b"../".repeat(100)[..], &to[1..]].concat(); + + let result = relative_platform::(&from, &to); + assert_eq!(result, &expected[..]); + assert_eq!(is_in_fixed_relative_buffer(result.as_ptr().addr()), fits); + + // Its operands are normalized, so it allows for one separator less. + to.extend_from_slice(b"bbb"); + let result = relative_normalized::(&from, &to); + assert_eq!(result, &[&expected[..], b"bbb"].concat()[..]); + assert_eq!(is_in_fixed_relative_buffer(result.as_ptr().addr()), fits); + } + } + + #[test] + fn relative_uses_the_fixed_buffer_until_from_exceeds_it() { + for (from_len, fits) in [(MAX_PATH_BYTES - 1, true), (MAX_PATH_BYTES, false)] { + let mut from = b"/".to_vec(); + from.resize(from_len, b'a'); + let result = relative_platform::(&from, b"/b"); + assert_eq!(result, b"../b"); + assert_eq!(is_in_fixed_relative_buffer(result.as_ptr().addr()), fits); + } + } + #[test] fn join_string_buf_skips_empty_parts_and_normalizes() { let mut out = [0u8; 64]; diff --git a/src/paths/string_paths.rs b/src/paths/string_paths.rs index ad1fb1e03f86..5348ef37545b 100644 --- a/src/paths/string_paths.rs +++ b/src/paths/string_paths.rs @@ -69,7 +69,7 @@ pub fn from_w_path<'a>(buf: &'a mut [u8], utf16: &[u16]) -> &'a ZStr { let to_copy = strings::trim_prefix_comptime::(utf16, &windows::LONG_PATH_PREFIX); let last = buf.len() - 1; let encode_into_result = strings::copy_utf16_into_utf8(&mut buf[..last], to_copy); - let written = encode_into_result.written as usize; + let written = encode_into_result.written; debug_assert!(written < buf.len()); buf[written] = 0; ZStr::from_buf(buf, written) @@ -453,7 +453,7 @@ pub fn without_trailing_slash_windows_path(input: &[u8]) -> &[u8] { return without_trailing_slash(input); } - let root_len = resolve_path::windows_filesystem_root(input).len() + 1; + let root_len = resolve_path::windows_filesystem_root(input).len(); let mut path = input; while path.len() > root_len && matches!(path[path.len() - 1], b'/' | b'\\') { diff --git a/src/react_compiler/reactive_scopes/build_reactive_function.rs b/src/react_compiler/reactive_scopes/build_reactive_function.rs index b6e503ffa59a..c02f7916a499 100644 --- a/src/react_compiler/reactive_scopes/build_reactive_function.rs +++ b/src/react_compiler/reactive_scopes/build_reactive_function.rs @@ -905,7 +905,16 @@ impl<'a, 'b> Driver<'a, 'b> { block_value.push(stmt); } GotoVariant::Try => { - // noop + // The `try` terminal visits the target. This check isn't in upstream. It catches + // a pass that removed the terminal without merging the target, which drops code. + if !self.cx.is_scheduled(*goto_block) { + return Err(cold_invariant( + "Expected the fallthrough of a try to be scheduled", + Some(format!("bb{} is not scheduled", goto_block.0)), + None, + ) + .into()); + } } } } diff --git a/src/resolver/fs.rs b/src/resolver/fs.rs index c4bbe2ac1642..e26790b6fcdf 100644 --- a/src/resolver/fs.rs +++ b/src/resolver/fs.rs @@ -1,6 +1,7 @@ use core::sync::atomic::{AtomicBool, Ordering}; use std::borrow::Cow; use std::io::Write as _; +use std::sync::OnceLock; use bun_alloc::{AllocError, allocators}; use bun_collections::VecExt as _; @@ -140,7 +141,7 @@ pub struct Entry { pub mutex: Mutex, pub need_stat: AtomicBool, - pub abs_path: Interned, + abs_path: OnceLock, } impl Entry { @@ -184,15 +185,32 @@ impl Entry { self.dir } - /// `Interned` is `Copy`. + /// Returns `Interned::EMPTY` until a fill has been published. Lock-free. #[inline] pub fn abs_path(&self) -> Interned { - self.abs_path + self.abs_path.get().copied().unwrap_or(Interned::EMPTY) } - #[inline] - pub fn set_abs_path(&mut self, p: Interned) { - self.abs_path = p; + /// Returns `abs_path`, calling `fill` under `mutex` to compute it the first time. + /// Nothing else writes `abs_path`, and a failed `fill` doesn't publish anything. + pub fn abs_path_or_try_fill( + &self, + fill: impl FnOnce() -> Result, + ) -> Result { + if let Some(p) = self.abs_path.get() { + return Ok(*p); + } + let _guard = self.mutex.lock_guard(); + if let Some(p) = self.abs_path.get() { + return Ok(*p); + } + let p = fill()?; + Ok(*self.abs_path.get_or_init(|| p)) + } + + pub fn abs_path_or_fill(&self, fill: impl FnOnce() -> Interned) -> Interned { + let Ok(p) = self.abs_path_or_try_fill(|| Ok::<_, core::convert::Infallible>(fill())); + p } /// Stat-on-first-use. @@ -275,8 +293,7 @@ impl Entry { // `entry` is a RAW `*mut Entry`. A safe // `&self → &mut Entry` accessor would let two `get()` calls produce coexisting -// aliased `&mut Entry` (PORTING.md §Forbidden). Callers `unsafe { &mut *entry }` -// at each write site under the per-entry `Entry.mutex`. +// aliased `&mut Entry` (PORTING.md §Forbidden). pub struct EntryLookup<'a> { pub(crate) entry: *mut Entry, // tie the lookup's nominal lifetime to the DirEntry it came from @@ -288,9 +305,9 @@ impl<'a> EntryLookup<'a> { /// /// # Safety (encapsulated) /// `self.entry` is a slot in the process-lifetime `EntryStore` BSSMap - /// singleton (see `dir_entry::EntryStore`); never freed. `Entry`'s - /// only mutable state (`cache`) is behind `Cell`, so interior - /// writes via `set_cache*()` do not alias this `&Entry`. The + /// singleton (see `dir_entry::EntryStore`); never freed. `cache` is + /// behind `Cell` and `abs_path` behind `OnceLock`, so the interior writes through + /// `set_cache*()` / `abs_path_or_fill()` do not alias this `&Entry`. The /// `PhantomData<&'a Entry>` ties the borrow to the `DirEntry` it was /// looked up from. #[inline(always)] @@ -298,13 +315,6 @@ impl<'a> EntryLookup<'a> { // SAFETY: ARENA — EntryStore-owned slot; see fn doc. unsafe { &*self.entry } } - - // former `entry_mut() -> &'a mut Entry` accessor removed - // (zero callers). `Entry`'s only mutable state (`cache`) is `Cell`-backed, - // so all mutation goes through `entry().set_cache*()` on a shared borrow; - // no `&mut Entry` escape hatch is needed. Write sites that bypass the - // accessor go through the raw `self.entry` field directly under the - // per-entry `Entry.mutex` (see struct doc above). } /// `DirEntry` companion items: the entry map, the global entry store, and the @@ -541,7 +551,7 @@ impl DirEntry { kind: found_kind.unwrap_or(EntryKind::File), fd: Fd::INVALID, })); - addr_of_mut!((*p).abs_path).write(Interned::EMPTY); + addr_of_mut!((*p).abs_path).write(OnceLock::new()); p } }; @@ -681,9 +691,8 @@ impl ModKey { } } -// SAFETY: ARENA — `Entry` lives in the `BSSList` singleton; `*mut Entry` raw -// pointers are the only !Send/!Sync field. All access is serialized through -// `RealFS.entries_mutex`. +// SAFETY: `cache` is the only `!Sync` field. It is shared under the per-entry +// `mutex` discipline described on `Entry`. unsafe impl Sync for Entry {} // SAFETY: same invariant as the `Sync` impl above. unsafe impl Send for Entry {} diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index 40f158b9468e..a9872c36e891 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -175,16 +175,9 @@ pub mod fs { // ── FileSystem ─────────────────────────────────────────────────────── - /// Process-global filesystem facade for the resolver: holds the cached - /// top-level dir, the real-FS backend, and the dirname/filename interning - /// stores. + /// Process-global filesystem facade for the resolver. Wraps the real-FS + /// backend and the dirname/filename interning stores. pub struct FileSystem { - pub top_level_dir: &'static [u8], - - // used on subsequent updates (process.chdir writes here and re-slices - // `top_level_dir` to point into it). - pub top_level_dir_buf: bun_paths::PathBuffer, - pub fs: Implementation, pub dirname_store: &'static DirnameStore, pub filename_store: &'static FilenameStore, @@ -306,11 +299,10 @@ pub mod fs { // `Err`). `cwd` is passed as raw bytes — POSIX paths are not // guaranteed UTF-8, and the lower tier stores/serves bytes. bun_paths::fs::FileSystem::init(cwd); + bun_core::set_top_level_dir(cwd); // SAFETY: see above. unsafe { (*INSTANCE.get()).write(FileSystem { - top_level_dir: cwd, - top_level_dir_buf: bun_paths::PathBuffer::ZEROED, fs: Implementation::init(cwd), dirname_store: DirnameStore::instance(), filename_store: FilenameStore::instance(), @@ -352,13 +344,13 @@ pub mod fs { /// absolute path slice. pub fn abs_buf<'b>(&self, parts: &[&[u8]], buf: &'b mut [u8]) -> &'b [u8] { use bun_paths::resolve_path::{join_abs_string_buf, platform}; - join_abs_string_buf::(self.top_level_dir, buf, parts) + join_abs_string_buf::(self.top_level_dir(), buf, parts) } /// Returns `None` on overflow. pub fn abs_buf_checked<'b>(&self, parts: &[&[u8]], buf: &'b mut [u8]) -> Option<&'b [u8]> { use bun_paths::resolve_path::{join_abs_string_buf_checked, platform}; - join_abs_string_buf_checked::(self.top_level_dir, buf, parts) + join_abs_string_buf_checked::(self.top_level_dir(), buf, parts) } /// Normalizes `str` (separators, `.`/`..` segments) into `buf`. @@ -371,7 +363,7 @@ pub mod fs { /// into the resolver-shared threadlocal join buffer. pub fn abs(&self, parts: &[&[u8]]) -> &[u8] { use bun_paths::resolve_path::{join_abs_string, platform}; - join_abs_string::(self.top_level_dir, parts) + join_abs_string::(self.top_level_dir(), parts) } /// `ENAMETOOLONG` on overflow. @@ -380,7 +372,7 @@ pub mod fs { parts: &[&[u8]], ) -> core::result::Result<&[u8], bun_errno::SystemErrno> { use bun_paths::resolve_path::{join_abs_string_checked, platform}; - join_abs_string_checked::(self.top_level_dir, parts) + join_abs_string_checked::(self.top_level_dir(), parts) .ok_or(bun_errno::SystemErrno::ENAMETOOLONG) } @@ -391,7 +383,7 @@ pub mod fs { parts: &[&[u8]], ) -> core::result::Result<&'static [u8], bun_alloc::AllocError> { use bun_paths::resolve_path::{join_abs_string, platform}; - let joined = join_abs_string::(self.top_level_dir, parts); + let joined = join_abs_string::(self.top_level_dir(), parts); // Route through DirnameStore so // the resolver's `&'static [u8]` storage contract holds. DirnameStore::instance() @@ -410,30 +402,26 @@ pub mod fs { /// `top_level_dir` to `to`. Returns a slice into the resolver-shared /// threadlocal relative buffer; caller must dup before the next call. pub fn relative_to(&self, to: &[u8]) -> &'static [u8] { - bun_paths::resolve_path::relative(self.top_level_dir, to) + bun_paths::resolve_path::relative(self.top_level_dir(), to) } - /// Cached cwd captured at `FileSystem::init`. + /// Returns the process cwd. `process.chdir()` on any thread replaces it. #[inline] pub fn top_level_dir(&self) -> &'static [u8] { - self.top_level_dir + bun_core::top_level_dir() } - /// `dir` must be - /// `'static` (interned in `DirnameStore` or a process-lifetime buffer - /// like `cwd_buf`). Takes `&mut self` — callers hold `&'static mut - /// FileSystem` from `instance()`; only called during single-threaded - /// CLI init. + /// `dir` must be `'static` and never written again (interned in + /// `DirnameStore` or a process-lifetime buffer like `cwd_buf`). #[inline] - pub fn set_top_level_dir(&mut self, dir: &'static [u8]) { - self.top_level_dir = dir; + pub fn set_top_level_dir(&self, dir: &'static [u8]) { bun_core::set_top_level_dir(dir); } /// `top_level_dir` with any trailing separator stripped (root `/` is /// left intact). pub fn top_level_dir_without_trailing_slash(&self) -> &'static [u8] { - let d = self.top_level_dir; + let d = self.top_level_dir(); if d.len() > 1 && d.last() == Some(&bun_paths::SEP) { &d[..d.len() - 1] } else { @@ -1834,6 +1822,27 @@ pub mod dir_entry_accessor { } } + impl DirEntryAccessor { + /// Joins `path` onto the handle's directory. The result can be longer than a path buffer. + fn full_path<'a>( + handle: DirEntryHandle, + path: &'a ZStr, + buf: &'a mut [u8], + spill: &'a mut Vec, + ) -> &'a ZStr { + match handle.value { + Some(entry) if !Platform::AUTO.is_absolute(path.as_bytes()) => { + resolve_path::join_z_buf_spill::( + buf, + spill, + &[entry.dir, path.as_bytes()], + ) + } + _ => path, + } + } + } + impl Accessor for DirEntryAccessor { const COUNT_FDS: bool = false; type Handle = DirEntryHandle; @@ -1841,23 +1850,8 @@ pub mod dir_entry_accessor { fn statat(handle: DirEntryHandle, path_: &ZStr) -> Maybe { let mut buf = bun_paths::path_buffer_pool::get(); - let path: &ZStr = if !Platform::AUTO.is_absolute(path_.as_bytes()) { - if let Some(entry) = handle.value { - let slice = resolve_path::join_string_buf::( - &mut buf, - &[entry.dir, path_.as_bytes()], - ); - let len = slice.len(); - buf[len] = 0; - // SAFETY: buf[len] == 0 written above - ZStr::from_buf(&buf[..], len) - } else { - path_ - } - } else { - path_ - }; - Syscall::stat(path) + let mut spill = Vec::new(); + Syscall::stat(Self::full_path(handle, path_, &mut buf[..], &mut spill)) } /// Like statat but does not follow symlinks. @@ -1895,22 +1889,14 @@ pub mod dir_entry_accessor { path_: &ZStr, ) -> Result, bun_core::Error> { let mut buf = bun_paths::path_buffer_pool::get(); - let mut path: &[u8] = path_.as_bytes(); - - if !Platform::AUTO.is_absolute(path) { - if let Some(entry) = handle.value { - path = resolve_path::join_string_buf::( - &mut buf, - &[entry.dir, path], - ); - } - } + let mut spill = Vec::new(); + let path = Self::full_path(handle, path_, &mut buf[..], &mut spill); // TODO do we want to propagate ENOTDIR through the 'Maybe' to match the SyscallAccessor? // The glob implementation specifically checks for this error when dealing with symlinks // return Err(SysError::from_code(E::NOTDIR, Syscall::Tag::open)); let res = FS::instance() .fs - .read_directory(path, None, 0, false) + .read_directory(path.as_bytes(), None, 0, false) .map_err(crate::Error::into_core)?; match res { EntriesOption::Entries(entry) => { diff --git a/src/resolver/package_json.rs b/src/resolver/package_json.rs index faa1e2422d8c..1aa43e1a9881 100644 --- a/src/resolver/package_json.rs +++ b/src/resolver/package_json.rs @@ -1,6 +1,7 @@ use bun_ast as js_ast; use bun_collections::{ArrayHashMap, StringArrayHashMap}; use bun_core::Output; +use bun_core::StackCheck; use bun_core::strings; use bun_js_parser::lexer as js_lexer; use bun_paths::{self as resolve_path, MAX_PATH_BYTES, PathBuffer, SEP_STR}; @@ -296,11 +297,14 @@ impl SideEffects { /// Thin extension trait that delegates to `bun_paths::resolve_path`. trait FileSystemPackageJsonExt { - fn join(&self, parts: &[&[u8]]) -> &'static [u8]; + /// Returns a slice of `spill` if the result is too long for the thread-local join buffer. + fn join<'a>(&self, spill: &'a mut Vec, parts: &[&[u8]]) -> &'a [u8]; } impl FileSystemPackageJsonExt for crate::fs::FileSystem { - fn join(&self, parts: &[&[u8]]) -> &'static [u8] { - resolve_path::resolve_path::join::(parts) + fn join<'a>(&self, spill: &'a mut Vec, parts: &[&[u8]]) -> &'a [u8] { + resolve_path::resolve_path::join_spill::( + spill, parts, + ) } } @@ -723,6 +727,8 @@ impl PackageJSON { } } + let mut spill: Vec = Vec::new(); + // If the array is empty, treat it as false (no side effects) if !has_globs && !has_exact { package_json.side_effects = SideEffects::False; @@ -742,7 +748,7 @@ impl PackageJSON { let joined: [&[u8]; 2] = [json_source.path.name().dir_with_trailing_slash(), name]; - let pattern = r_fs.join(&joined); + let pattern = r_fs.join(&mut spill, &joined); if strings::contains_char(name, b'*') || strings::contains_char(name, b'?') @@ -779,7 +785,7 @@ impl PackageJSON { let joined: [&[u8]; 2] = [json_source.path.name().dir_with_trailing_slash(), name]; - let pattern = r_fs.join(&joined); + let pattern = r_fs.join(&mut spill, &joined); // Normalize pattern to use forward slashes for cross-platform compatibility let normalized_pattern = Self::normalize_path_for_glob(pattern) .unwrap_or_else(|_| pattern.to_vec()); @@ -796,8 +802,8 @@ impl PackageJSON { let joined: [&[u8]; 2] = [json_source.path.name().dir_with_trailing_slash(), name]; - let _ = - map.insert(StringHashMapUnownedKey::init(r_fs.join(&joined)), ()); + let pattern = r_fs.join(&mut spill, &joined); + let _ = map.insert(StringHashMapUnownedKey::init(pattern), ()); } } package_json.side_effects = SideEffects::Map(map); @@ -1065,7 +1071,11 @@ impl ExportsMap { log: &mut bun_ast::Log, json: js_ast::Expr, ) -> Option { - let mut visitor = Visitor { source, log }; + let mut visitor = Visitor { + source, + log, + stack_check: StackCheck::init(), + }; let root = visitor.visit(json); @@ -1080,6 +1090,7 @@ impl ExportsMap { pub(crate) struct Visitor<'a> { pub(crate) source: &'a bun_ast::Source, pub(crate) log: &'a mut bun_ast::Log, + stack_check: StackCheck, } impl<'a> Visitor<'a> { @@ -1106,6 +1117,10 @@ impl<'a> Visitor<'a> { value: &js_ast::E::JsonValue, vloc: json_parser::ValueLocation<'_>, ) -> Entry { + if !self.stack_check.is_safe_to_recurse() { + let loc = vloc.resolve(&self.source.contents); + return self.too_deep(bun_ast::Range { loc, len: 1 }); + } match value { js_ast::E::JsonValue::Null => Entry { data: EntryData::Null, @@ -1248,6 +1263,18 @@ impl<'a> Visitor<'a> { data: EntryData::Invalid, } } + + #[cold] + fn too_deep(&mut self, first_token: bun_ast::Range) -> Entry { + self.log.add_range_warning( + Some(self.source), + first_token, + b"This value is nested too deeply", + ); + Entry { + data: EntryData::Invalid, + } + } } #[derive(Clone)] @@ -1306,6 +1333,8 @@ pub type ConditionsMap = StringArrayHashMap<()>; pub(crate) struct ESModule<'a> { pub(crate) debug_logs: Option<&'a mut resolver::DebugLogs>, pub(crate) conditions: &'a ConditionsMap, + /// `resolve_target` uses bigger stack frames than the JSON parser, so it can overflow on a target that parsed. + pub(crate) stack_check: StackCheck, } #[derive(Clone)] @@ -1857,6 +1886,15 @@ impl<'a> ESModule<'a> { subpath: &[u8], internal: bool, ) -> Resolution { + if !self.stack_check.is_safe_to_recurse() { + if let Some(logs) = self.debug_logs.as_deref_mut() { + logs.add_note(b"The package target is nested too deeply".to_vec()); + } + return Resolution { + status: Status::InvalidPackageConfiguration, + ..Default::default() + }; + } match &target.data { EntryData::String(str) => { let mb = module_bufs(); diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index b4bab754c3d7..f0abdff5ca3a 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -102,29 +102,16 @@ mod bun_paths { pub(super) fn dirname_platform(p: &[u8], platform: Platform) -> &[u8] { dispatch_platform!(platform, |P| ::bun_paths::resolve_path::dirname::

(p)) } - /// Port of `bun.path.joinAbsStringBuf` (value-dispatched). - pub(super) fn join_abs_string_buf<'b>( + /// Port of `bun.path.joinAbsStringBuf` (value-dispatched). Returns `None` if the result is too long for `buf`. + pub(super) fn join_abs_string_buf_checked<'b>( cwd: &'b [u8], buf: &'b mut [u8], parts: &[&[u8]], platform: Platform, - ) -> &'b [u8] { - dispatch_platform!( - platform, - |P| ::bun_paths::resolve_path::join_abs_string_buf::

(cwd, buf, parts) - ) - } - pub(super) fn join_abs(cwd: &[u8], platform: Platform, part: &[u8]) -> &'static [u8] { - // NOTE: `resolve_path::join_abs` ties the result lifetime to `cwd`, but the - // returned slice always points into the threadlocal `PARSER_JOIN_INPUT_BUFFER` - // (or is `cwd` itself when `parts.is_empty()`, which never happens here — we - // pass exactly one part). Re-erase to `'static` so the resolver can hold it - // across `&mut self` calls. - let s = dispatch_platform!(platform, |P| ::bun_paths::resolve_path::join_abs::

( - cwd, part - )); - // SAFETY: see NOTE — slice borrows threadlocal storage, valid 'static per-thread. - unsafe { bun_ptr::detach_lifetime(s) } + ) -> Option<&'b [u8]> { + dispatch_platform!(platform, |P| { + ::bun_paths::resolve_path::join_abs_string_buf_checked::

(cwd, buf, parts) + }) } pub(super) fn join(parts: &[&[u8]], platform: Platform) -> &'static [u8] { dispatch_platform!(platform, |P| ::bun_paths::resolve_path::join::

(parts)) @@ -256,7 +243,7 @@ impl FdZero for ::bun_sys::Fd { use self::bun_paths as ResolvePath; use ::bun_ast::import_record as ast; -use ::bun_core::{FeatureFlags, Generation}; +use ::bun_core::{FeatureFlags, Generation, StackCheck}; use bun_ast::Msg; use bun_collections::BoundedArray; use bun_dotenv::env_loader as DotEnv; @@ -680,8 +667,8 @@ impl<'a> Resolver<'a> { /// (`abs_buf*`, `normalize_buf`, `dirname_store`, `filename_store`, /// `top_level_dir`). Preferred over `unsafe { &mut *self.fs() }` whenever /// the callee takes `&self` — avoids materializing a `&mut FileSystem` - /// that could (under Stacked Borrows) pop a coexisting `rfs_ptr()` / - /// `&mut *query.entry` tag derived from the same allocation. + /// that could (under Stacked Borrows) pop a coexisting `rfs_ptr()` tag + /// derived from the same allocation. #[inline(always)] pub(crate) fn fs_ref(&self) -> &Fs::FileSystem { // SAFETY: BACKREF — `self.fs` is the process-global FileSystem singleton @@ -837,7 +824,7 @@ impl<'a> Resolver<'a> { /// directory was deleted or is unreadable — callers surface that as a /// resolve failure rather than panicking. pub fn get_package_manager(&mut self) -> crate::CrateResult<*mut dyn AutoInstaller> { - let top_level_dir = self.fs_ref().top_level_dir; + let top_level_dir = self.fs_ref().top_level_dir(); self.get_package_manager_for(top_level_dir) } @@ -1379,7 +1366,7 @@ impl<'a> Resolver<'a> { self.extension_order = original_order; return ResultUnion::NotFound; } - break 'brk Fs::FileSystem::instance().top_level_dir; + break 'brk Fs::FileSystem::instance().top_level_dir(); } } @@ -1396,7 +1383,7 @@ impl<'a> Resolver<'a> { // let _ = self.flush_debug_logs(FlushMode::Fail); // } // return ResultUnion::Failure(crate::Error::MissingResolveDir); - break 'brk Fs::FileSystem::instance().top_level_dir; + break 'brk Fs::FileSystem::instance().top_level_dir(); } // This can also be hit if you use plugins with non-file namespaces, @@ -1408,12 +1395,19 @@ impl<'a> Resolver<'a> { // let _ = self.flush_debug_logs(FlushMode::Fail); // } // return ResultUnion::Failure(crate::Error::InvalidResolveDir); - break 'brk Fs::FileSystem::instance().top_level_dir; + break 'brk Fs::FileSystem::instance().top_level_dir(); } - break 'brk source_dir_resolver - .resolve_cwd(source_dir) - .unwrap_or_else(|_| panic!("Failed to query CWD")); + break 'brk match source_dir_resolver.resolve_cwd(source_dir) { + Ok(dir) => dir, + // On Windows, adding the drive to a rooted directory can overflow a path buffer. + Err(::bun_paths::Error::Sys(bun_errno::SystemErrno::ENAMETOOLONG)) => { + let _ = self.flush_debug_logs(FlushMode::Fail); + self.extension_order = original_order; + return ResultUnion::NotFound; + } + Err(_) => panic!("Failed to query CWD"), + }; }; // A path with a null byte cannot exist on the filesystem. Continuing @@ -1565,7 +1559,7 @@ impl<'a> Resolver<'a> { // resolver's lifetime; the `'static` erase only releases the `&self` borrow. let path: &'static [u8] = unsafe { &*std::ptr::from_ref::<[u8]>(path.as_ref()) }; - let top = self.fs_ref().top_level_dir; + let top = self.fs_ref().top_level_dir(); return self.resolve(top, path, ast::ImportKind::EntryPointBuild); } } @@ -1664,20 +1658,22 @@ impl<'a> Resolver<'a> { bstr::BStr::new(symlink_path) )); } - } else if !dir.abs_real_path.is_empty() { + } else if !dir.abs_real_path.is_empty() // When the directory is a symlink, we don't need to call getFdPath. - let parts = [dir.abs_real_path, query.entry().base()]; - let mut buf = bun_paths::path_buffer_pool::get(); - - // NOTE: `abs_buf` returns a borrow of `buf`; capture only the - // length so `buf` can be re-borrowed for null-termination below. - let out_len = self.fs_ref().abs_buf(&parts, &mut buf).len(); - + && let mut buf = bun_paths::path_buffer_pool::get() + && let Some(out_len) = + ::bun_paths::resolve_path::join_abs_string_buf_z_checked::< + ::bun_paths::platform::Loose, + >( + self.fs_ref().top_level_dir(), + &mut buf[..], + &[dir.abs_real_path, query.entry().base()], + ) + .map(|joined| joined.len()) + { let store_fd = self.store_fd; if !query.entry().cache().fd.is_valid() && store_fd { - buf[out_len] = 0; - // SAFETY: buf[out_len] == 0 written above let span = bun_core::ZStr::from_buf(&buf[..], out_len); // I/O errors propagate so `resolveAndAutoInstall` can // return them as `Result.Union.failure` — never @@ -2479,9 +2475,9 @@ impl<'a> Resolver<'a> { } } - /// Bust the directory cache for the given path. - /// See `assertValidCacheKey` for requirements on the input + /// Bust the directory cache for the given path, which may end in a separator. pub fn bust_dir_cache(&mut self, path: &[u8]) -> bool { + let path = strings::without_trailing_slash_windows_path(path); Self::assert_valid_cache_key(path); let first_bust = self.fs_mut().fs.bust_entries_cache(path); let second_bust = self.dir_cache_mut().remove(path); @@ -2503,10 +2499,7 @@ impl<'a> Resolver<'a> { specifier: &[u8], ) -> bool { if bun_paths::is_absolute(specifier) { - let dir = bun_paths::dirname_platform(specifier, bun_paths::Platform::AUTO); - let a = self.bust_dir_cache(dir); - let b = self.bust_dir_cache(specifier); - return a || b; + return self.bust_dir_cache_and_parent(specifier); } if !(specifier.starts_with(b"./") || specifier.starts_with(b"../")) { @@ -2516,15 +2509,26 @@ impl<'a> Resolver<'a> { return false; } - let joined = bun_paths::join_abs( + let mut buf = bun_paths::path_buffer_pool::get(); + let Some(joined) = bun_paths::join_abs_string_buf_checked( bun_paths::dirname_platform(import_source_file, bun_paths::Platform::AUTO), + &mut buf[..], + &[specifier], bun_paths::Platform::AUTO, - specifier, - ); - let dir = bun_paths::dirname_platform(joined, bun_paths::Platform::AUTO); + ) else { + // Too long to be a cache key (see `dir_info_cached_maybe_log`). + return false; + }; + self.bust_dir_cache_and_parent(joined) + } + + fn bust_dir_cache_and_parent(&mut self, path: &[u8]) -> bool { + // On Windows the dirname of `a\hello\` is `a\hello`, so strip first. + let path = strings::without_trailing_slash_windows_path(path); + let dir = bun_paths::dirname_platform(path, bun_paths::Platform::AUTO); let a = self.bust_dir_cache(dir); - let b = self.bust_dir_cache(joined); + let b = self.bust_dir_cache(path); a || b } @@ -2671,15 +2675,18 @@ impl<'a> Resolver<'a> { // NOTE: defer restore reshaped — restored at end of block if let Some(ref esm) = esm_ { - let abs_package_path: &[u8] = if is_self_reference { - dir_info.abs_path + // This join can fail even though `abs_path` fit, because `/..` normalizes to a shorter path. + let abs_package_path: Option<&[u8]> = if is_self_reference { + Some(dir_info.abs_path) } else { let parts = [dir_info.abs_path, b"node_modules".as_slice(), esm.name]; self.fs_ref() - .abs_buf(&parts, bufs!(esm_absolute_package_path)) + .abs_buf_checked(&parts, bufs!(esm_absolute_package_path)) }; - if let Ok(Some(pkg_dir_info)) = self.dir_info_cached(abs_package_path) { + if let Some(abs_package_path) = abs_package_path + && let Ok(Some(pkg_dir_info)) = self.dir_info_cached(abs_package_path) + { self.extension_order = match kind { ast::ImportKind::Url | ast::ImportKind::AtConditional @@ -2715,6 +2722,7 @@ impl<'a> Resolver<'a> { _ => &self.opts.conditions.import, }, debug_logs: self.debug_logs.as_mut(), + stack_check: StackCheck::init(), } .resolve(b"/", esm.subpath, &exports_map.root); // ESModule temporary dropped here; `self` is unborrowed. @@ -2770,6 +2778,7 @@ impl<'a> Resolver<'a> { _ => &self.opts.conditions.import, }, debug_logs: self.debug_logs.as_mut(), + stack_check: StackCheck::init(), } .resolve( b"/", @@ -2927,7 +2936,7 @@ impl<'a> Resolver<'a> { // network drive). Report it as a catchable resolve // error; the `Metadata::Resolve` msg carries the text // for `import.meta.resolveSync` & co. - let top_level_dir = self.fs_ref().top_level_dir; + let top_level_dir = self.fs_ref().top_level_dir(); let off_because = match err { crate::Error::SecurityScannerConfigured => { Some("a security scanner is configured") @@ -3250,6 +3259,7 @@ impl<'a> Resolver<'a> { _ => &self.opts.conditions.import, }, debug_logs: self.debug_logs.as_mut(), + stack_check: StackCheck::init(), } .resolve(b"/", esm.subpath, &exports_map.root); @@ -3288,6 +3298,7 @@ impl<'a> Resolver<'a> { _ => &self.opts.conditions.import, }, debug_logs: self.debug_logs.as_mut(), + stack_check: StackCheck::init(), } .resolve( b"/", @@ -3775,6 +3786,9 @@ impl<'a> Resolver<'a> { buf[..index.len()].copy_from_slice(index); for ext in self.opts.ext_order_slice(extension_order).iter() { let ext: &[u8] = ext; + if index.len() + ext.len() > buf.len() { + continue; + } let file_name = &mut buf[0..index.len() + ext.len()]; file_name[index.len()..].copy_from_slice(ext); let index_query = @@ -3811,19 +3825,17 @@ impl<'a> Resolver<'a> { return MatchStatus::NotFound; } - let absolute_out_path: &[u8] = { - if entry_query.entry().abs_path.is_empty() { - // SAFETY: EntryStore-owned slot; resolver mutex held. RHS fully - // evaluated before LHS `&mut Entry` is materialized. - unsafe { &mut *entry_query.entry }.abs_path = Interned::from_static( + let absolute_out_path: &[u8] = entry_query + .entry() + .abs_path_or_fill(|| { + Interned::from_static( self.fs_ref() .dirname_store .append_slice(abs_esm_path) .expect("unreachable"), - ); - } - entry_query.entry().abs_path.as_bytes() - }; + ) + }) + .as_bytes(); let module_type = if let Some(pkg) = resolved_dir_info.package_json() { pkg.module_type } else { @@ -3895,6 +3907,9 @@ impl<'a> Resolver<'a> { buf[..base.len()].copy_from_slice(base); for ext in self.opts.ext_order_slice(extension_order).iter() { let ext: &[u8] = ext; + if base.len() + ext.len() > buf.len() { + continue; + } let file_name = &mut buf[0..base.len() + ext.len()]; file_name[base.len()..].copy_from_slice(ext); if let Some(ext_query) = @@ -3978,21 +3993,19 @@ impl<'a> Resolver<'a> { query: &crate::fs::EntryLookup<'static>, out: &mut MatchResult, ) { - let abs_path: &[u8] = { - if query.entry().abs_path.is_empty() { + let abs_path: &[u8] = query + .entry() + .abs_path_or_fill(|| { let parts = [query.entry().dir, query.entry().base()]; let abs = self.fs_ref().abs_buf(&parts, bufs!(remap_path)); - // SAFETY: EntryStore-owned slot; resolver mutex held. RHS fully - // evaluated before LHS `&mut Entry` is materialized. - unsafe { &mut *query.entry }.abs_path = Interned::from_static( + Interned::from_static( self.fs_ref() .dirname_store .append_slice(abs) .expect("unreachable"), - ); - } - query.entry().abs_path.as_bytes() - }; + ) + }) + .as_bytes(); let module_type = if let Some(pkg) = resolved_dir_info.package_json() { pkg.module_type } else { @@ -4097,15 +4110,11 @@ impl<'a> Resolver<'a> { None => return Ok(None), }; + // `baseUrl` can be longer than a path buffer. Joins against it are checked, so a long one never matches. if result.has_base_url() { // this might leak if !bun_paths::is_absolute(&result.base_url) { - // NOTE: `base_url: Box<[u8]>` owns its bytes, so - // copy `abs_buf`'s thread-local result directly instead of - // double-copying through the `dirname_store` arena. - let abs = self - .fs_ref() - .abs_buf(&[file_dir, &result.base_url[..]], bufs!(tsconfig_base_url)); + let abs = self.fs_ref().abs(&[file_dir, &result.base_url[..]]); result.base_url = Box::from(abs); } } @@ -4115,9 +4124,7 @@ impl<'a> Resolver<'a> { || !bun_paths::is_absolute(&result.base_url_for_paths)) { // this might leak - let abs = self - .fs_ref() - .abs_buf(&[file_dir, &result.base_url[..]], bufs!(tsconfig_base_url)); + let abs = self.fs_ref().abs(&[file_dir, &result.base_url[..]]); result.base_url_for_paths = Box::from(abs); } @@ -4204,7 +4211,7 @@ impl<'a> Resolver<'a> { let mut input_path = raw_input_path; if is_dot_slash(input_path) || input_path == b"." { - input_path = self.fs_ref().top_level_dir; + input_path = self.fs_ref().top_level_dir(); } // A path longer than MAX_PATH_BYTES cannot name a real directory. @@ -4850,8 +4857,13 @@ impl<'a> Resolver<'a> { if !bun_paths::is_absolute(absolute_original_path) { let parts: [&[u8]; 2] = [abs_base_url, original_path.as_ref()]; - absolute_original_path = - self.fs_ref().abs_buf(&parts, bufs!(tsconfig_path_abs)); + absolute_original_path = match self + .fs_ref() + .abs_buf_checked(&parts, bufs!(tsconfig_path_abs)) + { + Some(p) => p, + None => continue, + }; } if self @@ -5057,6 +5069,7 @@ impl<'a> Resolver<'a> { _ => &self.opts.conditions.import, }, debug_logs: self.debug_logs.as_mut(), + stack_check: StackCheck::init(), } .resolve_imports(import_path, &imports_map.root); @@ -5143,6 +5156,11 @@ impl<'a> Resolver<'a> { return None; } + // Every candidate below is built in a path buffer. `/index` is the longest one. + if input_path.len() + BROWSER_MAP_INDEX_SUFFIX.len() >= MAX_PATH_BYTES { + return None; + } + // Normalize the path so we can compare against it without getting confused by "./" let cleaned = self .fs_ref() @@ -5237,9 +5255,13 @@ impl<'a> Resolver<'a> { }}; } - let mut field_abs_path: &[u8] = self + // The field comes from package.json and can be any length. + let Some(mut field_abs_path) = self .fs_ref() - .abs_buf(&[path, field_rel_path], bufs!(field_abs_path)); + .abs_buf_checked(&[path, field_rel_path], bufs!(field_abs_path)) + else { + dec_ret!(MatchStatus::NotFound); + }; if self.care_about_browser_field { // Potentially remap using the "browser" field @@ -5269,9 +5291,13 @@ impl<'a> Resolver<'a> { } // `remap` is relative to the package that owns the browser map. - field_abs_path = self - .fs_ref() - .abs_buf(&[browser_scope.abs_path, remap], bufs!(field_abs_path)); + field_abs_path = match self.fs_ref().abs_buf_checked( + &[browser_scope.abs_path, remap], + bufs!(field_abs_path), + ) { + Some(remapped) => remapped, + None => dec_ret!(MatchStatus::NotFound), + }; } } } @@ -5380,6 +5406,9 @@ impl<'a> Resolver<'a> { let ext_buf = bufs!(extension_path); + if b"index".len() + ext.len() > ext_buf.len() { + return MatchStatus::NotFound; + } let base = &mut ext_buf[0..b"index".len() + ext.len()]; base[0..b"index".len()].copy_from_slice(b"index"); base[b"index".len()..].copy_from_slice(ext); @@ -5409,21 +5438,19 @@ impl<'a> Resolver<'a> { if unsafe { lookup.entry().kind(rfs, self.store_fd) } == Fs::file_system::EntryKind::File { - let out_buf: &[u8] = { - if lookup.entry().abs_path.is_empty() { + let out_buf: &[u8] = lookup + .entry() + .abs_path_or_fill(|| { let parts = [dir_info.abs_path, &base[..]]; let out_buf_ = self.fs_ref().abs_buf(&parts, bufs!(index)); - // SAFETY: EntryStore-owned slot; resolver mutex held. RHS fully - // evaluated before LHS `&mut Entry` is materialized. - unsafe { &mut *lookup.entry }.abs_path = Interned::from_static( + Interned::from_static( self.fs_ref() .dirname_store .append_slice(out_buf_) .expect("unreachable"), - ); - } - lookup.entry().abs_path.as_bytes() - }; + ) + }) + .as_bytes(); if let Some(debug) = self.debug_logs.as_mut() { debug @@ -5483,6 +5510,10 @@ impl<'a> Resolver<'a> { // this fn (only `remap_path` is, via a separate `bufs!` raw-ptr projection). let path_buf = bufs!(remap_path_trailing_slash); if !strings::ends_with_char(path_, SEP) { + // Leave room for the separator and the NUL. + if path.len() + 2 > path_buf.len() { + return MatchStatus::NotFound; + } path_buf[..path.len()].copy_from_slice(path); path_buf[path.len()] = SEP; path_buf[path.len() + 1] = 0; @@ -5495,7 +5526,12 @@ impl<'a> Resolver<'a> { if let Some(browser_json) = browser_scope.package_json() { let index_paths = [path, FIELD_REL_PATH]; - let index_abs_path = self.fs_ref().abs_buf(&index_paths, bufs!(remap_path)); + let Some(index_abs_path) = self + .fs_ref() + .abs_buf_checked(&index_paths, bufs!(remap_path)) + else { + return MatchStatus::NotFound; + }; if let Some(remap) = self .check_browser_map::<{ BrowserMapPathKind::AbsolutePath }>( &browser_scope, @@ -5521,7 +5557,11 @@ impl<'a> Resolver<'a> { // `remap` is relative to the browser scope, which may be an ancestor of `path`. let new_paths = [browser_scope.abs_path, remap]; - let remapped_abs = self.fs_ref().abs_buf(&new_paths, bufs!(remap_path)); + let Some(remapped_abs) = + self.fs_ref().abs_buf_checked(&new_paths, bufs!(remap_path)) + else { + return MatchStatus::NotFound; + }; // Is this a file if let Some(file_result) = self.load_as_file(remapped_abs, extension_order) @@ -5899,21 +5939,19 @@ impl<'a> Resolver<'a> { debug.add_note_fmt(format_args!("Found file \"{}\" ", bstr::BStr::new(base))); } - let abs_path: &'static [u8] = { - if query.entry().abs_path.is_empty() { + let abs_path: &'static [u8] = query + .entry() + .abs_path_or_fill(|| { let abs_path_parts = [query.entry().dir, query.entry().base()]; let joined = self.fs_ref().abs_buf(&abs_path_parts, bufs!(load_as_file)); - // SAFETY: EntryStore-owned slot; resolver mutex held. RHS fully - // evaluated before LHS `&mut Entry` is materialized. - unsafe { &mut *query.entry }.abs_path = Interned::from_static( + Interned::from_static( self.fs_ref() .dirname_store .append_slice(joined) .expect("unreachable"), - ); - } - query.entry().abs_path.as_bytes() - }; + ) + }) + .as_bytes(); dec_ret!(Some(LoadResult { path: abs_path, @@ -5924,6 +5962,9 @@ impl<'a> Resolver<'a> { } // Try the path with extensions + if path.len() >= MAX_PATH_BYTES { + dec_ret!(None); + } bufs!(load_as_file)[..path.len()].copy_from_slice(path); // NOTE: index by `0..len` so each iteration takes a fresh short // borrow of `self.opts` that ends before `&mut self` is taken by @@ -5963,6 +6004,9 @@ impl<'a> Resolver<'a> { tail[..segment.len()].copy_from_slice(segment); for ext_to_replace in exts { + if path.len() - ext.len() + ext_to_replace.len() >= MAX_PATH_BYTES { + continue; + } let buffer = &mut tail[0..segment.len() + ext_to_replace.len()]; buffer[segment.len()..].copy_from_slice(ext_to_replace); @@ -5981,13 +6025,12 @@ impl<'a> Resolver<'a> { } dec_ret!(Some(LoadResult { - path: { - if query.entry().abs_path.is_empty() { - // SAFETY: `dir` is `&'static [u8]` (DirnameStore-interned), - // copied out so no `&Entry` borrow survives into the - // `&mut Entry` write below. + path: query + .entry() + .abs_path_or_fill(|| { let entry_dir = query.entry().dir; - let new_abs = if !entry_dir.is_empty() + // the trailing separator CAN be missing here + if !entry_dir.is_empty() && entry_dir[entry_dir.len() - 1] == SEP { let parts: [&[u8]; 2] = [entry_dir, &buffer[..]]; @@ -5997,7 +6040,6 @@ impl<'a> Resolver<'a> { .append_parts(&parts) .expect("unreachable"), ) - // the trailing path CAN be missing here } else { let parts: [&[u8]; 3] = [entry_dir, SEP_STR.as_bytes(), &buffer[..]]; @@ -6007,13 +6049,9 @@ impl<'a> Resolver<'a> { .append_parts(&parts) .expect("unreachable"), ) - }; - // SAFETY: EntryStore-owned slot; resolver mutex held. RHS - // fully evaluated above — sole `&mut Entry` for this write. - unsafe { &mut *query.entry }.abs_path = new_abs; - } - query.entry().abs_path.as_bytes() - }, + } + }) + .as_bytes(), dirname_fd: ts_dirname_fd, file_fd: query.entry().cache().fd, })); @@ -6059,6 +6097,9 @@ impl<'a> Resolver<'a> { // field so `unsafe { &mut *self.fs() }` calls below (`filename_store.append_parts`) don't pop // its provenance under Stacked Borrows. let rfs: *mut Fs::file_system::RealFS = self.rfs_ptr(); + if path.len() + ext.len() >= MAX_PATH_BYTES { + return None; + } let buffer = &mut bufs!(load_as_file)[0..path.len() + ext.len()]; buffer[path.len()..].copy_from_slice(ext); let file_name = &buffer[path.len() - base.len()..buffer.len()]; @@ -6085,23 +6126,17 @@ impl<'a> Resolver<'a> { // now that we've found it, we allocate it. return Some(LoadResult { - path: { - // SAFETY: EntryStore-owned slot; resolver mutex held. RHS is fully - // evaluated (shared reads) before the LHS `&mut Entry` is - // materialized for the write — no overlapping unique borrow. - unsafe { &mut *query.entry }.abs_path = if query.entry().abs_path.is_empty() - { + path: query + .entry() + .abs_path_or_fill(|| { Interned::from_static( self.fs_ref() .dirname_store .append_slice(&buffer[..]) .expect("unreachable"), ) - } else { - query.entry().abs_path - }; - query.entry().abs_path.as_bytes() - }, + }) + .as_bytes(), dirname_fd, file_fd: query.entry().cache().fd, }); @@ -6338,14 +6373,13 @@ impl<'a> Resolver<'a> { logs.add_note(buf); } info.abs_real_path = symlink; - } else if !parent_.abs_real_path.is_empty() { + } else if !parent_.abs_real_path.is_empty() + && let Some(joined) = self.fs_ref().abs_buf_checked( + &[parent_.abs_real_path, base], + bufs!(dir_info_uncached_filename), + ) + { // this might leak a little i'm not sure - let parts = [parent_.abs_real_path, base]; - // NOTE: split into two statements so the two `&mut FileSystem` - // borrows from `unsafe { &mut *self.fs() }` don't overlap (Stacked Borrows). - let joined = self - .fs_ref() - .abs_buf(&parts, bufs!(dir_info_uncached_filename)); symlink = self .fs_ref() .dirname_store @@ -6556,28 +6590,36 @@ impl<'a> Resolver<'a> { bun_ptr::BackRef::from(core::ptr::NonNull::new(tsconfig_json).expect("heap alloc")); while !current.extends.is_empty() { let ts_dir_name = Dirname::dirname(¤t.abs_path); - let abs_path = ResolvePath::join_abs_string_buf( + // `extends` comes from the config file and can be any length. + let parsed = match ResolvePath::join_abs_string_buf_checked( ts_dir_name, bufs!(tsconfig_path_abs), &[ts_dir_name, ¤t.extends], bun_paths::Platform::AUTO, - ); - let parent_config_maybe: Option<*mut TSConfigJSON> = - match self.parse_tsconfig(abs_path, FD::INVALID) { - Ok(v) => v.map(bun_core::heap::into_raw), - Err(err) => { - let _ = self.log_mut().add_debug_fmt( - None, - bun_ast::Loc::EMPTY, - format_args!( - "{} loading tsconfig.json extends {}", - bstr::BStr::new(err.name()), - bun_core::fmt::quote(abs_path) - ), - ); - break; - } - }; + ) { + Some(abs_path) => self + .parse_tsconfig(abs_path, FD::INVALID) + .map_err(|err| (err, abs_path)), + None => Err(( + crate::Error::Sys(bun_errno::SystemErrno::ENAMETOOLONG), + ¤t.extends[..], + )), + }; + let parent_config_maybe: Option<*mut TSConfigJSON> = match parsed { + Ok(v) => v.map(bun_core::heap::into_raw), + Err((err, shown_path)) => { + let _ = self.log_mut().add_debug_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!( + "{} loading tsconfig.json extends {}", + bstr::BStr::new(err.name()), + bun_core::fmt::quote(shown_path) + ), + ); + break; + } + }; if let Some(parent_config) = parent_config_maybe { parent_configs.append(parent_config)?; current = bun_ptr::BackRef::from( @@ -6660,14 +6702,15 @@ impl<'a> Resolver<'a> { let is_directory = bun_sys::directory_exists_at(FD::cwd(), z).unwrap_or(false); path.pop(); // As for `tsc -p`, and for the type check. - if is_directory { - path = ResolvePath::join_abs_string_buf( + if is_directory + && let Some(joined) = ResolvePath::join_abs_string_buf_checked( &path, bufs!(tsconfig_path_abs), &[b"tsconfig.json".as_slice()], bun_paths::Platform::AUTO, ) - .to_vec(); + { + path = joined.to_vec(); } let loaded = self.load_tsconfig(&path, FD::INVALID).ok().flatten(); self.opts.tsconfig_override_json = Some(loaded.map(Arc::from)); @@ -6730,6 +6773,9 @@ pub enum BrowserMapPathKind { AbsolutePath, } +/// Longest suffix the browser-map lookup appends to a candidate. +const BROWSER_MAP_INDEX_SUFFIX: &str = const_format::concatcp!(SEP_STR, "index"); + pub(crate) struct BrowserMapPath<'b> { pub(crate) remapped: &'static [u8], pub(crate) cleaned: &'b [u8], @@ -6784,10 +6830,7 @@ impl<'b> BrowserMapPath<'b> { let index_path: &[u8] = { let trimmed = strings::trim_right(path_to_check, &[SEP]); - let parts = [ - trimmed, - const_format::concatcp!(SEP_STR, "index").as_bytes(), - ]; + let parts = [trimmed, BROWSER_MAP_INDEX_SUFFIX.as_bytes()]; ResolvePath::join_string_buf( bufs!(tsconfig_base_url), &parts, diff --git a/src/router/lib.rs b/src/router/lib.rs index 73c39e7743b5..ceb2dfdf6168 100644 --- a/src/router/lib.rs +++ b/src/router/lib.rs @@ -305,6 +305,8 @@ impl Routes { if Pattern::match_::(path, &case_sensitive_name[1..], name, params) { return Some(&raw const **route); } + // A failed match can leave params behind. + params.clear(); } None @@ -721,21 +723,8 @@ impl Route { public_dir_: &[u8], routes_dirname_len: u16, ) -> Option { - // NOTE: `entry` is a raw `*mut Entry` - // because `base_`/`extname` may borrow `(*entry).base_` (tiny inline - // string) and a `&mut Entry` parameter would alias them. - // Reads go through `unsafe { &*entry }`; the single mutation - // (`set_abs_path`) goes through `unsafe { &mut *entry }` after - // `base_`/`extname` are no longer used. - // SAFETY: caller passes an EntryStore-owned pointer valid for the - // process lifetime; no other live `&mut` to it during this call. - let entry_abs_path = unsafe { &*entry }.abs_path().as_bytes(); - let mut abs_path_str: &[u8] = if entry_abs_path.is_empty() { - b"" - } else { - entry_abs_path - }; - + // NOTE: `entry` is a raw `*mut Entry` because `base_`/`extname` may + // borrow `(*entry).base_` and a `&mut Entry` parameter would alias them. let base = &base_[0..base_.len() - extname.len()]; let public_dir = strings::trim(public_dir_, SEP_STR.as_bytes()); @@ -842,12 +831,12 @@ impl Route { (Route::INDEX_ROUTE_NAME, Route::INDEX_ROUTE_NAME) }; - if abs_path_str.is_empty() { - // The reads of `cache().fd` and the `set_abs_path` write below - // rewrite the cached `Entry`; serialize them on the per-entry - // mutex (the same lock every other `Entry` rewrite path takes). - // SAFETY: see fn-level NOTE — read-only reborrow. - let _entry_guard = unsafe { &*entry }.mutex.lock_guard(); + // SAFETY: caller passes an EntryStore-owned pointer valid for the + // process lifetime; no other live `&mut` to it during this call. + let entry_r = unsafe { &*entry }; + // The closure runs under the per-entry mutex, like every other write to a cached `Entry`. + let Ok(entry_abs_path) = entry_r.abs_path_or_try_fill(|| { + let mut abs_path_str: &[u8] = b""; // NOTE: reshaped for borrowck — `defer if (needs_close) file.close()` // becomes a scopeguard owning the Option; `needs_close` is a // Cell so the drop closure can read it while the body still mutates. @@ -863,13 +852,10 @@ impl Route { } }); - // SAFETY: see fn-level NOTE — read-only reborrow. - if let Some(valid) = unsafe { &*entry }.cache().fd.unwrap_valid() { + if let Some(valid) = entry_r.cache().fd.unwrap_valid() { *file = Some(bun_sys::File::from_fd(valid)); needs_close.set(false); } else { - // SAFETY: see fn-level NOTE — read-only reborrow. - let entry_r = unsafe { &*entry }; let parts = [entry_r.dir(), entry_r.base()]; let abs_len = FileSystem::instance().abs_buf(&parts, route_file_buf).len(); // Rebind so the later getFdPath error @@ -897,7 +883,7 @@ impl Route { bstr::BStr::new(&route_file_buf[..abs_len]) ), ); - return None; + return Err(()); } } FileSystem::set_max_fd(file.as_ref().unwrap().handle().native()); @@ -916,19 +902,20 @@ impl Route { bstr::BStr::new(abs_path_str) ), ); - return None; + return Err(()); } }; - abs_path_str = FileSystem::instance() - .dirname_store() - .append(_abs) - .expect("unreachable"); - - // SAFETY: sole mutation; `base_`/`extname` (which may borrow - // `(*entry).base_.remainder_buf`) are not used after this. - unsafe { &mut *entry }.set_abs_path(Interned::from_static(abs_path_str)); - } + Ok(Interned::from_static( + FileSystem::instance() + .dirname_store() + .append(_abs) + .expect("unreachable"), + )) + }) else { + return None; + }; + let abs_path_str = entry_abs_path.as_bytes(); #[cfg(windows)] let abs_path: AbsPath = { @@ -952,8 +939,7 @@ impl Route { debug_assert!(!strings::index_of_char(name, b'\\').is_some()); debug_assert!(!strings::index_of_char(match_name, b'\\').is_some()); debug_assert!(!strings::index_of_char(abs_path.as_bytes(), b'\\').is_some()); - // SAFETY: read-only reborrow; the `&mut` write above is dead. - debug_assert!(!strings::index_of_char(unsafe { &*entry }.base(), b'\\').is_some()); + debug_assert!(!strings::index_of_char(entry_r.base(), b'\\').is_some()); } Some(Route { diff --git a/src/runtime/JSONLineBuffer.rs b/src/runtime/JSONLineBuffer.rs index 336e8af7a671..251957a82e70 100644 --- a/src/runtime/JSONLineBuffer.rs +++ b/src/runtime/JSONLineBuffer.rs @@ -19,6 +19,10 @@ pub(crate) struct JSONLineBuffer { pub(crate) newline_pos: Option, /// How far we've scanned for newlines relative to head. pub(crate) scanned_pos: u32, + /// Maximum line length, not counting the newline. Capped so a line plus its newline fits in a `u32`. + max_line: u32, + /// True once a line exceeds `max_line`. The buffer is freed and later bytes are dropped. + too_long: bool, } /// Return type of [`JSONLineBuffer::next`]: a complete message slice plus its newline offset. @@ -31,14 +35,27 @@ impl JSONLineBuffer { /// Compact the buffer when head exceeds this threshold. const COMPACTION_THRESHOLD: u32 = 16 * 1024 * 1024; // 16 MB + pub(crate) fn new(max_line: usize) -> Self { + Self { + // Consuming a line also consumes its newline, so `max_line + 1` must not overflow. + max_line: max_line.min(u32::MAX as usize - 1) as u32, + ..Self::default() + } + } + /// Get the active (unconsumed) portion of the buffer. fn active_slice(&self) -> &[u8] { &self.data.slice()[self.head as usize..] } - /// Scan for newline in unscanned portion of the buffer. + /// True if a line exceeded `max_line`. `next` returns `None` from then on. + pub(crate) fn is_too_long(&self) -> bool { + self.too_long + } + + /// Scan the unscanned bytes for a newline, and reject the line if it's longer than `max_line`. fn scan_for_newline(&mut self) { - if self.newline_pos.is_some() { + if self.too_long || self.newline_pos.is_some() { return; } let slice = self.active_slice(); @@ -47,13 +64,22 @@ impl JSONLineBuffer { } let unscanned = &slice[self.scanned_pos as usize..]; - if let Some(local_idx) = strings::index_of_char(unscanned, b'\n') { - let pos = self.scanned_pos.saturating_add(local_idx); - self.newline_pos = Some(pos); - self.scanned_pos = pos.saturating_add(1); // Only scanned up to (and including) the newline - } else { - debug_assert!((slice.len() as u64) <= u32::MAX as u64); - self.scanned_pos = u32::try_from(slice.len()).expect("int cast"); // No newline, scanned everything + let newline = strings::index_of_char_usize(unscanned, b'\n') + .map(|local_idx| self.scanned_pos as usize + local_idx); + // Without a newline, all the unconsumed bytes are one unfinished line. + let line_len = newline.unwrap_or(slice.len()); + if line_len > self.max_line as usize { + *self = Self::new(self.max_line as usize); + self.too_long = true; + return; + } + // `line_len <= max_line <= u32::MAX - 1`, so neither cast truncates. + match newline { + Some(pos) => { + self.newline_pos = Some(pos as u32); + self.scanned_pos = pos as u32 + 1; // Only scanned up to (and including) the newline + } + None => self.scanned_pos = line_len as u32, // No newline, scanned everything } } @@ -68,6 +94,9 @@ impl JSONLineBuffer { /// Append bytes to the buffer, scanning only new data for newline. pub(crate) fn append(&mut self, bytes: &[u8]) { + if self.too_long { + return; + } let _ = self.data.write(bytes); self.scan_for_newline(); } diff --git a/src/runtime/api/BunObject.bind.ts b/src/runtime/api/BunObject.bind.ts index dbe842483fad..d544e13221f2 100644 --- a/src/runtime/api/BunObject.bind.ts +++ b/src/runtime/api/BunObject.bind.ts @@ -53,16 +53,23 @@ export const ZlibDecompressionOptions = b.dictionary( { allowArray: true }, ); -const zlib = (rustName: string, options: typeof ZlibCompressionOptions | typeof ZlibDecompressionOptions) => +const zlib = ( + rustName: string, + options: typeof ZlibCompressionOptions | typeof ZlibDecompressionOptions, + data = stringOrBuffer, +) => b.fn({ - args: { data: stringOrBuffer, options: options.default({}) }, + args: { data, options: options.default({}) }, returns: b.bufferSource, capability: "heap", throws: true, rustPath: `crate::api::bun_object::JSZlib::${rustName}`, }); -export const deflateSync = zlib("deflate_sync", ZlibCompressionOptions); +/** libdeflate reads each input block twice, so copy shared buffers that another thread could change in between. */ +const compressionInput = b.bytes({ from: ["string", "buffer"], shared: "copied" }); + +export const deflateSync = zlib("deflate_sync", ZlibCompressionOptions, compressionInput); export const gc = b.fn({ args: { @@ -74,7 +81,7 @@ export const gc = b.fn({ }); export const gunzipSync = zlib("gunzip_sync", ZlibDecompressionOptions); -export const gzipSync = zlib("gzip_sync", ZlibCompressionOptions); +export const gzipSync = zlib("gzip_sync", ZlibCompressionOptions, compressionInput); export const indexOfLine = b.fn({ args: { diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index ae8a4b9b3f61..34b3286aced4 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -681,7 +681,7 @@ pub(crate) fn register_macro<'a>( } fn get_cwd(global_this: &JSGlobalObject, _: &JSObject) -> JSValue { - EncodedSlice::from_bytes(bun_resolver::fs::FileSystem::get().top_level_dir).to_js(global_this) + EncodedSlice::from_bytes(bun_resolver::fs::FileSystem::get().top_level_dir()).to_js(global_this) } fn get_origin(global_this: &JSGlobalObject, _: &JSObject) -> JSValue { diff --git a/src/runtime/api/Glob.bind.ts b/src/runtime/api/Glob.bind.ts index 38e4355dbed5..fb69888fe636 100644 --- a/src/runtime/api/Glob.bind.ts +++ b/src/runtime/api/Glob.bind.ts @@ -37,14 +37,14 @@ export const Glob = b.class({ length: 1, }, __scan: { - privateSymbol: "pull", + privateSymbol: "globScan", args: { options: GlobScanArgument.nullable }, returns: b.any, capability: "js", throws: true, }, __scanSync: { - privateSymbol: "resolveSync", + privateSymbol: "globScanSync", args: { options: GlobScanArgument.nullable }, returns: b.any, capability: "heap", diff --git a/src/runtime/api/JSBundler.rs b/src/runtime/api/JSBundler.rs index dcc0aefd3f9f..5044e93e35f9 100644 --- a/src/runtime/api/JSBundler.rs +++ b/src/runtime/api/JSBundler.rs @@ -1582,22 +1582,28 @@ pub(crate) mod js_bundler { { resolve.value = ResolveValue::NoMatch; } else { - let global = bv2_plugin(resolve.bv2).global_object(); - let path = path_value - .to_bun_string(global) - .expect("Unexpected: path is not a string") - .to_owned_slice() - .into_boxed_slice(); - let namespace = namespace_value - .to_bun_string(global) - .expect("Unexpected: namespace is not a string") - .to_owned_slice() - .into_boxed_slice(); - resolve.value = ResolveValue::Success(ResolveSuccess { - path, - namespace, - external: external_value.to_boolean(), + let plugin = bv2_plugin(resolve.bv2); + let global = plugin.global_object(); + let strings = path_value.to_bun_string(global).and_then(|path| { + let namespace = namespace_value.to_bun_string(global)?; + Ok((path, namespace)) }); + resolve.value = match strings { + Ok((path, namespace)) => ResolveValue::Success(ResolveSuccess { + path: path.to_owned_slice().into_boxed_slice(), + namespace: namespace.to_owned_slice().into_boxed_slice(), + external: external_value.to_boolean(), + }), + // Flattening a `path` or `namespace` string can still throw. + Err(err) => { + let exception = global.take_exception(err); + ResolveValue::Err(plugin_msg_from_js( + plugin, + &resolve.import_record.source_file, + exception, + )) + } + }; } bv2_mut(resolve.bv2).on_resolve_async(resolve); @@ -1707,24 +1713,21 @@ pub(crate) mod js_bundler { this.value = LoadValue::NoMatch; } else { let loader = api::Loader::from_raw(loader_as_int.as_int32() as u8); - let global = bv2_plugin(this.bv2).global_object(); - let source_code = match crate::node::StringOrBuffer::from_js_to_owned_slice( - global, + let plugin = bv2_plugin(this.bv2); + this.value = match crate::node::StringOrBuffer::from_js_to_owned_slice( + plugin.global_object(), source_code_value, ) { - Ok(s) => s, + Ok(source_code) => LoadValue::Success(LoadSuccess { + loader: bun_ast::Loader::from_api(loader), + source_code: source_code.into(), + }), + // Flattening a `contents` string can still throw. Err(err) => { - match err { - JsError::OutOfMemory => bun_core::out_of_memory(), - JsError::Thrown | JsError::Terminated => {} - } - panic!("Unexpected: source_code is not a string"); + let exception = plugin.global_object().take_exception(err); + LoadValue::Err(plugin_msg_from_js(plugin, &this.path, exception)) } }; - this.value = LoadValue::Success(LoadSuccess { - loader: bun_ast::Loader::from_api(loader), - source_code: source_code.into(), - }); } bv2_mut(this.bv2).on_load_async(this); diff --git a/src/runtime/api/JSTranspiler.rs b/src/runtime/api/JSTranspiler.rs index df5d4bc5f529..6ac2cb874452 100644 --- a/src/runtime/api/JSTranspiler.rs +++ b/src/runtime/api/JSTranspiler.rs @@ -129,6 +129,27 @@ fn level_from_js(global: &JSGlobalObject, value: JSValue) -> JsResult JsResult>> { + let parsed = StringOrBuffer::from_js(global, value)?; + let Some(StringOrBuffer::Buffer(buffer)) = &parsed else { + return Ok(parsed); + }; + if !buffer.buffer.can_change_under_borrow() + && buffer.buffer.byte_len > bun_ast::Source::MAX_PARSEABLE_LEN + { + return Ok(parsed); + } + match buffer.buffer.try_copy_bytes() { + Some(copy) => Ok(Some(StringOrBuffer::owned(copy))), + None => Err(global.throw_out_of_memory()), + } +} + /// Deep-clone a [`MacroMap`]. The keys are `Box<[u8]>`, so an owned copy /// is needed wherever the map is assigned by value. fn clone_macro_map(src: &MacroMap) -> MacroMap { @@ -878,6 +899,9 @@ fn export_replacement_value( global: &JSGlobalObject, arena: &Arena, ) -> JsResult> { + // The result outlives every parse, so don't allocate it in the thread-local store. + let _guard = bun_ast::expr::Disabler::scope(); + if value.is_boolean() { return Ok(Some(Expr { data: bun_ast::ExprData::EBoolean(bun_ast::E::Boolean { @@ -916,7 +940,8 @@ fn export_replacement_value( // `E::EString::init` erases the borrow to `'static` per the AST // crate's `Str` convention (see ast/E.rs). let data = arena.alloc_slice_copy(utf8.slice()); - return Ok(Some(Expr::init( + return Ok(Some(Expr::allocate( + arena, bun_ast::E::EString::init(data), bun_ast::Loc::EMPTY, ))); @@ -1011,6 +1036,8 @@ impl JSTranspiler { // is uniquely owned at init time, so `&mut` projection is alias-free. let transpiler = unsafe { this.transpiler.get_mut() }; transpiler.set_log(&raw mut config.log); + // `set_log` doesn't update `options.log`, which `configure_defines` logs to. + transpiler.options.log = &raw mut config.log; transpiler.options.no_macros = config.no_macros; transpiler.configure_linker_with_auto_jsx(false); @@ -1270,7 +1297,7 @@ impl JSTranspiler { return Err(global.throw_invalid_argument_type("scan", "code", "string or Uint8Array")); }; - let Some(code_holder) = StringOrBuffer::from_js(global, code_arg)? else { + let Some(code_holder) = code_from_js(global, code_arg)? else { return Err(global.throw_invalid_argument_type("scan", "code", "string or Uint8Array")); }; let code = code_holder.slice(); @@ -1412,7 +1439,7 @@ impl JSTranspiler { }; let arena = Arena::new(); - let Some(code_holder) = StringOrBuffer::from_js(global, code_arg)? else { + let Some(code_holder) = code_from_js(global, code_arg)? else { return Err(global.throw_invalid_argument_type( "transformSync", "code", @@ -1615,7 +1642,7 @@ impl JSTranspiler { )); }; - let Some(code_holder) = StringOrBuffer::from_js(global, code_arg)? else { + let Some(code_holder) = code_from_js(global, code_arg)? else { return Err(global.throw_invalid_argument_type( "scanImports", "code", diff --git a/src/runtime/api/MarkdownObject.rs b/src/runtime/api/MarkdownObject.rs index 00c72589170a..4380f18d6071 100644 --- a/src/runtime/api/MarkdownObject.rs +++ b/src/runtime/api/MarkdownObject.rs @@ -123,7 +123,7 @@ pub(crate) fn render_to_ansi( .throw_invalid_arguments(format_args!("Expected a string or buffer to render"))); } - let Some(buffer) = StringOrBuffer::from_js(global_this, input_value)? else { + let Some(buffer) = StringOrBuffer::from_js_stable(global_this, input_value)? else { return Err(global_this .throw_invalid_arguments(format_args!("Expected a string or buffer to render"))); }; @@ -191,7 +191,7 @@ fn render_to_html(global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResu .throw_invalid_arguments(format_args!("Expected a string or buffer to render"))); } - let Some(buffer) = StringOrBuffer::from_js(global_this, input_value)? else { + let Some(buffer) = StringOrBuffer::from_js_stable(global_this, input_value)? else { return Err(global_this .throw_invalid_arguments(format_args!("Expected a string or buffer to render"))); }; @@ -294,7 +294,7 @@ fn render(global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResult( // whose encoding declaration must not be acted upon: a Latin-1 string is // parsed byte-per-character as is, a UTF-16 one by its code units. let text; + let mut copy = Vec::new(); let mut utf16 = None; let (bytes, encoding): (&[u8], _) = match input { - StringOrJsBytes::Bytes(bytes) => (bytes.get(&cx.heap), xml::InputEncoding::Bytes), + StringOrJsBytes::Bytes(bytes) => { + // The parser reads a byte more than once, and the result's strings are made from the input. + let bytes = bytes.get_or_copy_if_shared(&cx.heap, &mut copy)?; + (bytes, xml::InputEncoding::Bytes) + } StringOrJsBytes::String(string) => { text = string; if text.is_8bit() { diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index 6fdc51e902ef..862168b864c7 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -232,6 +232,8 @@ const MAX_WINDOW_SIZE: u32 = i32::MAX as u32; const MAX_HEADER_TABLE_SIZE: u32 = u32::MAX; const MAX_STREAM_ID: u32 = i32::MAX as u32; const MAX_FRAME_SIZE: u32 = 0xFF_FFFF; // u24::MAX +// node's kMaxALTSVC: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/http2/core.js#L241 +const MAX_ALTSVC_LENGTH: usize = 16384 - 2; const DEFAULT_WINDOW_SIZE: u64 = u16::MAX as u64; // Float versions for range validation before integer conversion const MAX_WINDOW_SIZE_F64: f64 = MAX_WINDOW_SIZE as f64; @@ -4962,20 +4964,25 @@ impl H2FrameParser { } stream_id = stream_id_js.to_u32(); } + // https://github.com/nodejs/node/blob/v26.3.0/src/node_http2.cc#L3233-L3239 + let origin_bytes = origin_view.as_ref().map(header_value_bytes); + let value_bytes = value_view.as_ref().map(header_value_bytes); + let origin = origin_bytes.as_deref().unwrap_or_default(); + let value = value_bytes.as_deref().unwrap_or_default(); + if origin.len() + value.len() > MAX_ALTSVC_LENGTH { + let exception = global_object.to_type_error( + bun_jsc::ErrorCode::HTTP2_ALTSVC_LENGTH, + format_args!("HTTP/2 ALTSVC frames are limited to {MAX_ALTSVC_LENGTH} bytes"), + ); + return Err(global_object.throw_value(exception)); + } if stream_id > 0 { // dont error but dont send frame to invalid stream id if this.streams.get().get(&stream_id).is_none() { return Ok(JSValue::UNDEFINED); } } - // https://github.com/nodejs/node/blob/v26.3.0/src/node_http2.cc#L3233-L3239 - let origin_bytes = origin_view.as_ref().map(header_value_bytes); - let value_bytes = value_view.as_ref().map(header_value_bytes); - this.send_alt_svc( - stream_id, - origin_bytes.as_deref().unwrap_or_default(), - value_bytes.as_deref().unwrap_or_default(), - ); + this.send_alt_svc(stream_id, origin, value); Ok(JSValue::UNDEFINED) } @@ -6033,13 +6040,13 @@ impl H2FrameParser { #[bun_jsc::host_fn(method)] pub(crate) fn set_next_stream_id( this: &Self, - _global_object: &JSGlobalObject, + global_object: &JSGlobalObject, callframe: &CallFrame, ) -> JsResult { - let args_list = callframe.arguments(); - debug_assert!(args_list.len() >= 1); - let stream_id_arg = args_list[0]; - debug_assert!(stream_id_arg.is_number()); + let stream_id_arg = callframe.argument(0); + if !stream_id_arg.is_number() { + return Err(global_object.throw(format_args!("Expected stream_id to be a number"))); + } // Store the id `get_next_stream_id` steps from. A fractional id passes the JS layer's // `id <= 0` check and truncates to 0 here; 0 (and 1 on a client) has no predecessor, // so the subtraction saturates to the initial state instead of wrapping. @@ -6096,11 +6103,20 @@ impl H2FrameParser { "Expected parentId, promisedId, headers and sensitiveHeaders arguments" ))); } + if !parent_id_arg.is_number() { + return Err(global_object.throw(format_args!("Expected parentId to be a number"))); + } + if !promised_id_arg.is_number() { + return Err(global_object.throw(format_args!("Expected promisedId to be a number"))); + } let parent_id = parent_id_arg.to_u32(); let promised_id = promised_id_arg.to_u32(); if promised_id > MAX_STREAM_ID { return Ok(JSValue::js_number(-1.0)); } + if parent_id == 0 || parent_id > MAX_STREAM_ID || promised_id == 0 { + return Err(global_object.throw(format_args!("Invalid stream id"))); + } let Some(headers_obj) = headers_arg.get_object() else { return Err(global_object.throw(format_args!("Expected headers to be an object"))); }; diff --git a/src/runtime/api/bun/js_bun_spawn_bindings.rs b/src/runtime/api/bun/js_bun_spawn_bindings.rs index 93b311f0c4cf..86db753802c7 100644 --- a/src/runtime/api/bun/js_bun_spawn_bindings.rs +++ b/src/runtime/api/bun/js_bun_spawn_bindings.rs @@ -363,7 +363,7 @@ fn spawn_maybe_sync( // outlives this call frame. let jsc_vm: &mut jsc::VirtualMachineRef = cx.vm().as_mut(); - let mut cwd: &[u8] = bun_resolver::fs::FileSystem::get().top_level_dir; + let mut cwd: &[u8] = bun_resolver::fs::FileSystem::get().top_level_dir(); let mut user_specified_cwd = false; let mut stdio: [Stdio; 3] = [Stdio::Ignore, Stdio::Pipe, Stdio::Inherit]; @@ -1837,7 +1837,10 @@ fn spawn_maybe_sync( // Re-evaluate this at each iteration of the loop since it may change between iterations. let bun_test_timeout: Timespec = if bun_test_fired { Timespec::EPOCH - } else if let Some(runner) = crate::test_runner::jest::Jest::runner() { + } else if jsc_vm.is_main_thread + && let Some(runner) = crate::test_runner::jest::Jest::runner() + { + // The test runner and its deadlines belong to the main thread. runner.get_active_timeout() } else { Timespec::EPOCH diff --git a/src/runtime/api/crash_handler_jsc.rs b/src/runtime/api/crash_handler_jsc.rs index e042f1124cf6..8e6fb775723c 100644 --- a/src/runtime/api/crash_handler_jsc.rs +++ b/src/runtime/api/crash_handler_jsc.rs @@ -24,6 +24,7 @@ pub(crate) mod js_bindings { ("panic", __jsc_host_js_panic), ("rootError", __jsc_host_js_root_error), ("outOfMemory", __jsc_host_js_out_of_memory), + ("operatorNew", __jsc_host_js_operator_new), ("abort", __jsc_host_js_abort), ("fastfail", __jsc_host_js_fastfail), ("trap", __jsc_host_js_trap), @@ -206,6 +207,24 @@ pub(crate) mod js_bindings { bun_core::out_of_memory(); } + /// Calls `mi_new` or `mi_new_nothrow`, which back C++ `operator new` where mimalloc overrides it. + /// Only `mi_new_nothrow` may return null. + #[bun_jsc::host_fn] + fn js_operator_new(_global: &JSGlobalObject, frame: &CallFrame) -> JsResult { + unsafe extern "C" { + // safe: both accept any size, and the result is never dereferenced. + safe fn mi_new(size: usize) -> *mut core::ffi::c_void; + safe fn mi_new_nothrow(size: usize) -> *mut core::ffi::c_void; + } + if frame.argument(0).to_boolean() { + let refused = mi_new_nothrow(usize::MAX / 2).is_null(); + return Ok(JSValue::js_boolean(refused)); + } + crash_handler::suppress_core_dumps_if_necessary(); + let _ = mi_new(usize::MAX / 2); + Ok(JSValue::UNDEFINED) + } + #[bun_jsc::host_fn] fn js_raise_ignoring_panic_handler( _global: &JSGlobalObject, diff --git a/src/runtime/api/cron.rs b/src/runtime/api/cron.rs index 3f6c57acbc4f..55b6ea394c5d 100644 --- a/src/runtime/api/cron.rs +++ b/src/runtime/api/cron.rs @@ -2175,7 +2175,7 @@ fn spawn_cmd_prepare( bun_core::heap::into_raw(Box::new(bun_core::ffi::zeroed::< bun_sys::windows::libuv::Pipe, >())); - let cwd = FileSystem::get().top_level_dir; + let cwd = FileSystem::get().top_level_dir(); let spawn_options = SpawnOptions { stdin: stdin_opt, stdout: stdout_opt, diff --git a/src/runtime/api/filesystem_router.rs b/src/runtime/api/filesystem_router.rs index 46de69ea6946..4a2e55623f30 100644 --- a/src/runtime/api/filesystem_router.rs +++ b/src/runtime/api/filesystem_router.rs @@ -5,7 +5,7 @@ use bun_paths::strings; use bun_ast as Log; use bun_core::Utf8Bytes; use bun_jsc::bindgen::{self, JsHeap, JsLocal, JsPayload as _, JsScope, JsWrapper, to_js}; -use bun_jsc::ref_string::RefString; +use bun_jsc::ref_string::{RefString, StringTooLong}; use bun_jsc::virtual_machine::VirtualMachine; use bun_jsc::{JSGlobalObject, JsCell, JsResult, LogJsc}; use bun_paths::{self as path, MAX_PATH_BYTES}; @@ -22,6 +22,7 @@ use crate::generated_bindgen::filesystem_router::{ FileSystemRouterClass, FileSystemRouterInternalFields as _, FileSystemRouterOptions, MatchedRouteClass, Routable, RouteKind, RouterStyle, }; +use crate::node::types::Valid; // Note: `FrameworkFileSystemRouter` is declared in this file's // `filesystem_router.classes.ts`, so codegen looks for the backing struct here @@ -61,12 +62,11 @@ impl<'a, 'r> Router::ResolverLike for RouterResolver<'a, 'r> { struct RefCountedString(BackRef); impl RefCountedString { - fn new(vm: &mut VirtualMachine, bytes: &[u8]) -> Self { - let string = vm.ref_counted_string::(bytes, None); - let string = BackRef::from(core::ptr::NonNull::new(string).expect("ref_counted_string")); + fn new(vm: &mut VirtualMachine, bytes: &[u8]) -> Result { + let string = BackRef::from(vm.ref_counted_string(bytes, None)?); // The cache adds no ref on a hit. string.ref_(); - Self(string) + Ok(Self(string)) } fn bytes(&self) -> &[u8] { @@ -250,6 +250,7 @@ impl FileSystemRouterClass for FileSystemRouter { } } let global = cx.global(); + Valid::no_null_bytes(options.dir, "dir", "a string", global)?; let mut out_buf = [0u8; MAX_PATH_BYTES * 2]; let mut root_dir_path = global.bun_vm().top_level_dir(); @@ -257,7 +258,7 @@ impl FileSystemRouterClass for FileSystemRouter { // An absolute path is joined too: a route's name is cut from the resolver's normalized spelling. let Some(joined) = path::resolve_path::join_abs_string_buf_checked::( - Fs::FileSystem::instance().top_level_dir, + Fs::FileSystem::instance().top_level_dir(), &mut out_buf, &[options.dir], ) @@ -300,16 +301,24 @@ impl FileSystemRouterClass for FileSystemRouter { }; let vm = global.bun_vm().as_mut(); - let base_dir = RefCountedString::new(vm, base_dir_str); - router.config.dir = Box::from(base_dir.bytes()); - let mut ref_counted = |bytes: Option<&[u8]>| { - let bytes = bytes.filter(|bytes| !bytes.is_empty())?; - Some(RefCountedString::new(vm, bytes)) + let mut ref_counted = |bytes: &[u8]| { + RefCountedString::new(vm, bytes).map_err(|_| global.throw_string_too_long()) }; + let base_dir = ref_counted(base_dir_str)?; + router.config.dir = Box::from(base_dir.bytes()); + let not_empty = |bytes: &&[u8]| !bytes.is_empty(); Ok(FileSystemRouter { - origin: ref_counted(options.origin), + origin: options + .origin + .filter(not_empty) + .map(&mut ref_counted) + .transpose()?, base_dir, - asset_prefix: ref_counted(options.asset_prefix), + asset_prefix: options + .asset_prefix + .filter(not_empty) + .map(&mut ref_counted) + .transpose()?, router, }) } diff --git a/src/runtime/api/glob.rs b/src/runtime/api/glob.rs index 325fe832984a..d39ea8c343b2 100644 --- a/src/runtime/api/glob.rs +++ b/src/runtime/api/glob.rs @@ -22,6 +22,7 @@ pub(crate) enum ScanError<'a> { Sys(syscall::Error), Runtime(crate::Error), CwdTooLong(&'static str), + NullBytes(&'static str, Box<[u8]>), Js(bindgen::Error<'a>), } impl<'a> IntoError<'a> for ScanError<'a> { @@ -33,6 +34,13 @@ impl<'a> IntoError<'a> for ScanError<'a> { ScanError::CwdTooLong(fn_name) => heap.error(format_args!( "{fn_name}: invalid `cwd`, longer than {MAX_PATH_BYTES} bytes" )), + ScanError::NullBytes(name, value) => heap.error_with_code( + bun_jsc::ErrorCode::INVALID_ARG_VALUE, + format_args!( + "The argument '{name}' must be a string without null bytes. Received {}", + bun_core::fmt::quote(&value) + ), + ), ScanError::Js(err) => err, } } @@ -60,6 +68,9 @@ impl ScanOpts { if cwd.len() > MAX_PATH_BYTES { return Err(ScanError::CwdTooLong(fn_name)); } + if bun_core::strings::contains_char(cwd, 0) { + return Err(ScanError::NullBytes("cwd", Box::from(cwd))); + } if resolve_path::Platform::AUTO.is_absolute(cwd) { return Ok(Some(Box::from(cwd))); } @@ -346,6 +357,12 @@ impl Glob { argument: Option>, fn_name: &'static str, ) -> Result> { + if bun_core::strings::contains_char(self.pattern.slice(), 0) { + return Err(ScanError::NullBytes( + "pattern", + Box::from(self.pattern.slice()), + )); + } let match_opts = ScanOpts::new(argument, fn_name)?; fn init_walker<'a, A: walk::Accessor>( diff --git a/src/runtime/api/html_rewriter.rs b/src/runtime/api/html_rewriter.rs index a500f86d5249..703c31ba3039 100644 --- a/src/runtime/api/html_rewriter.rs +++ b/src/runtime/api/html_rewriter.rs @@ -39,7 +39,7 @@ use lol_html::html_content::UserData as _; // returns). The `DetachablePtr` type invariant is discharged by // `handler_callback`: it parks the `&mut X` lol-html lends the closure // (`build_settings`, `EndTag::on_end_tag`), runs the JS callback, and its -// scopeguard `detach()`s the slot before the closure returns to lol-html — so +// scopeguard (or its suspend arm) nulls the slot before the closure returns — so // a non-null load means the pointee is still inside lol-html's exclusive // borrow, and a JS object retained past its handler reads `None`. type RawElement = lol_html::html_content::Element<'static, 'static>; @@ -283,7 +283,7 @@ pub(crate) enum HandlerOutcome { /// The handler returned a promise that is still pending after one /// microtask drain: make lol-html park the current rewritable unit and /// return from `write()`/`end()`/`resume()` so the event loop can run. - /// See [`RewriterPipe::begin_suspension`]. + /// See [`RewriterPipe::park_suspension`]. Suspend, } @@ -798,9 +798,10 @@ pub struct RewriterPipe { /// fails the whole rewrite instead. sync_only_noun: Cell>, /// Handed from the suspending [`handler_callback`] to - /// [`Self::begin_suspension`] across the lol-html unwind. The promise + /// [`Self::park_suspension`] across the lol-html unwind. The promise /// itself is rooted in the cell's `suspensionPromise` WriteBarrier slot. pending_suspension: JsCell>, + /// Set while a handler is suspended. The wrapper points to the unit lol-html parked. suspended_wrapper: JsCell>, /// `true` while a lol-html `write`/`end_mut`/`resume` call on this pipe's /// `rewriter` is on the stack. The output sink may re-enter the pipe via @@ -1003,7 +1004,7 @@ impl RewriterPipe { #[inline] fn is_suspended(&self) -> bool { - self.suspended_wrapper.get().is_some() || self.pending_suspension.get().is_some() + self.suspended_wrapper.get().is_some() } /// Output emitted but not yet taken by a reader. @@ -1589,7 +1590,10 @@ impl RewriterPipe { /// `SourceHandle` calls into this pipe check `driving` and defer, so the /// `with_mut` borrow on `rewriter` is never aliased. Returns `None` when /// the rewriter is unset. - fn drive_rewriter(&self, f: impl FnOnce(&mut LolRewriter) -> R) -> Option { + fn drive_rewriter( + &self, + f: impl FnOnce(&mut LolRewriter) -> Result<(), lol_html::errors::RewritingError>, + ) -> Option> { if self.rewriter.get().is_none() { return None; } @@ -1597,7 +1601,14 @@ impl RewriterPipe { let cell = self.cell.get_or_undefined(); let _active = ActiveSinkGuard::enter(self); self.driving.set(true); - let res = self.rewriter.with_mut(|r| r.as_deref_mut().map(f)); + let res = self.rewriter.with_mut(|r| { + let r = r.as_deref_mut()?; + let res = f(r); + if let Err(e) = &res { + self.park_suspension(r, e); + } + Some(res) + }); // Hand this call's output to the stream as one chunk: lol-html emits a // fragment per token piece, and each `on_data` may be a socket write // or a downstream rewriter's `write`. Still under `driving`, so the @@ -1611,6 +1622,31 @@ impl RewriterPipe { res } + /// Points the suspended handler's wrapper at the unit lol-html parked. + #[cold] + #[inline(never)] + fn park_suspension(&self, rewriter: &mut LolRewriter, e: &lol_html::errors::RewritingError) { + let wrapper = self.pending_suspension.take(); + if !matches!(e, lol_html::errors::RewritingError::Suspended) { + // The call can hit the memory limit after a handler suspended. lol-html doesn't park a unit then. + if wrapper.is_some() + && let Some(cell) = self.cell.try_get() + { + js_HTMLRewriterTransform::suspension_promise_set_cached( + cell, + &self.global, + JSValue::UNDEFINED, + ); + } + return; + } + let wrapper = + wrapper.expect("lol-html suspended without a pending HTMLRewriter handler promise"); + wrapper.retarget(rewriter); + let parked = self.suspended_wrapper.replace(Some(wrapper)); + debug_assert!(parked.is_none()); + } + /// `None` once the rewrite is over or the cell is collected. fn handler_list(&self) -> Option { let cell = self.cell.try_get()?; @@ -1691,6 +1727,8 @@ impl RewriterPipe { } fn flush_output(&self) { + // `on_data` runs JS, so a suspended handler's wrapper must already be parked. + debug_assert!(self.pending_suspension.get().is_none()); let Some(out) = self.output.get() else { return; }; @@ -1832,12 +1870,6 @@ impl RewriterPipe { if matches!(e, lol_html::errors::RewritingError::Suspended) { return self.begin_suspension(); } - let leftover = self.pending_suspension.take(); - debug_assert!( - leftover.is_none(), - "lol-html returned a non-suspension error with a suspension armed" - ); - drop(leftover); self.phase.set(RewritePhase::Done); let captured = self.take_handler_error(); @@ -1862,18 +1894,9 @@ impl RewriterPipe { self.fail(value_error); } + /// Attaches the `.then()` reaction for the handler that [`Self::park_suspension`] parked. fn begin_suspension(&self) { - let wrapper = self - .pending_suspension - .take() - .expect("lol-html suspended without a pending HTMLRewriter handler promise"); - - self.rewriter.with_mut(|r| { - if let Some(r) = r.as_deref_mut() { - wrapper.retarget(r); - } - }); - self.suspended_wrapper.set(Some(wrapper)); + debug_assert!(self.suspended_wrapper.get().is_some()); // The `.then()` context is a `NativePromiseContext` holding the // Transform cell: while the promise can settle, the reaction roots @@ -2502,6 +2525,8 @@ where // Hand the wrapper to the suspension: it has to stay valid across // the handler's `await`, so disarm the guard here. let wrapper = scopeguard::ScopeGuard::into_inner(guard); + // lol-html moves the unit as it returns, so null the wrapper until `park_suspension` retargets it. + wrapper.retarget(core::ptr::null_mut()); // The callback came out of the cell, and `drive_rewriter` keeps it alive. let cell = sink .cell @@ -3147,7 +3172,7 @@ impl Element { // SAFETY: lifetime erasure. `end_tag` only lives for this // synchronous call; `handler_callback`'s guard detaches the // `EndTag` JsClass slot before this closure returns (or, on a - // suspension, re-points it at the heap copy lol-html parks), so + // suspension, nulls it until lol-html has parked the heap copy), so // JS can never reach a dangling pointer. let raw: *mut RawEndTag = core::ptr::from_mut(end_tag).cast(); handler_result(EndTagHandler::on_end_tag( diff --git a/src/runtime/api/js_bundle_completion_task.rs b/src/runtime/api/js_bundle_completion_task.rs index 5ca0266322e0..339e444a43e8 100644 --- a/src/runtime/api/js_bundle_completion_task.rs +++ b/src/runtime/api/js_bundle_completion_task.rs @@ -27,7 +27,7 @@ use bun_jsc::bun_string_jsc; use bun_jsc::{self as jsc, JSGlobalObject, JSPromise, JSValue, LogJsc as _}; use bun_options_types::WindowsOptions; use bun_options_types::schema::api; -use bun_paths::resolve_path::{join_abs_string, join_abs_string_buf, platform}; +use bun_paths::resolve_path::{join_abs_string, platform}; use bun_paths::{self as paths, SEP}; use bun_ptr::{BackRef, RefCount, RefPtr}; use bun_standalone_graph::StandaloneModuleGraph::{ @@ -66,7 +66,8 @@ pub(crate) struct JSBundleCompletionTask { pub(crate) context: jsc::ContextId, pub(crate) promise: jsc::JSPromiseStrong, pub poll_ref: KeepAlive, - pub(crate) env: *mut bun_dotenv::Loader, + /// This build's copy of the VM's env. Boxed because the transpiler keeps a pointer to it. + pub(crate) env: Box, pub(crate) log: bun_ast::Log, /// Set by the owner giving up on the result (HTMLBundle route torn down) /// or by the VM's stop phase; read by `on_complete` (skip delivery) and by @@ -178,7 +179,10 @@ impl JSBundleCompletionTask { context, promise: jsc::JSPromiseStrong::default(), poll_ref: KeepAlive::init(), - env: global_this.bun_vm().transpiler.env, + // Copy on the JS thread because only the JS thread writes to the VM's loader. + env: Box::new(bun_core::handle_oom( + global_this.bun_vm().env_loader().clone_loaded(), + )), log: bun_ast::Log::init(), cancelled: core::sync::atomic::AtomicBool::new(false), bundle_loop: core::sync::atomic::AtomicPtr::new(ptr::null_mut()), @@ -204,7 +208,9 @@ impl JSBundleCompletionTask { let plugins = self.plugins; let completion = RefPtr::new(self).into_raw(); if let Some(plugin) = plugins { - Plugin::opaque_mut(plugin.as_ptr()).set_config(completion.cast()); + let plugin = Plugin::opaque_mut(plugin.as_ptr()); + plugin.set_config(completion.cast()); + plugin.freeze_filters(); } // Ensure this exists before we spawn the thread to prevent any race @@ -212,9 +218,9 @@ impl JSBundleCompletionTask { let _ = WorkPool::get(); // Out on the bundle thread from here until it posts the completion: it - // reads this VM's env loader and the plugin cell, so it is cancelled and - // waited for (`bundle_ticket`) when the realm's context stops: at teardown, - // and at a `bun test --isolate` file swap. + // reads this VM's plugin cell, so it is cancelled and waited for + // (`bundle_ticket`) when the realm's context stops: at teardown, and at a + // `bun test --isolate` file swap. // SAFETY: `completion` is the live heap allocation; it leaves its // context in `on_complete_anytask`. unsafe { @@ -240,23 +246,18 @@ fn opt_box(s: &[u8]) -> Option> { /// Absolute, because the PE metadata operations need an absolute path. fn executable_path(config: &JSBundlerConfig, compile: &CompileOptions) -> Box<[u8]> { - let mut outbuf = paths::path_buffer_pool::get(); // SAFETY: `FileSystem::instance()` is the process-lifetime singleton // initialized during VM startup before any `Bun.build` is reachable. - let top_level_dir = bun_resolver::fs::FileSystem::get().top_level_dir; + let top_level_dir = bun_resolver::fs::FileSystem::get().top_level_dir(); let outdir_slice = &config.outdir.list; let outfile_slice = &compile.outfile.list; let joined: &[u8] = if !outdir_slice.is_empty() { - join_abs_string_buf::( - top_level_dir, - &mut outbuf[..], - &[outdir_slice, outfile_slice], - ) + join_abs_string::(top_level_dir, &[outdir_slice, outfile_slice]) } else if paths::is_absolute(outfile_slice) { outfile_slice } else { // For relative paths, ensure we make them absolute relative to the current working directory - join_abs_string_buf::(top_level_dir, &mut outbuf[..], &[outfile_slice]) + join_abs_string::(top_level_dir, &[outfile_slice]) }; if compile.compile_target.os == OperatingSystem::Windows && !joined.ends_with(b".exe") { let mut v = Vec::with_capacity(joined.len() + 4); @@ -454,10 +455,7 @@ impl JSBundleCompletionTask { root_dir.fd, module_prefix, outfile_for_executable, - // SAFETY: `self.env` is the per-VM `DotEnv.Loader` stashed at - // construction; valid for the lifetime of the VirtualMachine, and - // nothing inside `to_executable` reaches it otherwise. - unsafe { &mut *self.env }, + &mut self.env, self.config.format, &WindowsOptions { hide_console: compile_options.windows_hide_console, @@ -765,7 +763,7 @@ impl JSBundleCompletionTask { let dir = this.config.dir.list.clone(); // SAFETY: `FileSystem::instance()` is the process-lifetime singleton // initialized during VM startup before any `Bun.build` is reachable. - let top_level_dir = bun_resolver::fs::FileSystem::get().top_level_dir; + let top_level_dir = bun_resolver::fs::FileSystem::get().top_level_dir(); let mut to_assign_on_sourcemap = JSValue::ZERO; for (i, output_file) in output_files.iter_mut().enumerate() { @@ -1167,8 +1165,7 @@ impl CompletionStruct for JSBundleCompletionTask { { match bun_standalone_graph::StandaloneModuleGraph::target_builtins( &compile.compile_target, - // SAFETY: `self.env` is the per-VM `DotEnv.Loader` stashed at construction; see `to_executable` below. - unsafe { &mut *self.env }, + &mut self.env, Some(&compile.executable_path.list[..]).filter(|p| !p.is_empty()), ) { Ok(Some(section)) => options::CompileTargetBuiltins::Target(section), @@ -1279,7 +1276,7 @@ impl CompletionStruct for JSBundleCompletionTask { // Relative to where the entry points are relative to, as for `--tsconfig-override`. tsconfig_override: (!config.tsconfig_override.list.is_empty()).then(|| { let cwd = match config.dir.list.is_empty() { - true => bun_resolver::fs::FileSystem::instance().top_level_dir, + true => bun_resolver::fs::FileSystem::instance().top_level_dir(), false => config.dir.list.as_slice(), }; let path = config.tsconfig_override.list.as_slice(); @@ -1292,7 +1289,8 @@ impl CompletionStruct for JSBundleCompletionTask { }; let log: *mut bun_ast::Log = &raw mut self.log; - let t = Transpiler::init(bump, log, opts, Some(self.env))?; + let env: *mut bun_dotenv::Loader = &raw mut *self.env; + let t = Transpiler::init(bump, log, opts, Some(env))?; let transpiler: &'a mut Transpiler<'a> = bump.alloc(t); // Post-init field wiring. diff --git a/src/runtime/bake/BakeGlobalObject.cpp b/src/runtime/bake/BakeGlobalObject.cpp index caf5a1716038..9445a5b238ed 100644 --- a/src/runtime/bake/BakeGlobalObject.cpp +++ b/src/runtime/bake/BakeGlobalObject.cpp @@ -22,37 +22,28 @@ bakeModuleLoaderImportModule(JSC::JSGlobalObject* global, bool deferred) { UNUSED_PARAM(deferred); + auto& vm = JSC::getVM(global); + auto scope = DECLARE_THROW_SCOPE(vm); + WTF::String keyString = moduleNameValue->getString(global); + RETURN_IF_EXCEPTION(scope, nullptr); if (keyString.startsWith("bake:/"_s)) { - auto& vm = JSC::getVM(global); - return JSC::importModule(global, JSC::Identifier::fromString(vm, keyString), - JSC::Identifier(), WTF::move(parameters), nullptr); + RELEASE_AND_RETURN(scope, JSC::importModule(global, JSC::Identifier::fromString(vm, keyString), JSC::Identifier(), WTF::move(parameters), nullptr)); } if (!sourceOrigin.isNull() && sourceOrigin.string().startsWith("bake:/"_s)) { - auto& vm = JSC::getVM(global); - auto scope = DECLARE_THROW_SCOPE(vm); - WTF::String refererString = sourceOrigin.string(); - WTF::String keyString = moduleNameValue->getString(global); - - if (!keyString) { - auto promise = JSC::JSPromise::create(vm, global->promiseStructure()); - promise->reject(vm, JSC::createError(global, "import() requires a string"_s)); - return promise; - } BunString refererBunString = Bun::toString(refererString); BunString keyBunString = Bun::toString(keyString); BunString result = BakeProdResolve(global, &refererBunString, &keyBunString); RETURN_IF_EXCEPTION(scope, nullptr); - return JSC::importModule(global, JSC::Identifier::fromString(vm, result.transferToWTFString()), - JSC::Identifier(), WTF::move(parameters), nullptr); + RELEASE_AND_RETURN(scope, JSC::importModule(global, JSC::Identifier::fromString(vm, result.transferToWTFString()), JSC::Identifier(), WTF::move(parameters), nullptr)); } // TODO: make static cast instead of jscast - return uncheckedDowncast(global)->moduleLoaderImportModule(global, moduleLoader, moduleNameValue, WTF::move(parameters), sourceOrigin, false); + RELEASE_AND_RETURN(scope, uncheckedDowncast(global)->moduleLoaderImportModule(global, moduleLoader, moduleNameValue, WTF::move(parameters), sourceOrigin, false)); } JSC::Identifier bakeModuleLoaderResolve(JSC::JSGlobalObject* jsGlobal, @@ -95,7 +86,7 @@ JSC::Identifier bakeModuleLoaderResolve(JSC::JSGlobalObject* jsGlobal, } } - return Zig::GlobalObject::moduleLoaderResolve(jsGlobal, loader, key, referrer, WTF::move(origin), useImportMap); + RELEASE_AND_RETURN(scope, Zig::GlobalObject::moduleLoaderResolve(jsGlobal, loader, key, referrer, WTF::move(origin), useImportMap)); } static JSC::JSPromise* rejectedInternalPromise(JSC::JSGlobalObject* globalObject, JSC::JSValue value) @@ -172,14 +163,12 @@ JSC::JSPromise* bakeModuleLoaderFetch(JSC::JSGlobalObject* globalObject, #endif JSString* bakePrefixRemovedString = jsNontrivialString(vm, bakePrefixRemoved); JSValue bakePrefixRemovedJsvalue = bakePrefixRemovedString; - return Zig::GlobalObject::moduleLoaderFetch(globalObject, loader, bakePrefixRemovedJsvalue, referrer, WTF::move(parameters), WTF::move(script)); + RELEASE_AND_RETURN(scope, Zig::GlobalObject::moduleLoaderFetch(globalObject, loader, bakePrefixRemovedJsvalue, referrer, WTF::move(parameters), WTF::move(script))); } return rejectedInternalPromise(globalObject, createTypeError(globalObject, "BakeGlobalObject does not have per-thread data configured"_s)); } - auto result = Zig::GlobalObject::moduleLoaderFetch(globalObject, loader, key, referrer, WTF::move(parameters), WTF::move(script)); - RETURN_IF_EXCEPTION(scope, rejectedInternalPromise(globalObject, scope.exception()->value())); - return result; + RELEASE_AND_RETURN(scope, Zig::GlobalObject::moduleLoaderFetch(globalObject, loader, key, referrer, WTF::move(parameters), WTF::move(script))); } GlobalObject* GlobalObject::create(JSC::VM& vm, JSC::Structure* structure, diff --git a/src/runtime/bake/BakeSourceProvider.cpp b/src/runtime/bake/BakeSourceProvider.cpp index f8257fba3747..fc38e3298cb6 100644 --- a/src/runtime/bake/BakeSourceProvider.cpp +++ b/src/runtime/bake/BakeSourceProvider.cpp @@ -4,6 +4,7 @@ #include "BakeGlobalObject.h" #include "JavaScriptCore/CallData.h" #include "JavaScriptCore/Completion.h" +#include "JavaScriptCore/Error.h" #include "JavaScriptCore/Identifier.h" #include "JavaScriptCore/JSCJSValue.h" #include "JavaScriptCore/JSCast.h" @@ -15,6 +16,7 @@ #include "JavaScriptCore/JSString.h" #include "JavaScriptCore/JSModuleNamespaceObject.h" #include "ImportMetaObject.h" +#include namespace Bake { @@ -29,6 +31,9 @@ extern "C" BunString BakeSourceProvider__getSourceSlice(SourceProvider* provider return Bun::toStringView(provider->source()); } +// Rust calls the EncodedJSValue functions below through `from_js_host_call`, +// so they must only return empty when an exception is pending. + extern "C" JSC::EncodedJSValue BakeLoadInitialServerCode(JSC::JSGlobalObject* global, BunString source, bool separateSSRGraph) { auto& vm = JSC::getVM(global); auto scope = DECLARE_THROW_SCOPE(vm); @@ -56,11 +61,24 @@ extern "C" JSC::EncodedJSValue BakeLoadInitialServerCode(JSC::JSGlobalObject* gl args.append(JSC::jsBoolean(separateSSRGraph)); // separateSSRGraph args.append(Zig::ImportMetaObject::create(global, "bake://server-runtime.js"_s)); // importMeta - RELEASE_AND_RETURN(scope, JSC::JSValue::encode(JSC::profiledCall(global, JSC::ProfilingReason::API, fn, callData, JSC::jsUndefined(), args))); + // `JSC::call` returns undefined, not empty, when the callee throws. + JSC::JSValue result = JSC::profiledCall(global, JSC::ProfilingReason::API, fn, callData, JSC::jsUndefined(), args); + RETURN_IF_EXCEPTION(scope, {}); + return JSC::JSValue::encode(result); } -extern "C" JSC::JSPromise* BakeLoadModuleByKey(GlobalObject* global, JSC::JSString* key) { - return JSC::loadAndEvaluateModule(global, key->getString(global), nullptr, nullptr); +extern "C" JSC::EncodedJSValue BakeLoadModuleByKey(JSC::JSGlobalObject* global, JSC::EncodedJSValue keyValue) { + auto& vm = JSC::getVM(global); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSC::JSString* key = uncheckedDowncast(JSC::JSValue::decode(keyValue)); + String keyString = key->getString(global); + RETURN_IF_EXCEPTION(scope, {}); + + JSC::JSPromise* promise = JSC::loadAndEvaluateModule(global, keyString, nullptr, nullptr); + RETURN_IF_EXCEPTION(scope, {}); + ASSERT(promise); + return JSC::JSValue::encode(promise); } extern "C" JSC::EncodedJSValue BakeLoadServerHmrPatch(GlobalObject* global, BunString source) { @@ -113,28 +131,48 @@ extern "C" JSC::EncodedJSValue BakeLoadServerHmrPatchWithSourceMap(GlobalObject* return JSC::JSValue::encode(result); } -extern "C" JSC::EncodedJSValue BakeGetModuleNamespace( - JSC::JSGlobalObject* global, - JSC::JSValue keyValue -) { - JSC::JSString* key = uncheckedDowncast(keyValue); +// Only returns nullptr when an exception is pending. +static JSC::JSModuleNamespaceObject* getModuleNamespace(JSC::JSGlobalObject* global, JSC::JSValue keyValue) { auto& vm = JSC::getVM(global); - auto keyIdent = JSC::Identifier::fromString(vm, key->value(global)); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSC::JSString* key = uncheckedDowncast(keyValue); + String keyString = key->getString(global); + RETURN_IF_EXCEPTION(scope, nullptr); + + auto keyIdent = JSC::Identifier::fromString(vm, keyString); auto* entry = global->moduleLoader()->registryEntry(keyIdent); - ASSERT(entry); // should have called BakeLoadServerCode and wait for that promise auto* module = entry ? entry->record() : nullptr; - ASSERT(module); + if (!module) [[unlikely]] { + throwTypeError(global, scope, makeString("Module \""_s, keyString, "\" is not in the module registry"_s)); + return nullptr; + } JSC::JSModuleNamespaceObject* namespaceObject = global->moduleLoader()->getModuleNamespaceObject(global, module); + RETURN_IF_EXCEPTION(scope, nullptr); ASSERT(namespaceObject); - return JSC::JSValue::encode(namespaceObject); + return namespaceObject; +} + +extern "C" JSC::EncodedJSValue BakeGetModuleNamespace( + JSC::JSGlobalObject* global, + JSC::EncodedJSValue keyValue +) { + return JSC::JSValue::encode(getModuleNamespace(global, JSC::JSValue::decode(keyValue))); } extern "C" JSC::EncodedJSValue BakeGetDefaultExportFromModule( JSC::JSGlobalObject* global, - JSC::JSValue keyValue + JSC::EncodedJSValue keyValue ) { auto& vm = JSC::getVM(global); - return JSC::JSValue::encode(uncheckedDowncast(JSC::JSValue::decode(BakeGetModuleNamespace(global, keyValue)))->get(global, vm.propertyNames->defaultKeyword)); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSC::JSModuleNamespaceObject* namespaceObject = getModuleNamespace(global, JSC::JSValue::decode(keyValue)); + RETURN_IF_EXCEPTION(scope, {}); + + JSC::JSValue defaultExport = namespaceObject->get(global, vm.propertyNames->defaultKeyword); + RETURN_IF_EXCEPTION(scope, {}); + return JSC::JSValue::encode(defaultExport); } // There were issues when trying to use JSValue.get from zig @@ -145,10 +183,13 @@ extern "C" JSC::EncodedJSValue BakeGetOnModuleNamespace( size_t keyLength ) { auto& vm = JSC::getVM(global); + auto scope = DECLARE_THROW_SCOPE(vm); const auto propertyString = String(StringImpl::createWithoutCopying({ byteCast(key), keyLength })); const auto identifier = JSC::Identifier::fromString(vm, propertyString); const auto property = JSC::PropertyName(identifier); - return JSC::JSValue::encode(moduleNamespace->get(global, property)); + JSC::JSValue value = moduleNamespace->get(global, property); + RETURN_IF_EXCEPTION(scope, {}); + return JSC::JSValue::encode(value); } } // namespace Bake diff --git a/src/runtime/bake/BakeSourceProvider.h b/src/runtime/bake/BakeSourceProvider.h index be904cf7a931..f6caa6671d44 100644 --- a/src/runtime/bake/BakeSourceProvider.h +++ b/src/runtime/bake/BakeSourceProvider.h @@ -9,6 +9,7 @@ namespace Bake { class SourceProvider; extern "C" void Bun__addBakeSourceProviderSourceMap(void* bun_vm, SourceProvider* opaque_source_provider, const BunString* specifier); +extern "C" void Bun__removeBakeSourceProviderSourceMap(void* bun_vm, SourceProvider* opaque_source_provider, const BunString* specifier); class SourceProvider final : public JSC::StringSourceProvider { public: @@ -50,6 +51,13 @@ class SourceProvider final : public JSC::StringSourceProvider { { } + // Runs during JSC's sweep, when the global object may already be swept, so use the saved `m_bunVM`. + ~SourceProvider() + { + auto specifier = Bun::toString(sourceURL()); + Bun__removeBakeSourceProviderSourceMap(m_bunVM, this, &specifier); + } + void* m_bunVM; }; diff --git a/src/runtime/bake/config/framework.rs b/src/runtime/bake/config/framework.rs index edec4ee5fe34..67678864f534 100644 --- a/src/runtime/bake/config/framework.rs +++ b/src/runtime/bake/config/framework.rs @@ -81,7 +81,7 @@ pub(super) fn resolve_path( r: &mut bun_resolver::Resolver, path: &[u8], ) -> Result, bun_resolver::Error> { - let top_level_dir = bun_resolver::fs::FileSystem::get().top_level_dir; + let top_level_dir = bun_resolver::fs::FileSystem::get().top_level_dir(); let result = r.resolve(top_level_dir, path, bun_ast::ImportKind::Stmt)?; // No path: disabled, by a "browser" map for example. let text = result @@ -129,7 +129,7 @@ impl Framework { .map(|rfr| bt::ReactFastRefresh { import_source: rfr.import_source.as_ref().into(), }); - let top_level_dir = bun_resolver::fs::FileSystem::get().top_level_dir; + let top_level_dir = bun_resolver::fs::FileSystem::get().top_level_dir(); let client_page_dirs = self .file_system_router_types .iter() @@ -149,7 +149,6 @@ impl Framework { built_in_modules, server_components, react_fast_refresh, - self.is_built_in_react, client_page_dirs, ) } @@ -181,15 +180,18 @@ impl Framework { b"server components runtime", ); } - for fsr in self.file_system_router_types.iter_mut() { - let top_level_dir = bun_resolver::fs::FileSystem::get().top_level_dir; - fsr.root = Utf8Bytes::Owned( - bun_paths::resolve_path::join_abs::( - top_level_dir, - &fsr.root, - ) - .to_vec(), - ); + for (i, fsr) in self.file_system_router_types.iter_mut().enumerate() { + match resolve_dir_option(&fsr.root) { + Some(root) => fsr.root = Utf8Bytes::Owned(root), + None => { + bun_core::Output::err( + "ENAMETOOLONG", + "Failed to resolve 'fileSystemRouterTypes[{}].root' for framework", + (i,), + ); + had_errors = true; + } + } if let Some(entry_client) = &mut fsr.entry_client { Self::resolve_helper( &self.built_in_modules, @@ -245,6 +247,18 @@ impl Framework { } } +/// Joins a directory option onto the working directory. +/// Returns `None` if the result is too long for `Resolver::read_dir_info`. +pub(crate) fn resolve_dir_option(dir: &[u8]) -> Option> { + let mut buf = bun_paths::path_buffer_pool::get(); + let resolved = bun_paths::resolve_path::join_abs_string_buf_checked::( + bun_resolver::fs::FileSystem::get().top_level_dir(), + &mut buf[..bun_paths::MAX_PATH_BYTES - 1], + &[dir], + )?; + Some(bun_paths::string_paths::without_trailing_slash_windows_path(resolved).to_vec()) +} + impl Framework { pub(crate) fn from_js( opts: JSValue, diff --git a/src/runtime/bake/config/mod.rs b/src/runtime/bake/config/mod.rs index 4748d2821a28..835fec3ea2ff 100644 --- a/src/runtime/bake/config/mod.rs +++ b/src/runtime/bake/config/mod.rs @@ -115,7 +115,14 @@ impl UserOptions { )?; let root = if let Some(slice) = config.get_optional_slice(global, "root")? { - ZBox::from_bytes(slice.slice()) + // Make the root absolute because the dev server strips it off absolute file paths. + match framework::resolve_dir_option(slice.slice()) { + Some(root) => ZBox::from_vec(root), + None => { + return Err(global + .throw_invalid_arguments(format_args!("'{}.root' is too long", API_NAME))); + } + } } else { match bun_sys::getcwd_alloc() { Ok(z) => z, diff --git a/src/runtime/bake/dev_server/bundle.rs b/src/runtime/bake/dev_server/bundle.rs index 56c625c432ed..4d89a6307d11 100644 --- a/src/runtime/bake/dev_server/bundle.rs +++ b/src/runtime/bake/dev_server/bundle.rs @@ -93,24 +93,34 @@ impl DevServer { let _g = self.graph_safety_lock.guard(); macro_rules! check { - ($g:expr) => {{ + ($g:expr, |$file:ident| $bundled_for_side:expr) => {{ let g = $g; let index = g.bundled_files.get_index(path)?; - if !g.stale_files.is_set(index) { - return Some(CacheEntry { - kind: g - .get_file_by_index(incremental_graph::FileIndex::init( - u32::try_from(index).expect("int cast"), - )) - .file_kind(), - }); + if g.stale_files.is_set(index) { + return None; } - return None; + let $file = g.get_file_by_index(incremental_graph::FileIndex::init( + u32::try_from(index).expect("int cast"), + )); + if !$bundled_for_side { + return None; + } + return Some(CacheEntry { + kind: $file.file_kind(), + }); }}; } match side { - bake::Graph::Client => check!(&self.client_graph), - bake::Graph::Server | bake::Graph::Ssr => check!(&self.server_graph), + bake::Graph::Client => check!(&self.client_graph, |_file| true), + // The server and SSR graphs share one record per file. Only a JS record tracks which graph bundled it. + bake::Graph::Server | bake::Graph::Ssr => check!(&self.server_graph, |file| { + file.file_kind() != FileKind::Js + || if side == bake::Graph::Ssr { + file.is_ssr + } else { + file.is_rsc + } + }), } } @@ -266,16 +276,28 @@ impl DevServer { pub(crate) fn on_plugins_rejected(&mut self) -> crate::Result<()> { self.plugin_state = PluginState::Err; + // Completing the last request on a stopped server frees `self`, so count as a pending request until the end. + let mut server = self.server; + if let Some(server) = server.as_mut() { + server.on_pending_request(); + } while let Some(item) = self.next_bundle.requests.pop_first() { // SAFETY: `pop_first` returns a valid `*mut Node`; // `data` was initialized by `defer_request`. unsafe { let d = (*item).data.assume_init_mut(); - d.abort(); - d.deref_(); + d.abort_and_deref(); } } + // Reset these routes so their next request gets the plugin error. + for &route_bundle_index in self.next_bundle.route_queue.keys() { + self.route_bundles[route_bundle_index.get() as usize].server_state = + route_bundle::State::Unqueued; + } self.next_bundle.route_queue.clear_retaining_capacity(); + if let Some(server) = server.as_mut() { + server.on_static_request_complete(); + } // TODO: allow recovery from this state Ok(()) } @@ -448,6 +470,7 @@ impl DevServer { start_data, had_reload_event, requests: ::core::mem::take(&mut self.next_bundle.requests), + script_requests: Vec::new(), resolution_failure_entries: Default::default(), }); @@ -540,6 +563,29 @@ impl DevServer { .expect("oom"); } + // The bundle that just finished may have already bundled every file in the queued framework + // routes. Waiting requests are only answered when a bundle finishes, so bundle their pages again. + if entry_points.set.is_empty() { + for i in 0..self.next_bundle.route_queue.len() { + let route_bundle_index = self.next_bundle.route_queue.keys()[i]; + if let route_bundle::Data::Framework(bundle) = + &self.route_bundles[route_bundle_index.get() as usize].data + { + let route = self.router.route_ptr(bundle.route_index); + let file = route.file_page.unwrap_or_else(|| { + self.router.type_ptr_const(route.r#type).server_file + }); + let file_index = from_opaque_file_id::<{ bake::Side::Server }>(file); + self.server_graph.stale_files.set(file_index.get() as usize); + } + self.append_route_entry_points_if_not_stale( + &mut entry_points, + route_bundle_index, + ) + .expect("oom"); + } + } + if !entry_points.set.is_empty() { self.start_async_bundle(entry_points, is_reload, timer) .expect("oom"); diff --git a/src/runtime/bake/dev_server/deferred_request.rs b/src/runtime/bake/dev_server/deferred_request.rs index 13904cdf2115..8121e880bc72 100644 --- a/src/runtime/bake/dev_server/deferred_request.rs +++ b/src/runtime/bake/dev_server/deferred_request.rs @@ -61,6 +61,18 @@ impl DeferredRequest { } } + /// Ends the response before the deref because until then uWS has an abort callback that points to this node. + pub(super) fn abort_and_deref(&mut self) { + if let Handler::ServerHandler(saved) = &self.handler { + // Releases `prepare_and_save_js_request_context`'s ref. `deref_` releases + // `defer_request`'s. + saved.ctx.end_with_internal_server_error(); + } else { + self.abort(); + } + self.deref_(); + } + pub(crate) fn weak_ref(&mut self) { debug_assert!(!self.weakly_referenced_by_requestcontext); self.weakly_referenced_by_requestcontext = true; @@ -136,7 +148,7 @@ impl DeferredRequest { ); let handler = ::core::mem::replace(&mut self.handler, Handler::Aborted); match handler { - Handler::ServerHandler(saved) => { + Handler::ServerHandler(mut saved) => { debug_log_dr!( " request url: {}", // SAFETY: saved.request is a live *mut webcore::Request (held strong by ctx) @@ -145,8 +157,8 @@ impl DeferredRequest { saved .ctx .set_signal_aborted(jsc::CommonAbortReason::ConnectionClosed); - // Note: saved.js_request (jsc::Strong) drops at end of arm - drop(saved); + // Releases `defer_request`'s ref. The context's abort handler has its own. + saved.deinit(); } Handler::BundledHtmlPage(r) => { // Reached from JS event-loop tasks (on_plugins_rejected, the @@ -167,3 +179,14 @@ pub(crate) struct ResponseAndMethod { pub response: AnyResponse, pub method: Method, } + +/// A client script request waiting for the in-flight bundle to finish, even if that bundle doesn't include its route. +#[derive(bun_ptr::CellRefCounted)] +pub(crate) struct DeferredScriptRequest { + pub(super) ref_count: ::core::cell::Cell, + pub(super) route_bundle_index: route_bundle::Index, + /// Parsed from the request URL. + pub(super) generation: u32, + /// `None` once the client has disconnected. + pub(super) response: ::core::cell::Cell>, +} diff --git a/src/runtime/bake/dev_server/directory_watch_store.rs b/src/runtime/bake/dev_server/directory_watch_store.rs index 91fd1c56d50b..c593965cfe40 100644 --- a/src/runtime/bake/dev_server/directory_watch_store.rs +++ b/src/runtime/bake/dev_server/directory_watch_store.rs @@ -172,11 +172,16 @@ impl DirectoryWatchStore { } let mut buf = bun_paths::path_buffer_pool::get(); - let joined = bun_paths::resolve_path::join_abs_string_buf::( - bun_paths::resolve_path::dirname::(import_source), - &mut buf.0, - &[specifier], - ); + let Some(joined) = + bun_paths::resolve_path::join_abs_string_buf_checked::( + bun_paths::resolve_path::dirname::(import_source), + &mut buf.0, + &[specifier], + ) + else { + // There's nothing to watch, like in `insert`'s NameTooLong case. + return Ok(()); + }; let dir = bun_paths::resolve_path::dirname::(joined); // The `import_source` parameter is not a stable string. Since the @@ -262,6 +267,17 @@ impl DirectoryWatchStore { if found_existing { let prev_first = Some(self.watches.values()[gop_index].first_dep); + // Every rebuild of a file reports its failed imports again. + let mut it = prev_first; + while let Some(index) = it { + let dep = &self.dependencies[index as usize]; + if dep.source_file_path.slice().as_ptr() == file_path.slice().as_ptr() + && dep.specifier == specifier_cloned + { + return Ok(()); + } + it = dep.next; + } let dep = self.append_dep_assume_capacity(Dep { next: prev_first, source_file_path: file_path, @@ -319,8 +335,8 @@ impl DirectoryWatchStore { // TODO: implement that. for now it ignores (BUN-10968) return Err(DirectoryWatchInsertError::Ignore); } - bun_sys::E::ENOTDIR => return Err(DirectoryWatchInsertError::Ignore), - _ => bun_core::todo_panic!("log watcher error"), + // The resolver already reported the failed import. + _ => return Err(DirectoryWatchInsertError::Ignore), }, } } diff --git a/src/runtime/bake/dev_server/ensure_route.rs b/src/runtime/bake/dev_server/ensure_route.rs index a8a4731c3500..d0f4f1b53ee7 100644 --- a/src/runtime/bake/dev_server/ensure_route.rs +++ b/src/runtime/bake/dev_server/ensure_route.rs @@ -147,7 +147,7 @@ pub(super) fn ensure_route_is_bundled( .as_ref() .expect("infallible: server bound") .get_or_load_plugins( - crate::server::ServePluginsCallback::DevServer(dev), + crate::server::ServePluginsCallback::DevServer, ); match load_result { crate::server::GetOrStartLoadResult::Pending => { @@ -403,13 +403,6 @@ fn check_route_failures( resp: AnyResponse, ) -> crate::Result { let mut gts = dev.init_graph_trace_state(0)?; - // Note: erase to a raw pointer so the deferred cleanup only fires on - // scope exit when no other borrow of `dev` is live. - let dev_ptr = std::ptr::from_mut::(dev); - scopeguard::defer! { - // SAFETY: see Note above. - unsafe { (*dev_ptr).incremental_result.failures_added.clear() } - }; let _lock_guard = dev.graph_safety_lock.guard(); let route_bundle = std::ptr::from_mut::(dev.route_bundle_ptr(route_bundle_index)); // SAFETY: `trace_all_route_imports` reads `route_bundle.data` but never @@ -421,7 +414,7 @@ fn check_route_failures( &mut gts, TraceImportGoal::FindErrors, )?; - if !dev.incremental_result.failures_added.is_empty() { + if !gts.failures.is_empty() { // See comment on this field for information if !dev.assume_perfect_incremental_bundling { // Cache bust EVERYTHING reachable @@ -440,11 +433,7 @@ fn check_route_failures( return Ok(CheckResult::Rebuild); } - // SAFETY: `send_serialized_failures` does not mutate - // `incremental_result.failures_added`; reborrow through raw ptr to - // satisfy borrowck. - let failures = unsafe { &(*dev_ptr).incremental_result.failures_added }; - dev.send_serialized_failures(resp, failures, None)?; + dev.send_serialized_failures(resp, >s.failures, None)?; Ok(CheckResult::Stop) } else { // Failures are unreachable by this route, so it is OK to load. diff --git a/src/runtime/bake/dev_server/failures.rs b/src/runtime/bake/dev_server/failures.rs index ece4682e31e9..83cc888e58f8 100644 --- a/src/runtime/bake/dev_server/failures.rs +++ b/src/runtime/bake/dev_server/failures.rs @@ -136,12 +136,10 @@ impl DevServer { bake::Side::Client => self.client_graph.insert_failure( incremental_graph::InsertFailureKey::Index(index), log, - false, )?, bake::Side::Server => self.server_graph.insert_failure( incremental_graph::InsertFailureKey::Index(index), log, - true, )?, } } @@ -197,7 +195,6 @@ impl DevServer { self.client_graph.insert_failure( incremental_graph::InsertFailureKey::Index(file_index.get()), &log, - false, )?; } Ok(()) @@ -351,30 +348,19 @@ impl DevServer { // `failed` is cleared by the next successful bundle. watch_for_route_file = !file.failed; self.client_graph.insert_failure( - incremental_graph::InsertFailureKey::AbsPath(abs_path), + incremental_graph::InsertFailureKey::AbsPath(abs_path, graph), log, - false, )?; } } } } else { + let key = incremental_graph::InsertFailureKey::AbsPath(abs_path, graph); match graph { - bake::Graph::Server => self.server_graph.insert_failure( - incremental_graph::InsertFailureKey::AbsPath(abs_path), - log, - false, - )?, - bake::Graph::Ssr => self.server_graph.insert_failure( - incremental_graph::InsertFailureKey::AbsPath(abs_path), - log, - true, - )?, - bake::Graph::Client => self.client_graph.insert_failure( - incremental_graph::InsertFailureKey::AbsPath(abs_path), - log, - false, - )?, + bake::Graph::Server | bake::Graph::Ssr => { + self.server_graph.insert_failure(key, log)? + } + bake::Graph::Client => self.client_graph.insert_failure(key, log)?, } } // `track_resolution_failure` takes the graph lock itself. diff --git a/src/runtime/bake/dev_server/finalize.rs b/src/runtime/bake/dev_server/finalize.rs index 4e841b9c417e..9895674ba86a 100644 --- a/src/runtime/bake/dev_server/finalize.rs +++ b/src/runtime/bake/dev_server/finalize.rs @@ -68,12 +68,28 @@ impl<'a> HotUpdateContext<'a> { fn finalize_bundle_cleanup(dev: &mut DevServer, bv2: &mut BundleV2, had_sent_hmr_event: bool) { bv2.deinit_without_freeing_arena(); + let script_requests = match &mut dev.current_bundle { + Some(current_bundle) => ::core::mem::take(&mut current_bundle.script_requests), + None => Vec::new(), + }; // Drops `CurrentBundle.heap` (the arena `bv2.graph.heap` borrows). dev.current_bundle = None; dev.log.clear_and_free(); let _ = dev.assets.reindex_if_needed(); // not fatal + // Answer these before `start_next_bundle_if_present` below, or they'd wait for that bundle too. + for deferred in script_requests { + if let Some(ResponseAndMethod { response, method }) = deferred.response.take() { + dev.serve_client_script( + deferred.route_bundle_index, + deferred.generation, + response, + method, + ); + } + } + // Signal for testing framework where it is in synchronization if matches!( dev.testing_batch_events, @@ -118,7 +134,7 @@ fn finalize_bundle_cleanup(dev: &mut DevServer, bv2: &mut BundleV2, had_sent_hmr } } -fn drain_current_bundle_requests(current_bundle: &mut CurrentBundle) { +pub(super) fn drain_current_bundle_requests(current_bundle: &mut CurrentBundle) { if !current_bundle.requests.first.is_null() { // cannot be an assertion because in the case of OOM, the request list was not drained. bun_core::debug!( @@ -129,8 +145,7 @@ fn drain_current_bundle_requests(current_bundle: &mut CurrentBundle) { // SAFETY: pop_first returns a live `*mut Node`; `data` was // initialized by `defer_request`. let req = unsafe { (*node).data.assume_init_mut() }; - req.abort(); - req.deref_(); + req.abort_and_deref(); } } @@ -253,6 +268,7 @@ pub(super) fn finalize_bundle( let mut gts_storage = GraphTraceState { server_bits: DynamicBitSet::default(), client_bits: DynamicBitSet::default(), + failures: Vec::new(), }; let mut ctx = HotUpdateContext { import_records, @@ -279,6 +295,39 @@ pub(super) fn finalize_bundle( .zip(js_chunk.compile_results_for_chunk.iter()) { let index = part_range.source_index; + // Report a part that failed to print as its file's failure, like a parse error. + if let bundler::CompileResult::Javascript { + result: bun_js_printer::PrintResult::Err(err), + .. + } = compile_result + { + let source = &ctx.sources[index.get() as usize]; + let mut log = Log::init(); + match err { + bun_js_printer::Error::StackOverflow => log.add_error( + Some(source), + bun_ast::Loc::EMPTY, + "Maximum call stack size exceeded while generating code for this file", + ), + err => log.add_error_fmt( + Some(source), + bun_ast::Loc::EMPTY, + format_args!("Failed to generate code for this file ({})", err.name()), + ), + } + let graph = targets[index.get() as usize].bake_graph(); + let key = incremental_graph::InsertFailureKey::AbsPath( + source.path.key_for_incremental_graph(), + graph, + ); + match graph { + bake::Graph::Client => dev.client_graph.insert_failure(key, &log)?, + bake::Graph::Server | bake::Graph::Ssr => { + dev.server_graph.insert_failure(key, &log)? + } + } + continue; + } let source_map: bun_sourcemap::Chunk = match compile_result.source_map_chunk() { Some(c) => c.clone(), None => 'brk: { @@ -344,6 +393,54 @@ pub(super) fn finalize_bundle( let index = bun_ast::Index::init(chunk.entry_point.source_index()); + // Report a chunk that failed to print as its entry point's failure. The next + // good build's `receive_chunk` clears it. + let entry_source = &ctx.sources[index.get() as usize]; + let mut log = Log::init(); + for compile_result in chunk.compile_results_for_chunk.iter() { + let (err, err_source_index) = match compile_result { + bundler::CompileResult::Css { + result: Err(err), + source_index, + .. + } => (err, *source_index), + _ => continue, + }; + let err_source = if err_source_index != bun_ast::Index::INVALID.get() { + &ctx.sources[err_source_index as usize] + } else { + entry_source + }; + log.add_error_fmt( + Some(err_source), + bun_ast::Loc::EMPTY, + format_args!("Failed to generate CSS for this file ({})", err.name()), + ); + } + if log.errors > 0 { + let entry_key = entry_source.path.key_for_incremental_graph(); + dev.client_graph.insert_failure( + incremental_graph::InsertFailureKey::AbsPath(entry_key, bake::Graph::Client), + &log, + )?; + let file_index = dev + .client_graph + .get_file_index(entry_key) + .expect("insert_failure registered the entry"); + // Pass 2 needs the index to diff the `@import` edges. Editing an imported + // file follows those edges to rebundle this chunk. + *ctx.get_cached_index(bake::Side::Client, index) = + CachedFileIndex::from(Some(file_index)); + dev.client_graph + .restore_failed_css_root(file_index, hash(entry_key)); + // This lets a server route's trace reach the failed client file. + if metadata.imported_on_server { + dev.server_graph + .insert_css_file_on_server(&mut ctx, index, entry_key)?; + } + continue; + } + // Note: `IntermediateOutput::code` takes `&mut self` (a field of // `*chunk`) plus `chunk: &Chunk` and `chunks: &[Chunk]`; borrowck still // rejects the overlap (`&mut chunk.intermediate_output` aliases the @@ -500,7 +597,23 @@ pub(super) fn finalize_bundle( // Pass 2, update the graph's edges by performing import diffing on each // changed file, removing dependencies. This pass also flags what routes // have been modified. - for part_range in js_chunk.content.javascript().parts_in_chunk_in_order.iter() { + for (part_range, compile_result) in js_chunk + .content + .javascript() + .parts_in_chunk_in_order + .iter() + .zip(js_chunk.compile_results_for_chunk.iter()) + { + // Pass 1 didn't resolve a file index for a part that failed to print. + if matches!( + compile_result, + bundler::CompileResult::Javascript { + result: bun_js_printer::PrintResult::Err(_), + .. + } + ) { + continue; + } match targets[part_range.source_index.get() as usize].bake_graph() { bake::Graph::Server | bake::Graph::Ssr => dev.server_graph.process_chunk_dependencies( &mut ctx, @@ -865,14 +978,27 @@ pub(super) fn finalize_bundle( w_int!(i32, -1); let css_chunks = &*css_chunks_mut; + // If a chunk failed to print, the page keeps its last good stylesheet. + let css_chunk_printed = |chunk: &bundler::chunk::Chunk| { + !chunk + .compile_results_for_chunk + .iter() + .any(|compile_result| { + matches!( + compile_result, + bundler::CompileResult::Css { result: Err(_), .. } + ) + }) + }; + let printed_css_count = css_chunks.iter().filter(|c| css_chunk_printed(c)).count(); if will_hear_hot_update { - if dev.client_graph.current_chunk_len > 0 || !css_chunks.is_empty() { + if dev.client_graph.current_chunk_len > 0 || printed_css_count > 0 { // Send CSS mutations dev.assets.reindex_if_needed()?; - w_int!(u32, u32::try_from(css_chunks.len()).expect("int cast")); + w_int!(u32, u32::try_from(printed_css_count).expect("int cast")); use bun_bundler::Graph::InputFileColumns as _; let sources = bv2.graph.input_files.items_source(); - for chunk in css_chunks { + for chunk in css_chunks.iter().filter(|c| css_chunk_printed(c)) { let key = sources[chunk.entry_point.source_index() as usize] .path .key_for_incremental_graph(); diff --git a/src/runtime/bake/dev_server/http.rs b/src/runtime/bake/dev_server/http.rs index a89a0ca3c963..e31a553f2510 100644 --- a/src/runtime/bake/dev_server/http.rs +++ b/src/runtime/bake/dev_server/http.rs @@ -257,7 +257,7 @@ fn on_not_found_corked(resp: AnyResponse) { resp.end(b"Not Found", false); } -fn on_outdated_js_corked(resp: AnyResponse) { +pub(super) fn on_outdated_js_corked(resp: AnyResponse) { // Send a payload to instantly reload the page. This only happens when the // client bundle is invalidated while the page is loading, aka when you // perform many file updates that cannot be hot-updated. @@ -318,23 +318,18 @@ fn on_js_request(dev: &mut DevServer, req: &mut Request, resp: AnyResponse) { return; } - let route_bundle_index = - route_bundle::Index::init(u32::try_from(id & 0xFFFFFFFF).expect("int cast")); - let generation: u32 = u32::try_from(id >> 32).expect("int cast"); + let index = id as u32; + let generation = (id >> 32) as u32; - if route_bundle_index.get() as usize >= dev.route_bundles.len() { + // Check the range first because `Index::init` asserts on `u32::MAX`, which the URL can contain. + if index as usize >= dev.route_bundles.len() { return not_found(resp); } + let route_bundle_index = route_bundle::Index::init(index); - let route_bundle = &dev.route_bundles[route_bundle_index.get() as usize]; - if route_bundle.client_script_generation != generation - || route_bundle.server_state != route_bundle::State::Loaded - { - return resp.corked(move || on_outdated_js_corked(resp)); - } - - dev.on_js_request_with_bundle( + dev.serve_client_script( route_bundle_index, + generation, resp, Method::which(req.method()).unwrap_or(Method::POST), ); @@ -376,7 +371,8 @@ fn on_src_request(_dev: &mut DevServer, req: &mut Request, resp: AnyResponse) { fn on_request(dev: &mut DevServer, req: &mut Request, mut resp: AnyResponse) -> JsResult<()> { let mut params: router::MatchedParams = Default::default(); - if let Some(route_index) = dev.router.match_slow(req.url(), &mut params) { + let pathname = extract_pathname_from_url(req.url()); + if let Some(route_index) = dev.router.match_slow(pathname, &mut params) { let route_bundle_index = dev .get_or_put_route_bundle(route_bundle::UnresolvedIndex::Framework(route_index)) .expect("oom"); @@ -509,27 +505,19 @@ impl UnrefSourceMapRequest { } } -// TODO: this is shitty +/// Returns the pathname of a URL or HTTP request target, without the query string or hash. +/// Returns a target that doesn't have a path (`CONNECT host:port`, `OPTIONS *`) unchanged. pub(super) fn extract_pathname_from_url(url: &[u8]) -> &[u8] { - // Extract pathname from URL (remove protocol, host, query, hash) - let mut pathname = if let Some(proto_end) = strings::index_of(url, b"://") { - &url[proto_end + 3..] - } else { + let pathname = if url.first() == Some(&b'/') { url + } else if let Some(scheme_end) = strings::index_of(url, b"://") { + let after_scheme = &url[scheme_end + b"://".len()..]; + match strings::index_of_any(after_scheme, b"/?#") { + Some(path_start) if after_scheme[path_start] == b'/' => &after_scheme[path_start..], + _ => b"/", + } + } else { + return url; }; - - if let Some(path_start) = strings::index_of_char(pathname, b'/') { - let path_with_query = &pathname[path_start as usize..]; - // Remove query string and hash - let query_index = strings::index_of_char(path_with_query, b'?') - .map(|i| i as usize) - .unwrap_or(path_with_query.len()); - let hash_index = strings::index_of_char(path_with_query, b'#') - .map(|i| i as usize) - .unwrap_or(path_with_query.len()); - let end = query_index.min(hash_index); - pathname = &path_with_query[..end]; - } - - pathname + &pathname[..strings::index_of_any(pathname, b"?#").unwrap_or(pathname.len())] } diff --git a/src/runtime/bake/dev_server/incremental_graph.rs b/src/runtime/bake/dev_server/incremental_graph.rs index e505e125f37a..bf1ccad46e68 100644 --- a/src/runtime/bake/dev_server/incremental_graph.rs +++ b/src/runtime/bake/dev_server/incremental_graph.rs @@ -83,6 +83,7 @@ pub(crate) enum Content { /// When stale, the code is "", otherwise it contains at least one /// non-whitespace character (empty chunks contain a function wrapper). Js(Box<[u8]>), + /// A module whose loader isn't JavaScript-like. Only copied files are in `DevServer::assets`. Asset(Box<[u8]>), /// First file in a CSS bundle (the one HTML/JS points into). Re-bundles /// of any downstream `CssChild` re-queue the root. @@ -136,6 +137,13 @@ impl File { self.kind } + /// True if a rebuild must enqueue this file for the SSR graph. Parsing a client component + /// boundary for the server already queues its SSR copy. + #[inline] + fn rebundles_for_ssr_graph(&self) -> bool { + self.is_ssr && !self.is_client_component_boundary + } + /// `ServerFile.stopsDependencyTrace` / `ClientFile.stopsDependencyTrace`. #[inline] fn stops_dependency_trace(&self, side: Side) -> bool { @@ -216,9 +224,10 @@ pub(crate) enum RouteKind { #[derive(Copy, Clone)] pub(crate) enum InsertFailureKey<'a> { - AbsPath(&'a [u8]), + /// A file that may not be in the graph yet, plus the graph the failed task was queued for. + AbsPath(&'a [u8], bake::Graph), /// Raw file index into `bundled_files` (side is implied by the graph the - /// caller is invoking `insert_failure` on). + /// caller is invoking `insert_failure` on). The record keeps its graph flags. Index(u32), } @@ -905,11 +914,13 @@ impl IncrementalGraph { // takes `&mut ctx`. let (flags, src, key) = { let ir = &ctx.import_records[index.get() as usize].as_slice()[i]; - ( - ir.flags, - ir.source_index, - ir.path.key_for_incremental_graph(), - ) + // If an `onResolve` plugin resolved the import, `ir.path` is still the specifier. + let path = if ir.source_index.is_valid() { + &ctx.sources[ir.source_index.get() as usize].path + } else { + &ir.path + }; + (ir.flags, ir.source_index, path.key_for_incremental_graph()) }; let _ = self.process_edge_attachment( ctx, @@ -1007,6 +1018,9 @@ impl IncrementalGraph { } else if mode == EdgeAttachmentMode::Css { let index = self.insert_empty(key, kind)?.index; ctx.gts.resize(SIDE, index.get() as usize + 1)?; + if kind == FileKind::Css { + self.receive_css_child(index); + } break 'brk (index, kind); } } @@ -1070,6 +1084,42 @@ impl IncrementalGraph { Ok(EdgeAttachmentResult::Continue) } + /// An `@import`ed stylesheet gets no `receive_chunk`, so the pass over its root's imports + /// keeps it a css child and clears a failure that this bundle did not repeat. + fn receive_css_child(&mut self, index: FileIndex) { + let file = &mut self.bundled_files.values_mut()[index.get() as usize]; + match file.content { + Content::Unknown => { + file.kind = FileKind::Css; + file.content = Content::CssChild; + } + Content::CssChild => {} + _ => return, + } + if !file.failed { + return; + } + let failed_in_this_bundle = |failure: &SerializedFailure| { + matches!( + failure.get_owner(), + serialized_failure::Owner::Client(owner) if owner.get() == index.get() + ) + }; + if self + .dev_incremental_result() + .failures_added + .iter() + .any(failed_in_this_bundle) + { + return; + } + self.bundled_files.values_mut()[index.get() as usize].failed = false; + let owner = serialized_failure::OwnerPacked::new(Side::Client, index.get()); + if let Some(kv) = self.dev_bundling_failures().fetch_swap_remove(&owner) { + self.dev_incremental_result().failures_removed.push(kv.1); + } + } + // ──────────────────────────────────────────────────────────────────────── // traceDependencies / traceImports // ──────────────────────────────────────────────────────────────────────── @@ -1228,22 +1278,42 @@ impl IncrementalGraph { .get(&owner) .cloned() .expect("Failed to get bundling failure"); - self.dev_incremental_result().failures_added.push(fail); + gts.failures.push(fail); } } Side::Client => { + // A failed css root keeps its `CssRoot` content, so check for failures before the css check. + if goal == TraceImportGoal::FindErrors + && self.bundled_files.values()[file_index.get() as usize].failed + { + let owner = + serialized_failure::OwnerPacked::new(Side::Client, file_index.get()); + let fail = self + .dev_bundling_failures() + .get(&owner) + .cloned() + .expect("Failed to get bundling failure"); + gts.failures.push(fail); + return Ok(()); + } + { let f = &self.bundled_files.values()[file_index.get() as usize]; match &f.content { Content::CssChild => { - debug_assert!(false, "only CSS roots should be found by tracing"); + debug_assert!( + goal == TraceImportGoal::FindErrors, + "only CSS roots should be found by tracing" + ); } Content::CssRoot(id) => { if goal == TraceImportGoal::FindCss { self.current_css_files.push(*id); } - // CSS can't import JS; trace is done. - return Ok(()); + // CSS can't import JS, but an `@import` can fail to parse. + if goal != TraceImportGoal::FindErrors { + return Ok(()); + } } _ => {} } @@ -1258,20 +1328,6 @@ impl IncrementalGraph { self.current_chunk_parts.push(file_index); self.current_chunk_len += len; } - - if goal == TraceImportGoal::FindErrors - && self.bundled_files.values()[file_index.get() as usize].failed - { - let owner = - serialized_failure::OwnerPacked::new(Side::Client, file_index.get()); - let fail = self - .dev_bundling_failures() - .get(&owner) - .cloned() - .expect("Failed to get bundling failure"); - self.dev_incremental_result().failures_added.push(fail); - return Ok(()); - } } } @@ -1333,8 +1389,23 @@ impl IncrementalGraph { // Note: re-derive owned key via `RawSlice` so // `free_file_content` can borrow `&mut self`. let key = bun_ptr::RawSlice::new(&*self.bundled_files.keys()[idx]); - self.free_file_content(key.slice(), &mut existing, FreeCssMode::UnrefCss); - existing.kind = FileKind::Unknown; + let prior_css_root = match existing.content { + Content::CssRoot(id) => Some(id), + _ => None, + }; + // The rebuild replaces a css root's asset (`replace_path`) or drops it (`insert_failure`). + let css_mode = if prior_css_root.is_some() { + FreeCssMode::IgnoreCss + } else { + FreeCssMode::UnrefCss + }; + self.free_file_content(key.slice(), &mut existing, css_mode); + if let Some(id) = prior_css_root { + existing.kind = FileKind::Css; + existing.content = Content::CssRoot(id); + } else { + existing.kind = FileKind::Unknown; + } self.bundled_files.values_mut()[idx] = existing; } else { self.bundled_files.values_mut()[idx] = File::default(); @@ -1396,6 +1467,7 @@ impl IncrementalGraph { if !found_existing { self.edge_lists.push(EdgeLists::default()); self.ensure_stale_bit_capacity(true)?; + self.stale_files.set(idx); } Ok(InsertEmptyResult { index: FileIndex::init(idx as u32), @@ -1431,15 +1503,24 @@ impl IncrementalGraph { Ok(()) } + /// Makes the entry point of a chunk that failed to print a css root again, so a trace + /// still finds the stylesheet. `id` is the hash of its path. + pub(crate) fn restore_failed_css_root(&mut self, index: FileIndex, id: u64) { + debug_assert!(matches!(SIDE, Side::Client)); + let f = &mut self.bundled_files.values_mut()[index.get() as usize]; + debug_assert!(f.failed); + f.kind = FileKind::Css; + f.content = Content::CssRoot(id); + } + /// `IncrementalGraph(side).insertFailure` (spec :1419). pub(crate) fn insert_failure( &mut self, key: InsertFailureKey<'_>, log: &bun_ast::Log, - is_ssr_graph: bool, ) -> Result<(), bun_alloc::AllocError> { - let (idx, found_existing) = match key { - InsertFailureKey::AbsPath(abs_path) => { + let (idx, found_existing, parsed_for) = match key { + InsertFailureKey::AbsPath(abs_path, graph) => { let gop = self.bundled_files.get_or_put(abs_path)?; if !gop.found_existing { *gop.key_ptr = Box::<[u8]>::from(abs_path); @@ -1448,9 +1529,9 @@ impl IncrementalGraph { if !fe { self.edge_lists.push(EdgeLists::default()); } - (i, fe) + (i, fe, Some(graph)) } - InsertFailureKey::Index(i) => (i as usize, true), + InsertFailureKey::Index(i) => (i as usize, true, None), }; self.ensure_stale_bit_capacity(true)?; self.stale_files.set(idx); @@ -1460,9 +1541,19 @@ impl IncrementalGraph { if found_existing { let mut existing = core::mem::take(&mut self.bundled_files.values_mut()[idx]); let key = bun_ptr::RawSlice::new(&*self.bundled_files.keys()[idx]); + let prior_css_root = match existing.content { + Content::CssRoot(id) => Some(id), + _ => None, + }; self.free_file_content(key.slice(), &mut existing, FreeCssMode::UnrefCss); existing.failed = true; - existing.kind = FileKind::Unknown; + if let Some(id) = prior_css_root { + // Keep it a css root so a trace still finds the stylesheet. + existing.kind = FileKind::Css; + existing.content = Content::CssRoot(id); + } else { + existing.kind = FileKind::Unknown; + } self.bundled_files.values_mut()[idx] = existing; } else { self.bundled_files.values_mut()[idx] = File { @@ -1473,21 +1564,15 @@ impl IncrementalGraph { } Side::Server => { if !found_existing { - self.bundled_files.values_mut()[idx] = File { - failed: true, - is_rsc: !is_ssr_graph, - is_ssr: is_ssr_graph, - ..Default::default() - }; - } else { - let f = &mut self.bundled_files.values_mut()[idx]; - if is_ssr_graph { - f.is_ssr = true; - } else { - f.is_rsc = true; - } - f.failed = true; + self.bundled_files.values_mut()[idx] = File::default(); + } + let f = &mut self.bundled_files.values_mut()[idx]; + match parsed_for { + Some(bake::Graph::Ssr) => f.is_ssr = true, + Some(_) => f.is_rsc = true, + None => {} } + f.failed = true; } } @@ -1557,25 +1642,35 @@ impl IncrementalGraph { } // Rebuild all dependencies. - let target = match SIDE { - Side::Client => bun_ast::Target::Browser, - Side::Server => bun_ast::Target::Bun, - }; let mut it = self.edge_lists[index.get() as usize].first_dep; while let Some(edge_index) = it { let dep = self.edges[edge_index.get() as usize]; it = dep.next_dependency; debug_assert_eq!(dep.imported.get(), index.get()); let key = &self.bundled_files.keys()[dep.dependency.get() as usize]; - let loader = self.bundled_files.values()[dep.dependency.get() as usize] + let file = &self.bundled_files.values()[dep.dependency.get() as usize]; + let loader = file .html_route_bundle_index .is_some() .then_some(bun_ast::Loader::Html); - bun_core::handle_oom( - bv2.enqueue_file_from_dev_server_incremental_graph_invalidation( - key, target, loader, - ), - ); + let mut enqueue = |target| { + bun_core::handle_oom( + bv2.enqueue_file_from_dev_server_incremental_graph_invalidation( + key, target, loader, + ), + ) + }; + match SIDE { + Side::Client => enqueue(bun_ast::Target::Browser), + Side::Server => { + if file.is_rsc { + enqueue(bun_ast::Target::Bun); + } + if file.rebundles_for_ssr_graph() { + enqueue(bun_ast::Target::ServerComponentsSsr); + } + } + } } // Bust the resolution cache of the dir containing this file. @@ -1685,7 +1780,7 @@ impl IncrementalGraph { if f.is_rsc { entry_points.append_js(owned_path, bake::Graph::Server)?; } - if f.is_ssr && !f.is_client_component_boundary { + if f.rebundles_for_ssr_graph() { entry_points.append_js(owned_path, bake::Graph::Ssr)?; } } diff --git a/src/runtime/bake/dev_server/lifecycle.rs b/src/runtime/bake/dev_server/lifecycle.rs index e1f419128292..600d080653c9 100644 --- a/src/runtime/bake/dev_server/lifecycle.rs +++ b/src/runtime/bake/dev_server/lifecycle.rs @@ -143,7 +143,7 @@ pub(crate) fn init(options: Options) -> JsResult> { Ok(fs) => fs, Err(err) => return Err(global.throw_error(err, generic_action)), }; - let top_level_dir: &'static [u8] = bun_resolver::fs::FileSystem::get().top_level_dir; + let top_level_dir: &'static [u8] = bun_resolver::fs::FileSystem::get().top_level_dir(); // `.bun_watcher = undefined` → `Watcher.init(DevServer, dev, fs, ...)` // SAFETY: `Watcher::init` only stores `p` as an opaque `*mut ()` ctx; it does @@ -457,10 +457,9 @@ pub(crate) fn init(options: Options) -> JsResult> { // `framework` iter does not lock `*dev`. // SAFETY: `dev_ptr` is the live `Box` heap address; `framework` // is read-only here and disjoint from the fields mutated in the loop body. - for (i, fsr) in unsafe { &(*dev_ptr).framework } + for fsr in unsafe { &(*dev_ptr).framework } .file_system_router_types .iter() - .enumerate() { let mut buf = paths::path_buffer_pool::get(); let joined_root = paths::resolve_path::join_abs_string_buf::( @@ -484,6 +483,8 @@ pub(crate) fn init(options: Options) -> JsResult> { incremental_graph::RouteKind::Route, )?; + // Types with a missing root were skipped above, so this can differ from the index in the config. + let type_index = router::TypeIndex::init(u8::try_from(types.len()).expect("int cast")); types.push(router::Type { abs_root: strings::without_trailing_slash(entry.abs_path).into(), ignore_underscores: fsr.ignore_underscores, @@ -516,10 +517,7 @@ pub(crate) fn init(options: Options) -> JsResult> { // SAFETY: `route_lookup` is disjoint from `framework`. unsafe { &mut (*dev_ptr).route_lookup }.put( server_file, - RouteIndexAndRecurseFlag::new( - router::RouteIndex::init(u32::try_from(i).expect("int cast")), - true, - ), + RouteIndexAndRecurseFlag::new(router::Type::root_route_index(type_index), true), )?; } @@ -639,22 +637,18 @@ impl Drop for DevServer { // The map's `Drop` runs `SerializedFailure::drop` for each value. - if self.current_bundle.is_some() { + if let Some(current_bundle) = &mut self.current_bundle { debug_assert!(false); // impossible to de-initialize this state correctly. + super::finalize::drain_current_bundle_requests(current_bundle); } { - let mut r = self.next_bundle.requests.first; - while !r.is_null() { - // SAFETY: `r` is a live intrusive-list node linked by `defer_request`; - // read the link before `deref_` may reclaim the node. - let next = unsafe { (*r).next }; - // SAFETY: `data` was initialized by `defer_request` before being - // linked; this exclusive borrow ends at `deref_` below. + while let Some(r) = self.next_bundle.requests.pop_first() { + // SAFETY: `pop_first` returns a live node linked by `defer_request`, + // which initialized `data`. This exclusive borrow ends at `deref_`. let data = unsafe { (*r).data.assume_init_mut() }; debug_assert!(!matches!(data.handler, Handler::ServerHandler(_))); - data.deref_(); - r = next; + data.abort_and_deref(); } } diff --git a/src/runtime/bake/dev_server/memory_cost.rs b/src/runtime/bake/dev_server/memory_cost.rs index b46326f5a201..963a6f439991 100644 --- a/src/runtime/bake/dev_server/memory_cost.rs +++ b/src/runtime/bake/dev_server/memory_cost.rs @@ -219,11 +219,13 @@ pub(crate) fn memory_cost_detailed(dev: &DevServer) -> MemoryCost { for failure in dev.bundling_failures.values() { other_bytes += failure.data.len(); } - // All entries are owned by the bundler arena, not DevServer, except for `requests` + // All entries are owned by the bundler arena, not DevServer, except for the request lists // .current_bundle if let Some(bundle) = &dev.current_bundle { // `SinglyLinkedList::len()` is an O(N) walk; only the node count matters. other_bytes += bundle.requests.len() * size_of::(); + other_bytes += memory_cost_array_list(&bundle.script_requests); + other_bytes += bundle.script_requests.len() * size_of::(); } // .next_bundle { @@ -302,10 +304,14 @@ impl DevServer { crate::jsc_hooks::timer_all_mut() } - pub(crate) fn emit_memory_visualizer_message_timer( - _timer: &mut EventLoopTimer, - _: &bun_core::Timespec, - ) { + pub(crate) fn emit_memory_visualizer_message_timer(timer: *mut EventLoopTimer) { + // SAFETY: `timer` points to the `memory_visualizer_timer` field of a DevServer. + let dev: &mut DevServer = unsafe { &mut *DevServer::from_timer_ptr(timer) }; + debug_assert!(dev.magic == Magic::Valid); + dev.emit_memory_visualizer_message(); + dev.memory_visualizer_timer.state = EventLoopTimerState::FIRED; + let next = bun_core::Timespec::ms_from_now(bun_core::TimespecMockMode::ForceRealTime, 1000); + dev.timer_heap().update(timer, &next); } pub(crate) fn emit_memory_visualizer_message_if_needed(&mut self) {} diff --git a/src/runtime/bake/dev_server/mod.rs b/src/runtime/bake/dev_server/mod.rs index 722eefc56b66..8c6b010c3698 100644 --- a/src/runtime/bake/dev_server/mod.rs +++ b/src/runtime/bake/dev_server/mod.rs @@ -87,7 +87,7 @@ mod watcher_atomics; pub(super) use crate::server::SavedRequestUnion; pub(crate) use bundle::EntryPointList; use bundle::{RouteIndexAndRecurseFlag, TestingBatch}; -use deferred_request::{DeferredRequest, Handler, ResponseAndMethod}; +use deferred_request::{DeferredRequest, DeferredScriptRequest, Handler, ResponseAndMethod}; pub(crate) use directory_watch_store::DirectoryWatchStore; use ensure_route::{HTMLRouter, ReqOrSaved, RequestEnsureRouteBundledCtx, ensure_route_is_bundled}; use failures::mark_all_route_children; @@ -96,7 +96,7 @@ use hmr_socket::hmr_socket_behavior; pub(crate) use hot_reload_event::HotReloadEvent; use http::{ DevHandlerId, dev_route_tramp, extract_pathname_from_url, host_forbidden, - is_allowed_dev_origin, origin_forbidden, + is_allowed_dev_origin, on_outdated_js_corked, origin_forbidden, }; pub(crate) use http::{is_allowed_dev_host, is_allowed_host_header}; pub(crate) use lifecycle::init; @@ -181,6 +181,8 @@ impl IncrementalResult { pub(crate) struct GraphTraceState { pub(crate) client_bits: DynamicBitSet, pub(crate) server_bits: DynamicBitSet, + /// Filled by `TraceImportGoal::FindErrors`. + pub(crate) failures: Vec, } impl GraphTraceState { #[inline] @@ -194,6 +196,7 @@ impl GraphTraceState { pub(crate) fn clear(&mut self) { self.server_bits.unmanaged.set_all(false); self.client_bits.unmanaged.set_all(false); + self.failures.clear(); } pub(crate) fn resize(&mut self, side: Side, new_size: usize) -> Result<(), crate::Error> { @@ -211,6 +214,7 @@ impl GraphTraceState { self.server_bits .resize(0, false) .expect("freeing memory can not fail"); + self.failures = Vec::new(); } } @@ -441,6 +445,8 @@ pub(crate) struct CurrentBundle { /// calling their handler on success or sending the error page on failure. /// Owned by `deferred_request_pool` in DevServer. pub(crate) requests: deferred_request::List, + /// Answered by `finalize_bundle_cleanup`. + pub(crate) script_requests: Vec>, /// Resolution failures are grouped by incremental graph file index. /// Unlike parse failures (`handleParseTaskFailure`), the resolution /// failures can be created asynchronously, and out of order. diff --git a/src/runtime/bake/dev_server/render.rs b/src/runtime/bake/dev_server/render.rs index dd72a7f2dd51..131956fa2a15 100644 --- a/src/runtime/bake/dev_server/render.rs +++ b/src/runtime/bake/dev_server/render.rs @@ -237,7 +237,7 @@ impl DevServer { // Extract pathname from URL (remove protocol, host, query, hash) let pathname = extract_pathname_from_url(url.slice()); if self.router.match_slow(pathname, &mut params).is_some() { - params.to_js(global) + params.to_js(global)? } else { JSValue::NULL } @@ -498,7 +498,49 @@ impl DevServer { Ok(array.into_boxed_slice()) } - pub(crate) fn on_js_request_with_bundle( + /// Serves `/_bun/client/-.js`. + pub(super) fn serve_client_script( + &mut self, + bundle_index: route_bundle::Index, + generation: u32, + resp: AnyResponse, + method: Method, + ) { + let route_bundle = &self.route_bundles[bundle_index.get() as usize]; + if route_bundle.client_script_generation != generation + || route_bundle.server_state != route_bundle::State::Loaded + { + return resp.corked(move || on_outdated_js_corked(resp)); + } + + // The in-flight bundle already freed the code of every file that failed in it, so wait for it to finish. + if route_bundle.client_bundle.is_none() + && let Some(current_bundle) = &mut self.current_bundle + { + let deferred = bun_ptr::RefPtr::new(DeferredScriptRequest { + ref_count: ::core::cell::Cell::new(1), + route_bundle_index: bundle_index, + generation, + response: ::core::cell::Cell::new(Some(ResponseAndMethod { + response: resp, + method, + })), + }); + // `script_requests` keeps `deferred` alive until the response removes this handler. + resp.on_aborted_this( + |deferred: bun_ptr::ThisPtr, _: AnyResponse| { + deferred.response.set(None) + }, + deferred.this_ptr(), + ); + current_bundle.script_requests.push(deferred); + return; + } + + self.on_js_request_with_bundle(bundle_index, resp, method); + } + + fn on_js_request_with_bundle( &mut self, bundle_index: route_bundle::Index, resp: AnyResponse, diff --git a/src/runtime/bake/dev_server/trace.rs b/src/runtime/bake/dev_server/trace.rs index 5522f40cbd2a..f1306fb2aae9 100644 --- a/src/runtime/bake/dev_server/trace.rs +++ b/src/runtime/bake/dev_server/trace.rs @@ -15,6 +15,7 @@ impl DevServer { Ok(GraphTraceState { server_bits, client_bits, + failures: Vec::new(), }) } } diff --git a/src/runtime/bake/hmr-module.ts b/src/runtime/bake/hmr-module.ts index d2bdf9e893a6..e7b5378d6b59 100644 --- a/src/runtime/bake/hmr-module.ts +++ b/src/runtime/bake/hmr-module.ts @@ -58,6 +58,26 @@ export async function loadExports(id: Id): Promise { return m.esm ? m.exports : m.cjs.exports; } +/** + * Returns a module's export names without evaluating it, or `null` if that requires evaluation. + * `export * from` is lowered to a spread, so it forwards `default` too. + */ +export function staticExportNames(id: Id): string[] | null { + const names = new Set(); + const queue: Id[] = [id]; + const visited = new Set(); + while (queue.length > 0) { + const current = queue.shift()!; + if (visited.has(current)) continue; + visited.add(current); + const unloaded = unloadedModuleRegistry[current]; + if (!Array.isArray(unloaded)) return null; + for (const name of unloaded[ESMProps.exports]) names.add(name); + queue.push(...unloaded[ESMProps.stars]); + } + return [...names]; +} + interface HotAccept { modules: string[]; cb: HotAcceptFunction; diff --git a/src/runtime/bake/hmr-runtime-server.ts b/src/runtime/bake/hmr-runtime-server.ts index 8d5139089bbe..c82de0740f83 100644 --- a/src/runtime/bake/hmr-runtime-server.ts +++ b/src/runtime/bake/hmr-runtime-server.ts @@ -2,7 +2,7 @@ // On the server, communication is established with `server_exports`. import type { Bake } from "bun"; import "./debug"; -import { loadExports, replaceModules, serverManifest, ssrManifest } from "./hmr-module"; +import { loadExports, replaceModules, serverManifest, ssrManifest, staticExportNames } from "./hmr-module"; if (typeof IS_BUN_DEVELOPMENT !== "boolean") { throw new Error("DCE is configured incorrectly"); @@ -76,10 +76,11 @@ server_exports = { if (componentManifestAdd) { for (const uid of componentManifestAdd) { try { - const exports = await loadExports<{}>(uid); + // A file that the component imports can have a build error, so avoid evaluating the component. + const exportNames = staticExportNames(uid) ?? Object.keys(await loadExports<{}>(uid)); const client = {}; - for (const exportName of Object.keys(exports)) { + for (const exportName of exportNames) { serverManifest[uid + "#" + exportName] = { id: uid, name: exportName, diff --git a/src/runtime/bake/production/entry_points.rs b/src/runtime/bake/production/entry_points.rs index db270ea4b439..ce7d71c66e25 100644 --- a/src/runtime/bake/production/entry_points.rs +++ b/src/runtime/bake/production/entry_points.rs @@ -11,26 +11,33 @@ use super::*; /// tier) so the bundler and runtime share ONE nominal type. pub(crate) use bun_bundler::bake_types::production::EntryPointMap; -impl router::InsertionHandler for EntryPointMap { +/// Scans routes for `bun build --app`. Counts invalid routes so the build can fail. +pub(super) struct RouteScan<'a> { + pub(super) entry_points: &'a mut EntryPointMap, + pub(super) invalid_routes: usize, +} + +impl router::InsertionHandler for RouteScan<'_> { fn get_file_id_for_router( &mut self, abs_path: &[u8], _: router::RouteIndex, _: router::FileKind, ) -> Result { - self.get_or_put_entry_point(abs_path, bake::Side::Server) + self.entry_points + .get_or_put_entry_point(abs_path, bake::Side::Server) .map(|id| OpaqueFileId::init(id.get())) .map_err(|_| bun_alloc::AllocError) } fn on_router_syntax_error( &mut self, - _rel_path: &[u8], - _fail: router::TinyLog, + rel_path: &[u8], + fail: router::TinyLog, ) -> Result<(), bun_alloc::AllocError> { - // EntryPointMap does not handle this, so a malformed route pattern - // during a production build must crash loudly rather than be swallowed. - bun_core::todo_panic!("onRouterSyntaxError for EntryPointMap") + fail.print(rel_path); + self.invalid_routes += 1; + Ok(()) } fn on_router_collision_error( @@ -47,8 +54,8 @@ impl router::InsertionHandler for EntryPointMap { bun_core::pretty_errorln!( " - {}", BStr::new(resolve_path::relative( - &self.root, - self.files.keys()[other_id.get() as usize].abs_path() + &self.entry_points.root, + self.entry_points.files.keys()[other_id.get() as usize].abs_path() )) ); Output::flush(); diff --git a/src/runtime/bake/production/ffi.rs b/src/runtime/bake/production/ffi.rs index b10b2ab14f73..3916bbc6d854 100644 --- a/src/runtime/bake/production/ffi.rs +++ b/src/runtime/bake/production/ffi.rs @@ -3,13 +3,14 @@ use super::*; /// unsafe function, must be run outside of the event loop -/// quits the process on exception +/// fails with `JSError` on exception pub(super) fn load_module( vm: *mut VirtualMachine, global: &JSGlobalObject, key: JSValue, ) -> crate::Result { - let promise_value = BakeLoadModuleByKey(global, key); + let promise_value = + jsc::from_js_host_call(global, || BakeLoadModuleByKey(global, key)).map_err(js_err)?; let promise: *mut jsc::JSInternalPromise = match promise_value.as_any_promise().unwrap() { AnyPromise::Internal(p) => p, AnyPromise::Normal(_) => unreachable!(), @@ -36,7 +37,9 @@ pub(super) fn load_module( let jsc_vm = vm_ref.as_mut().jsc_vm_mut(); match jsc::JSInternalPromise::opaque_mut(promise).unwrap(jsc_vm, UnwrapMode::MarkHandled) { Unwrapped::Pending => unreachable!(), - Unwrapped::Fulfilled(_) => Ok(BakeGetModuleNamespace(global, key)), + Unwrapped::Fulfilled(_) => { + jsc::from_js_host_call(global, || BakeGetModuleNamespace(global, key)).map_err(js_err) + } Unwrapped::Rejected(err) => Err(js_err(vm_ref.global().throw_value(err))), } } @@ -44,19 +47,23 @@ pub(super) fn load_module( // extern apis: unsafe extern "C" { - pub(super) safe fn BakeGetDefaultExportFromModule( - global: &JSGlobalObject, - key: JSValue, - ) -> JSValue; + safe fn BakeGetDefaultExportFromModule(global: &JSGlobalObject, key: JSValue) -> JSValue; safe fn BakeGetModuleNamespace(global: &JSGlobalObject, key: JSValue) -> JSValue; safe fn BakeLoadModuleByKey(global: &JSGlobalObject, key: JSValue) -> JSValue; } +pub(super) fn bake_get_default_export_from_module( + global: &JSGlobalObject, + key: JSValue, +) -> JsResult { + jsc::from_js_host_call(global, || BakeGetDefaultExportFromModule(global, key)) +} + pub(super) fn bake_get_on_module_namespace( global: &JSGlobalObject, module: JSValue, property: &[u8], -) -> Option { +) -> JsResult { unsafe extern "C" { // PRECONDITION: `ptr` must be readable for `len` bytes (C++ builds an // `Identifier` from the slice). Cannot be `safe fn` — raw ptr+len pair @@ -65,12 +72,12 @@ pub(super) fn bake_get_on_module_namespace( fn f(global: *const JSGlobalObject, module: JSValue, ptr: *const u8, len: usize) -> JSValue; } - // SAFETY: `global` is a live `&JSGlobalObject`, `module` is a stack-held - // `JSValue`, and `property.as_ptr()`/`len()` describe a valid borrowed - // `&[u8]` for the call duration — discharges the ptr+len precondition above. - let result: JSValue = unsafe { f(global, module, property.as_ptr(), property.len()) }; - debug_assert!(!result.is_empty()); - Some(result) + jsc::from_js_host_call(global, || { + // SAFETY: `global` is a live `&JSGlobalObject`, `module` is a stack-held + // `JSValue`, and `property.as_ptr()`/`len()` describe a valid borrowed + // `&[u8]` for the call duration — discharges the ptr+len precondition above. + unsafe { f(global, module, property.as_ptr(), property.len()) } + }) } // Renders all routes for static site generation by calling the JavaScript implementation. diff --git a/src/runtime/bake/production/mod.rs b/src/runtime/bake/production/mod.rs index ccc3e968bf3b..673c8ba7e322 100644 --- a/src/runtime/bake/production/mod.rs +++ b/src/runtime/bake/production/mod.rs @@ -364,10 +364,11 @@ fn build_with_vm(ctx: Context, cwd: &[u8], pt: &mut PerThread) -> crate::Result< { Unwrapped::Pending => unreachable!(), Unwrapped::Fulfilled(_) => { - let default = BakeGetDefaultExportFromModule( + let default = bake_get_default_export_from_module( global, config_entry_point_string.to_js(global).map_err(js_err)?, - ); + ) + .map_err(js_err)?; if !default.is_object() { return Err(js_err(global.throw_invalid_arguments(format_args!( @@ -552,7 +553,9 @@ fn build_with_vm(ctx: Context, cwd: &[u8], pt: &mut PerThread) -> crate::Result< }; for fsr in &framework.file_system_router_types { - let joined_root = resolve_path::join_abs::(cwd, &fsr.root); + let mut buf = bun_paths::path_buffer_pool::get(); + let joined_root = + resolve_path::join_abs_string_buf::(cwd, &mut buf[..], &[&fsr.root]); let Some(entry) = server_transpiler .resolver .read_dir_info_ignore_error(joined_root) @@ -591,14 +594,21 @@ fn build_with_vm(ctx: Context, cwd: &[u8], pt: &mut PerThread) -> crate::Result< } let mut router = FrameworkRouter::init_empty(cwd, router_types.into_boxed_slice())?; + let mut route_scan = entry_points::RouteScan { + entry_points: &mut entry_points, + invalid_routes: 0, + }; router.scan_all( &mut server_transpiler.resolver, - router::InsertionContext::wrap(&mut entry_points), + router::InsertionContext::wrap(&mut route_scan), )?; + if route_scan.invalid_routes > 0 { + Global::crash(); + } // `bake::Framework` is the runtime-side superset; the bundler reads only - // `built_in_modules` / `server_components` / `react_fast_refresh` / - // `is_built_in_react` via its lower-tier `bake_types::Framework` view. + // `built_in_modules` / `server_components` / `react_fast_refresh` + // via its lower-tier `bake_types::Framework` view. // (The two Framework types could only merge if `FileSystemRouterType` / // `router::Style` moved down to bun_bundler.) let bundler_framework = framework.as_bundler_view(); @@ -790,13 +800,9 @@ fn build_with_vm(ctx: Context, cwd: &[u8], pt: &mut PerThread) -> crate::Result< } } } - // Write the runtime file to disk if there are any client chunks - { - let Some(runtime_file_index) = maybe_runtime_file_index else { - Output::panic(format_args!( - "Runtime file not found. This is an unexpected bug in Bun. Please file a bug report on GitHub." - )); - }; + // Write the runtime file to disk if there are any client chunks. The runtime + // file doesn't exist if no bundled module uses its helpers. + if let Some(runtime_file_index) = maybe_runtime_file_index { let any_client_chunks = bundled_outputs_list.iter().any(|file| { file.side == Some(bun_bundler::options::Side::Client) && file.src_path.text != b"bun-framework-react/client.tsx" @@ -852,17 +858,10 @@ fn build_with_vm(ctx: Context, cwd: &[u8], pt: &mut PerThread) -> crate::Result< let server_file = router_type.server_file; let server_entry_point = pt.load_bundled_module(server_file)?; - let server_render_func = 'brk: { - let Some(raw) = bake_get_on_module_namespace(global, server_entry_point, b"prerender") - else { - break 'brk None; - }; - if !raw.is_callable() { - break 'brk None; - } - break 'brk Some(raw); - }; - let Some(server_render_func) = server_render_func else { + let server_render_func = + bake_get_on_module_namespace(global, server_entry_point, b"prerender") + .map_err(js_err)?; + if !server_render_func.is_callable() { bun_core::err_generic!("Framework does not support static site generation"); bun_core::note!( "The file {} is missing the \"prerender\" export, which defines how to generate static files.", @@ -872,34 +871,23 @@ fn build_with_vm(ctx: Context, cwd: &[u8], pt: &mut PerThread) -> crate::Result< )) ); Global::crash(); - }; + } let server_param_func = if router.dynamic_routes.count() > 0 { - let f = 'brk: { - let Some(raw) = - bake_get_on_module_namespace(global, server_entry_point, b"getParams") - else { - break 'brk None; - }; - if !raw.is_callable() { - break 'brk None; - } - break 'brk Some(raw); - }; - match f { - Some(f) => f, - None => { - bun_core::err_generic!("Framework does not support static site generation"); - bun_core::note!( - "The file {} is missing the \"getParams\" export, which defines how to generate static files.", - bun_core::fmt::quote(resolve_path::relative( - cwd, - pt.input_file(server_file).abs_path() - )) - ); - Global::crash(); - } + let f = bake_get_on_module_namespace(global, server_entry_point, b"getParams") + .map_err(js_err)?; + if !f.is_callable() { + bun_core::err_generic!("Framework does not support static site generation"); + bun_core::note!( + "The file {} is missing the \"getParams\" export, which defines how to generate static files.", + bun_core::fmt::quote(resolve_path::relative( + cwd, + pt.input_file(server_file).abs_path() + )) + ); + Global::crash(); } + f } else { JSValue::NULL }; diff --git a/src/runtime/bake/router/js.rs b/src/runtime/bake/router/js.rs index a3342b65d28c..29a9b96b18cb 100644 --- a/src/runtime/bake/router/js.rs +++ b/src/runtime/bake/router/js.rs @@ -78,7 +78,7 @@ impl JSFrameworkRouter { paths::platform::Auto, >( // SAFETY: FileSystem::instance() returns the process-global singleton; live for the program. - bun_resolver::fs::FileSystem::get().top_level_dir, + bun_resolver::fs::FileSystem::get().top_level_dir(), root.slice(), )) .into(); @@ -164,11 +164,12 @@ impl JSFrameworkRouter { JSValue::create_empty_object(global, params_out.params.len() as usize); for param in params_out.params.slice() { // key/value borrow from `path`/pattern, both live here (RawSlice invariant) - params_obj.put( + // A file named `[0].tsx` produces a param name that's an array index. + params_obj.put_may_be_index( global, - param.key.slice(), + &bun_core::String::borrow_utf8(param.key.slice()), bun_string_jsc::create_utf8_for_js(global, param.value.slice())?, - ); + )?; } params_obj } else { diff --git a/src/runtime/bake/router/mod.rs b/src/runtime/bake/router/mod.rs index 33e68547ec3c..7840a2cce021 100644 --- a/src/runtime/bake/router/mod.rs +++ b/src/runtime/bake/router/mod.rs @@ -8,7 +8,7 @@ use core::mem::size_of; use bun_alloc::{AllocError, Arena}; use bun_collections::{ArrayHashMap, BoundedArray, StringArrayHashMap}; -use bun_jsc::{JSGlobalObject, JSValue, StringJsc, StrongOptional}; +use bun_jsc::{JSGlobalObject, JSValue, JsResult, StringJsc, StrongOptional}; use bun_paths as paths; use bun_resolver::{DirInfo, Resolver}; @@ -32,7 +32,7 @@ pub(crate) type OpaqueFileId = bun_core::GenericIndex; pub(crate) type OpaqueFileIdOptional = Option; pub(crate) struct FrameworkRouter { - /// Absolute path to root directory of the router. + /// Absolute project root. Only used to shorten file paths in errors. A `Type::abs_root` can be outside it. pub(crate) root: Box<[u8]>, pub(crate) types: Box<[Type]>, pub(crate) routes: Vec, @@ -191,7 +191,7 @@ impl FrameworkRouter { for (type_index, ty) in types.iter_mut().enumerate() { ty.abs_root = strings::paths::without_trailing_slash_windows_path(&ty.abs_root).into(); - debug_assert!(strings::has_prefix(&ty.abs_root, root)); + debug_assert!(paths::is_absolute(&ty.abs_root)); routes.push(Route { part: Part::Text(b""), @@ -433,11 +433,11 @@ impl MatchedParams { /// Convert the matched params to a JavaScript object /// Returns null if there are no params - pub(crate) fn to_js(&self, global: &JSGlobalObject) -> JSValue { + pub(crate) fn to_js(&self, global: &JSGlobalObject) -> JsResult { let params_array = self.params.const_slice(); if params_array.is_empty() { - return JSValue::NULL; + return Ok(JSValue::NULL); } // Create a JavaScript object with params @@ -446,14 +446,9 @@ impl MatchedParams { let key_str = bun_core::String::clone_utf8(param.key.slice()); let value_str = bun_core::String::clone_utf8(param.value.slice()); - obj.put_bun_string_one_or_array( - global, - &key_str, - value_str.into_js(global).expect("unreachable"), - ) - .expect("unreachable"); + obj.put_bun_string_one_or_array(global, &key_str, value_str.into_js(global)?)?; } - obj + Ok(obj) } } @@ -464,7 +459,10 @@ impl FrameworkRouter { pub(crate) fn match_slow(&self, path: &[u8], params: &mut MatchedParams) -> Option { params.params = BoundedArray::default(); - debug_assert!(path[0] == b'/'); + // Every route pattern starts with '/', but a request target or a path from JS might not. + if path.first() != Some(&b'/') { + return None; + } if let Some(static_route) = self.static_routes.get(path) { return Some(*static_route); } @@ -652,31 +650,46 @@ impl FrameworkRouter { } } + let t = &self.types[t_index.get() as usize]; + let abs_path = fs_ref.abs(&[file.dir, file.base()]); + + // Parse the pattern from the path relative to this type's root, which can be outside `self.root`. let mut rel_path_buf = bun_paths::path_buffer_pool::get(); - let full_rel_path_len = { - let full_rel_path = paths::resolve_path::relative_normalized_buf::< + let rel_path_len = 1 + paths::resolve_path::relative_normalized_buf::< + paths::platform::Auto, + true, + >( + &mut rel_path_buf[1..], &t.abs_root, abs_path + ) + .len(); + rel_path_buf[0] = b'/'; + paths::resolve_path::platform_to_posix_in_place( + &mut rel_path_buf[0..rel_path_len], + ); + let rel_path: &[u8] = &rel_path_buf[0..rel_path_len]; + + // For a path outside `self.root`, each root segment becomes "../". + // `DevServer::relative_path` uses the same bound. + let in_root = abs_path.len() > self.root.len() + && abs_path.starts_with(&self.root) + && paths::is_sep_native(abs_path[self.root.len()]); + let label_fits = + in_root || abs_path.len() + self.root.len() * 2 < paths::MAX_PATH_BYTES; + let mut full_rel_path_buf = bun_paths::path_buffer_pool::get(); + let full_rel_path: &[u8] = if label_fits { + let len = paths::resolve_path::relative_normalized_buf::< paths::platform::Auto, true, >( - &mut rel_path_buf[1..], - &self.root, - fs_ref.abs(&[file.dir, file.base()]), + &mut full_rel_path_buf[..], &self.root, abs_path + ) + .len(); + paths::resolve_path::platform_to_posix_in_place( + &mut full_rel_path_buf[0..len], ); - full_rel_path.len() - }; - rel_path_buf[0] = b'/'; - paths::resolve_path::platform_to_posix_in_place( - &mut rel_path_buf[0..full_rel_path_len], - ); - - let t = &self.types[t_index.get() as usize]; - let abs_root_len = t.abs_root.len(); - let root_len = self.root.len(); - let full_rel_path = &rel_path_buf[1..1 + full_rel_path_len]; - let rel_path: &[u8] = if abs_root_len == root_len { - &rel_path_buf[0..full_rel_path_len + 1] + &full_rel_path_buf[0..len] } else { - &full_rel_path[abs_root_len - root_len - 1..] + rel_path }; let mut log = TinyLog::empty(); @@ -689,9 +702,24 @@ impl FrameworkRouter { .parse(rel_path, ext, &mut log, t.allow_layouts, arena_state); let parsed = match parse_result { Err(_) => { - log.cursor_at += - u32::try_from(abs_root_len - root_len).expect("int cast"); - ctx.on_router_syntax_error(full_rel_path, log)?; + // `cursor_at` is an index into `rel_path`. It only maps to + // `full_rel_path` inside their common suffix. + let common_suffix_len = rel_path + .iter() + .rev() + .zip(full_rel_path.iter().rev()) + .take_while(|(a, b)| a == b) + .count(); + let from_end = + rel_path.len().saturating_sub(log.cursor_at as usize); + let label = if from_end <= common_suffix_len { + log.cursor_at = u32::try_from(full_rel_path.len() - from_end) + .expect("int cast"); + full_rel_path + } else { + rel_path + }; + ctx.on_router_syntax_error(label, log)?; arena_state.reset_retain_with_limit(8 * 1024 * 1024); continue 'outer; } @@ -737,7 +765,18 @@ impl FrameworkRouter { ParsedPatternKind::Page => FileKind::Page, ParsedPatternKind::Layout => FileKind::Layout, ParsedPatternKind::Extra => { - panic!("TODO: associate extra files with route") + let file_name = paths::basename(full_rel_path); + log.fail( + format_args!( + "Bun Bake currently does not support \"{}\" files", + bstr::BStr::new(paths::stem(full_rel_path)) + ), + full_rel_path.len() - file_name.len(), + file_name.len(), + ); + ctx.on_router_syntax_error(full_rel_path, log)?; + arena_state.reset_retain_with_limit(8 * 1024 * 1024); + continue 'outer; } }; diff --git a/src/runtime/bake/source_provider_exports.rs b/src/runtime/bake/source_provider_exports.rs index 333cfbc4a71c..4de72fde16b4 100644 --- a/src/runtime/bake/source_provider_exports.rs +++ b/src/runtime/bake/source_provider_exports.rs @@ -146,6 +146,17 @@ pub(crate) fn add_bake_source_provider_source_map( ); } +// HOST_EXPORT(Bun__removeBakeSourceProviderSourceMap, c) +pub(crate) fn remove_bake_source_provider_source_map( + vm: &mut VirtualMachine, + opaque_source_provider: *mut c_void, + specifier: &BunString, +) { + let slice = specifier.to_utf8(); + vm.source_mappings + .remove_source_provider(opaque_source_provider, slice.slice()); +} + // HOST_EXPORT(Bun__addDevServerSourceProvider, c) pub(crate) fn add_dev_server_source_provider( vm: &mut VirtualMachine, diff --git a/src/runtime/bin_entry/c_abi_exports.rs b/src/runtime/bin_entry/c_abi_exports.rs index 8c8621eb73d2..0558a88c471a 100644 --- a/src/runtime/bin_entry/c_abi_exports.rs +++ b/src/runtime/bin_entry/c_abi_exports.rs @@ -25,3 +25,15 @@ extern "C" fn Bun__panic(msg: *const u8, len: usize) -> ! { extern "C" fn Bun__outOfMemory() -> ! { bun_core::out_of_memory() } + +/// Called by `uWS::Loop::create` when it can't create a thread's event loop. +/// Running out of file descriptors isn't a bug in Bun, so print an error instead of a crash report. +#[cfg(unix)] +#[unsafe(no_mangle)] +extern "C" fn Bun__loopInitFailed(err: core::ffi::c_int) -> ! { + match err { + libc::EMFILE => bun_crash_handler::handle_root_error("ProcessFdQuotaExceeded"), + libc::ENFILE => bun_crash_handler::handle_root_error("SystemFdQuotaExceeded"), + _ => bun_core::output::panic(format_args!("failed to create the event loop: errno {err}")), + } +} diff --git a/src/runtime/bin_entry/mod.rs b/src/runtime/bin_entry/mod.rs index fae5ecdb0c6f..2169092d76da 100644 --- a/src/runtime/bin_entry/mod.rs +++ b/src/runtime/bin_entry/mod.rs @@ -160,6 +160,14 @@ pub(crate) unsafe extern "C" fn main(argc: c_int, argv: *const *const c_char) -> // for the entire process. unsafe { bun_core::init_argv(argc, argv) }; + // Everything below calls into simdutf, including the crash handler. + if !bun_simdutf_sys::simdutf::has_any_implementation() { + unsafe extern "C" { + safe fn bun_abort_missing_simd() -> !; + } + bun_abort_missing_simd(); + } + // 1. Crash handler first so anything below gets a usable trace. bun_crash_handler::init(); diff --git a/src/runtime/cli/build_command.rs b/src/runtime/cli/build_command.rs index eab2470ca802..02697be05537 100644 --- a/src/runtime/cli/build_command.rs +++ b/src/runtime/cli/build_command.rs @@ -155,7 +155,7 @@ impl BuildCommand { // Nothing is bundled, so nothing has been resolved or read. let entry_points = this_transpiler.options.entry_points.iter(); let checked = options::TypeChecked { - cwd: bun_resolver::fs::FileSystem::instance().top_level_dir, + cwd: bun_resolver::fs::FileSystem::instance().top_level_dir(), tsconfig: ctx.args.tsconfig_override.as_deref(), conditions: &ctx.args.conditions, loaders: &this_transpiler.options.loaders, @@ -1259,6 +1259,8 @@ pub(crate) fn collect_compile_assets( } let mut push = |out: &mut Vec, asset: &[u8], dest: Vec, bytes: Vec| { + // Names are stored as valid UTF-8, so two names that differ only in invalid bytes collide. + let dest = bstr::BStr::new(&dest).to_string().into_bytes(); if seen.contains_key(&dest) { return Err(format!( "asset {} collides with another embedded file at {}", diff --git a/src/runtime/cli/bunx_command.rs b/src/runtime/cli/bunx_command.rs index 1e928966fd57..fbc62c929a9a 100644 --- a/src/runtime/cli/bunx_command.rs +++ b/src/runtime/cli/bunx_command.rs @@ -850,7 +850,7 @@ impl BunxCommand { }; // SAFETY: `Transpiler::init` always sets `fs` to the process singleton. - let top_level_dir: &[u8] = unsafe { &*this_transpiler.fs }.top_level_dir; + let top_level_dir: &[u8] = unsafe { &*this_transpiler.fs }.top_level_dir(); let which_cwd: &[u8] = if ignore_cwd.is_empty() { top_level_dir } else { diff --git a/src/runtime/cli/create_command.rs b/src/runtime/cli/create_command.rs index c2145c8d5658..ec8679815aae 100644 --- a/src/runtime/cli/create_command.rs +++ b/src/runtime/cli/create_command.rs @@ -373,7 +373,7 @@ impl CreateCommand { .dirname_store .append_slice(bun_paths::resolve_path::join_abs::< bun_paths::platform::Loose, - >(filesystem.top_level_dir, dirname))?; + >(filesystem.top_level_dir(), dirname))?; let mut progress = Progress { supports_ansi_escape_codes: Output::enable_ansi_colors_stderr(), @@ -1051,7 +1051,7 @@ impl CreateCommand { && bun_install::package_manager::config_ignores_scripts( ctx, env_loader, - filesystem.top_level_dir, + filesystem.top_level_dir(), )?); // `--no-install` skips the tasks too. No dependencies skips only `bun install`. @@ -1193,7 +1193,7 @@ impl CreateCommand { } let rel_destination = - bun_paths::resolve_path::relative(filesystem.top_level_dir, destination); + bun_paths::resolve_path::relative(filesystem.top_level_dir(), destination); if rel_destination.is_empty() { bun_core::pretty!( "\n# To get started, run:\n\n {}\n\n", @@ -1260,13 +1260,13 @@ impl CreateCommand { let join = |parts: &[&[u8]], buf: &mut PathBuffer| { bun_paths::resolve_path::join_abs_string_buf_z_checked::< bun_paths::resolve_path::platform::Loose, - >(filesystem.top_level_dir, &mut buf[..], parts) + >(filesystem.top_level_dir(), &mut buf[..], parts) .map(|path| path.as_bytes().len()) }; let template: &[u8] = 'brk: { let positional = positionals[0]; - let parts = [filesystem.top_level_dir, positional]; + let parts = [filesystem.top_level_dir(), positional]; let Some(len) = join(&parts, home_dir_buf) else { pretty_errorln!("error: template name too long"); Global::exit(1); @@ -1308,7 +1308,7 @@ impl CreateCommand { let home_dir = env_loader.map.get(b"HOME"); let template_dirs: [Option>; 3] = [ create_dir.map(|dir| vec![dir, positional]), - Some(vec![filesystem.top_level_dir, BUN_CREATE_DIR, positional]), + Some(vec![filesystem.top_level_dir(), BUN_CREATE_DIR, positional]), home_dir.map(|dir| vec![dir, BUN_CREATE_DIR, positional]), ]; for parts in template_dirs.iter().flatten() { @@ -1606,7 +1606,7 @@ impl Example { let home_dir = env_loader.map.get(bun_core::env_var::HOME.key()); let folders: [Option; 3] = [ create_dir.map(|dir| vec![dir]), - Some(vec![filesystem.top_level_dir, BUN_CREATE_DIR]), + Some(vec![filesystem.top_level_dir(), BUN_CREATE_DIR]), home_dir.map(|dir| vec![dir, BUN_CREATE_DIR]), ] .map(|parts| -> Option { @@ -1717,8 +1717,7 @@ impl Example { } let http_proxy = env_loader.get_http_proxy_for(&api_url); - let mutable: &'static mut MutableString = - crate::cli::cli_arena().alloc(MutableString::init(8192)?); + let mut mutable = MutableString::default(); // ensure very stable memory address let mut async_http = Box::new(HTTP::AsyncHTTP::init_sync( @@ -1734,7 +1733,7 @@ impl Example { async_http.client.progress_node = Some(core::ptr::NonNull::from(&mut *progress)); async_http.client.flags.reject_unauthorized = env_loader.get_tls_reject_unauthorized(); - let response = async_http.send_sync(mutable)?; + let response = async_http.send_sync(&mut mutable)?; match response.status_code() { 404 => return Err(crate::Error::GitHubRepositoryNotFound), @@ -1773,7 +1772,7 @@ impl Example { Global::crash(); } - Ok(mutable.clone()?) + Ok(mutable) } pub(crate) fn fetch( @@ -1788,8 +1787,7 @@ impl Example { // SAFETY: single-threaded CLI access to static buffer. let url_buf = unsafe { &mut *NPM_REGISTRY_URL_BUF.get() }; - let mutable: &'static mut MutableString = - crate::cli::cli_arena().alloc(MutableString::init(2048)?); + let mut mutable = MutableString::default(); let api_url = URL::parse({ let mut cursor: &mut [u8] = &mut url_buf[..]; @@ -1812,23 +1810,21 @@ impl Example { .get_http_proxy_for(unsafe { (*URL_.get()).as_ref().unwrap() }) .map(|u| URL::parse_single_reader(crate::cli::cli_dupe(u.href))); - // ensure very stable memory address - let async_http: &mut HTTP::AsyncHTTP = - crate::cli::cli_arena().alloc(HTTP::AsyncHTTP::init_sync( - HTTP::Method::GET, - // SAFETY: single-threaded CLI access to static URL_ (set just above) - unsafe { (*URL_.get()).clone() }.unwrap(), - Default::default(), - b"", - b"", - http_proxy, - HTTP::ProxySettings::from_env(env_loader), - HTTP::FetchRedirect::Follow, - )); + let mut async_http = HTTP::AsyncHTTP::init_sync( + HTTP::Method::GET, + // SAFETY: single-threaded CLI access to static URL_ (set just above) + unsafe { (*URL_.get()).clone() }.unwrap(), + Default::default(), + b"", + b"", + http_proxy, + HTTP::ProxySettings::from_env(env_loader), + HTTP::FetchRedirect::Follow, + ); async_http.client.progress_node = Some(core::ptr::NonNull::from(&mut *progress)); async_http.client.flags.reject_unauthorized = env_loader.get_tls_reject_unauthorized(); - let mut response = async_http.send_sync(mutable)?; + let mut response = async_http.send_sync(&mut mutable)?; match response.status_code() { 404 => return Err(crate::Error::ExampleNotFound), @@ -1896,7 +1892,7 @@ impl Example { .get_http_proxy_for(&parsed_tarball_url) .map(|u| URL::parse_single_reader(crate::cli::cli_dupe(u.href))); - *async_http = HTTP::AsyncHTTP::init_sync( + async_http = HTTP::AsyncHTTP::init_sync( HTTP::Method::GET, parsed_tarball_url, Default::default(), @@ -1911,7 +1907,7 @@ impl Example { refresher.maybe_refresh(); - response = async_http.send_sync(mutable)?; + response = async_http.send_sync(&mut mutable)?; refresher.maybe_refresh(); @@ -1927,7 +1923,7 @@ impl Example { refresher.refresh(); - Ok(mutable.clone()?) + Ok(mutable) } pub(crate) fn fetch_all( @@ -1938,8 +1934,13 @@ impl Example { let url = URL::parse(Self::EXAMPLES_URL); let http_proxy = env_loader.get_http_proxy_for(&url); + // Never freed because the returned `Example` names borrow from the body. let mutable: &'static mut MutableString = - crate::cli::cli_arena().alloc(MutableString::init(2048)?); + crate::cli::cli_arena().alloc(MutableString::default()); + bun_core::asan::register_root_region( + std::ptr::from_ref::(mutable).cast(), + core::mem::size_of::(), + ); let mut async_http = Box::new(HTTP::AsyncHTTP::init_sync( HTTP::Method::GET, diff --git a/src/runtime/cli/filter_arg.rs b/src/runtime/cli/filter_arg.rs index 82f282c8811d..caa6e2502034 100644 --- a/src/runtime/cli/filter_arg.rs +++ b/src/runtime/cli/filter_arg.rs @@ -4,7 +4,7 @@ use bstr::BStr; use bun_ast::{self, ExprData, Log}; use bun_core::Global; use bun_core::Output; -use bun_core::{ZStr, strings}; +use bun_core::strings; use bun_glob as glob; use bun_install::package_manager::workspace_selection::{ self, Candidate, NegatedWorkspaces, RootSelection, WorkspaceGraph, @@ -64,11 +64,14 @@ fn get_candidate_package_patterns<'a>( 'walk: loop { 'body: { let mut name_buf = bun_paths::path_buffer_pool::get(); - let json_path: &ZStr = resolve_path::join_abs_string_buf_z::( + // Treat a path that's too long like a missing package.json and try the parent. + let Some(json_path) = resolve_path::join_abs_string_buf_z_checked::( workdir, &mut name_buf[..], &[b"package.json".as_slice()], - ); + ) else { + break 'body; + }; log.msgs.clear(); log.errors = 0; diff --git a/src/runtime/cli/filter_run.rs b/src/runtime/cli/filter_run.rs index db76c6a2209e..5ad644a57410 100644 --- a/src/runtime/cli/filter_run.rs +++ b/src/runtime/cli/filter_run.rs @@ -859,7 +859,7 @@ pub(crate) fn run_scripts_with_filter( let selected = FilterArg::select_packages( &*ctx, &mut this_transpiler.resolver, - fsinstance.top_level_dir, + fsinstance.top_level_dir(), )?; let mut scripts: Vec = Vec::new(); @@ -969,7 +969,7 @@ pub(crate) fn run_scripts_with_filter( let shell_bin: Option<&'static ZStr> = if cfg!(unix) { // SAFETY: env_ptr is the live process-lifetime DotEnv loader. let path = unsafe { (*env_ptr).get(b"PATH") }.unwrap_or(b""); - let top_level_dir = fsinstance.top_level_dir; + let top_level_dir = fsinstance.top_level_dir(); Some(RunCommand::find_shell(path, top_level_dir).ok_or(crate::Error::MissingShell)?) } else { None diff --git a/src/runtime/cli/init_command.rs b/src/runtime/cli/init_command.rs index ac428b724a8c..80a8a134cb72 100644 --- a/src/runtime/cli/init_command.rs +++ b/src/runtime/cli/init_command.rs @@ -1470,7 +1470,7 @@ impl Template { return false; }; // SAFETY: FileSystem::instance() returns the process-global singleton. - let top_level_dir = Fs::FileSystem::get().top_level_dir; + let top_level_dir = Fs::FileSystem::get().top_level_dir(); bun_which::which(&mut *pathbuffer, path, top_level_dir, b"claude").is_some() } diff --git a/src/runtime/cli/mod.rs b/src/runtime/cli/mod.rs index 101cc49cff03..af5c7e3718cf 100644 --- a/src/runtime/cli/mod.rs +++ b/src/runtime/cli/mod.rs @@ -1825,12 +1825,12 @@ To create a project with the official Next.js scaffolding tool, run for arg in bun::argv() { if arg == b"--hash" { - let mut path_buf = bun_paths::path_buffer_pool::get(); - path_buf[..entry.len()].copy_from_slice(entry); - path_buf[entry.len()] = 0; - // SAFETY: NUL terminator written at `path_buf[entry.len()]` above. - let lockfile_path = bun_core::ZStr::from_buf(&path_buf[..], entry.len()); - let file = match bun_sys::File::open(lockfile_path, bun_sys::O::RDONLY, 0) { + let file = match bun_sys::File::openat( + bun_sys::Fd::cwd(), + &entry[..], + bun_sys::O::RDONLY, + 0, + ) { Ok(f) => f, Err(err) => { Output::err(err, "failed to open lockfile", ()); diff --git a/src/runtime/cli/multi_run.rs b/src/runtime/cli/multi_run.rs index 7c3e4992e5f8..b0590346f1de 100644 --- a/src/runtime/cli/multi_run.rs +++ b/src/runtime/cli/multi_run.rs @@ -898,7 +898,7 @@ pub(crate) fn run(ctx: &mut Command::ContextData) -> Result( - Fs::FileSystem::get().top_level_dir, + Fs::FileSystem::get().top_level_dir(), pm.options.bin_path.as_bytes(), ); bun_core::prettyln!("{}", bstr::BStr::new(output_path)); @@ -614,7 +614,7 @@ impl PackageManagerCommand { Output::println(format_args!( "{} node_modules{}", bstr::BStr::new(strings::without_trailing_slash( - Fs::FileSystem::get().top_level_dir + Fs::FileSystem::get().top_level_dir() )), if all { "" } else { " (0 installed)" }, )); diff --git a/src/runtime/cli/pm_diff_command.rs b/src/runtime/cli/pm_diff_command.rs index 6545a205d3cd..087d77f5a1cc 100644 --- a/src/runtime/cli/pm_diff_command.rs +++ b/src/runtime/cli/pm_diff_command.rs @@ -27,8 +27,6 @@ use crate::cli::pm_diff_normalize as normalize; use crate::cli::pm_diff_semantic::Operation; use crate::cli::pm_diff_semantic::{self as semantic, Op}; -use bun_core::fmt::buf_print_infallible as buf_print; - #[derive(Clone, Copy)] pub(crate) struct DiffFlags { /// Compare bytes, not the canonical re-print of JS/CSS/JSON. @@ -833,24 +831,15 @@ fn fetch_registry_tree( let bump = Bump::new(); let scope = pm.scope_for_package_name(name); - let mut url_buf = bun_paths::path_buffer_pool::get(); - let encoded_name = buf_print( - url_buf.0.as_mut_slice(), - format_args!("{}", bun_fmt::dependency_url(name)), - ); - let mut path_buf = bun_paths::path_buffer_pool::get(); - let manifest_url = buf_print( - path_buf.0.as_mut_slice(), - format_args!( - "{}/{}", - BStr::new(strings::without_trailing_slash(scope.url.href())), - BStr::new(encoded_name) - ), + let manifest_url = format!( + "{}/{}", + BStr::new(strings::without_trailing_slash(scope.url.href())), + bun_fmt::dependency_url(name), ); let body = registry_get( pm, scope, - URL::parse(manifest_url), + URL::parse(manifest_url.as_bytes()), true, // The abbreviated packument has versions + dist, all this needs; full ones run to tens of MB. Some(b"application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*"), diff --git a/src/runtime/cli/pm_pkg_command.rs b/src/runtime/cli/pm_pkg_command.rs index 71945fc85f74..fda308b21da0 100644 --- a/src/runtime/cli/pm_pkg_command.rs +++ b/src/runtime/cli/pm_pkg_command.rs @@ -354,13 +354,12 @@ impl PmPkgCommand { pkg_dir = cwd; } let mut buf = bun_paths::path_buffer_pool::get(); - let full_path = path::resolve_path::join_abs_string_buf_z::( - pkg_dir, - &mut buf, - &[bin_path], - ); + // A path that's too long can't exist. + let full_path = path::resolve_path::join_abs_string_buf_z_checked::< + path::platform::Auto, + >(pkg_dir, &mut buf[..], &[bin_path]); - if !bun_sys::exists_z(full_path) { + if !full_path.is_some_and(bun_sys::exists_z) { bun_core::warn!( "No bin file found at {}", bun_core::fmt::EscapeControlChars(bstr::BStr::new(bin_path)) diff --git a/src/runtime/cli/repl.rs b/src/runtime/cli/repl.rs index 99f8a2f90f1e..4ff19836ca6e 100644 --- a/src/runtime/cli/repl.rs +++ b/src/runtime/cli/repl.rs @@ -199,10 +199,13 @@ impl History { } let mut path_buf = bun_paths::path_buffer_pool::get(); - let path = path::resolve_path::join_z_buf::( - &mut path_buf, + // Skip the history file if `$HOME` is too long. + let Some(path) = path::resolve_path::join_z_buf_checked::( + &mut path_buf[..], &[home_path, HISTORY_FILENAME], - ); + ) else { + return Ok(()); + }; self.file_path = Some(Box::<[u8]>::from(path.as_bytes())); let content: Box<[u8]> = match sys::File::read_from(Fd::cwd(), path) { @@ -730,9 +733,7 @@ fn cmd_load(repl: &mut Repl, args: &[u8]) -> ReplResult { return ReplResult::SkipEval; } - let mut path_buf = bun_paths::path_buffer_pool::get(); - let path_z = path::resolve_path::z(filename, &mut path_buf); - let content: Box<[u8]> = match sys::File::read_from(Fd::cwd(), path_z) { + let content: Box<[u8]> = match sys::File::read_from(Fd::cwd(), filename) { sys::Result::Ok(bytes) => bytes.into(), sys::Result::Err(err) => { repl.print_error(format_args!("{}\n", err)); diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 62fe8e302f1a..5c1ec5c0497b 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -437,6 +437,17 @@ Full documentation is available at https://bun.com/docs/cli/run #[cfg(windows)] let ipc_fd: Option = None; // TODO: implement on Windows + // `Loader::load_process` drops these, but a package script inherits `ipc_fd` and needs them. + #[cfg(not(windows))] + if ipc_fd.is_some() { + if let Some(fd_s) = bun_core::env_var::NODE_CHANNEL_FD.get() { + env.map.put(b"NODE_CHANNEL_FD", fd_s)?; + } + if let Some(mode) = bun_core::env_var::NODE_CHANNEL_SERIALIZATION_MODE.get() { + env.map.put(b"NODE_CHANNEL_SERIALIZATION_MODE", mode)?; + } + } + // TODO: remember to free this when we add --filter or --concurrent // in the meantime we don't need to free it. let envp = env.map.create_null_delimited_env_map()?; @@ -666,7 +677,7 @@ Full documentation is available at https://bun.com/docs/cli/run } // SAFETY: `Transpiler::init` always sets `fs` to the process singleton. - let top_level_dir = unsafe { (*this_transpiler.fs).top_level_dir }; + let top_level_dir = unsafe { (*this_transpiler.fs).top_level_dir() }; let root_dir_info: bun_resolver::DirInfoRef = match this_transpiler.resolver.read_dir_info(top_level_dir) { Err(err) => { @@ -952,12 +963,7 @@ Full documentation is available at https://bun.com/docs/cli/run mini.top_level_dir = Box::<[u8]>::from(top_level_dir); // `initAndRunFromFile`: read source then hand off to the interpreter. - let mut path_buf = bun_paths::path_buffer_pool::get(); - path_buf[..entry_path.len()].copy_from_slice(entry_path); - path_buf[entry_path.len()] = 0; - // SAFETY: NUL-terminated above; `path_buf` outlives the call. - let path_z = ZStr::from_buf(&path_buf[..], entry_path.len()); - let src = sys::File::read_from(Fd::cwd(), path_z)?; + let src = sys::File::read_from(Fd::cwd(), entry_path)?; crate::shell::Interpreter::init_and_run_from_file(ctx, mini, entry_path, &src) } @@ -1535,7 +1541,7 @@ impl Run<'_> { if entry == Some(b".".as_slice()) { // SAFETY: `vm.transpiler.fs` is the process-static `FileSystem` // singleton (set in `Transpiler::init`). - let tld = unsafe { (*vm.transpiler.fs).top_level_dir }; + let tld = unsafe { (*vm.transpiler.fs).top_level_dir() }; if !tld.is_empty() { entry = Some(tld); } @@ -2608,7 +2614,7 @@ impl RunCommand { // load module and run that module // TODO: run module resolution here - try the next condition if the module can't be found // SAFETY: `Transpiler::init` always sets `fs` to the process singleton. - let fs_top_level_dir = unsafe { (*this_transpiler.fs).top_level_dir }; + let fs_top_level_dir = unsafe { (*this_transpiler.fs).top_level_dir() }; crate::cli::script_or_command::note_file_or_executable( env_loader, target_name, @@ -2630,7 +2636,7 @@ impl RunCommand { .get() .unwrap_or(false); // SAFETY: `Transpiler::init` always sets `fs`; resolver-cache lifetime. - let top_level_dir = unsafe { (*this_transpiler.fs).top_level_dir }; + let top_level_dir = unsafe { (*this_transpiler.fs).top_level_dir() }; let resolved = match this_transpiler.resolver.resolve( top_level_dir, target_name, @@ -2740,7 +2746,7 @@ impl RunCommand { { // SAFETY: `Transpiler::init` always sets `fs`; resolver-cache lifetime. let fs = unsafe { &mut *this_transpiler.fs }; - let top_level_dir = fs.top_level_dir; + let top_level_dir = fs.top_level_dir(); let path = env_loader.get(b"PATH").unwrap_or(b""); let mut path_for_which = path; if bin_dirs_only { @@ -2912,21 +2918,34 @@ impl RunCommand { let open_len: usize = if paths::is_absolute(target) { // `PosixToWinNormalizer::resolve_cwd` prepends the cwd drive // letter on Windows for `/abs` paths; then, on Windows only, - // `normalize_string` canonicalizes + // `normalize_string_buf` canonicalizes // separators. Both are no-ops on POSIX (`resolve_cwd` returns the // input slice untouched). let mut win_resolver = paths::resolve_path::PosixToWinNormalizer::default(); - let resolved = win_resolver - .resolve_cwd(target) - .unwrap_or_else(|_| panic!("Could not resolve path")); - #[cfg(windows)] - let resolved: &[u8] = - paths::resolve_path::normalize_string::(resolved); - if resolved.len() >= MAX_PATH_BYTES { + let Ok(resolved) = win_resolver.resolve_cwd(target) else { return false; - } - script_name_buf[..resolved.len()].copy_from_slice(resolved); - resolved.len() + }; + #[cfg(windows)] + let len = { + // Normalizing a bare `\\server\share` adds a trailing separator, and the NUL needs one more byte. + if resolved.len() + 1 >= MAX_PATH_BYTES { + return false; + } + paths::resolve_path::normalize_string_buf::( + resolved, + script_name_buf.as_mut_slice(), + ) + .len() + }; + #[cfg(not(windows))] + let len = { + if resolved.len() >= MAX_PATH_BYTES { + return false; + } + script_name_buf[..resolved.len()].copy_from_slice(resolved); + resolved.len() + }; + len } else if !target.starts_with(b"..") && target[0] != b'~' { // open relative to cwd as-is if target.len() >= MAX_PATH_BYTES { @@ -2942,11 +2961,13 @@ impl RunCommand { }; let cwd_len = cwd.as_bytes().len(); cwd_buf[cwd_len] = paths::SEP; - let joined = paths::resolve_path::join_abs_string_buf::( - &cwd_buf[..cwd_len + 1], - &mut script_name_buf.0, - &[target], - ); + let Some(joined) = paths::resolve_path::join_abs_string_buf_z_checked::< + paths::platform::Auto, + >( + &cwd_buf[..cwd_len + 1], &mut script_name_buf.0, &[target] + ) else { + return false; + }; if joined.is_empty() { return false; } @@ -3161,10 +3182,8 @@ impl RunCommand { let cwd = bun_core::getcwd_or_exe_dir(&mut cwd_buf); let cwd_len = cwd.as_bytes().len(); cwd_buf[cwd_len] = b'/'; - let mut out_buf = bun_paths::path_buffer_pool::get(); - let joined = paths::resolve_path::join_abs_string_buf::( + let joined = paths::resolve_path::join_abs_string::( &cwd_buf[..cwd_len + 1], - &mut out_buf.0, &[&filename], ); joined.to_vec().into_boxed_slice() @@ -3623,11 +3642,13 @@ impl RunCommand { Ok(n) => &cwd_buf[..n], Err(_) => break 'blk path, }; - paths::resolve_path::join_abs_string_buf::( + // If the absolute path is too long, the relative path can still be opened. + paths::resolve_path::join_abs_string_buf_checked::( cwd, &mut base_buf.0, &[path], ) + .unwrap_or(path) }; let dir = paths::resolve_path::dirname::(abs_md_path); // When dirname returns empty (bare filename + getcwd failed), fall @@ -3709,7 +3730,7 @@ impl RunCommand { this_transpiler.configure_linker(); // SAFETY: `Transpiler::fs` is the non-null process-static singleton. - let top_level_dir = unsafe { (*this_transpiler.fs).top_level_dir }; + let top_level_dir = unsafe { (*this_transpiler.fs).top_level_dir() }; let Some(root_dir_info) = this_transpiler .resolver .read_dir_info(top_level_dir) diff --git a/src/runtime/cli/test/ChangedFilesFilter.rs b/src/runtime/cli/test/ChangedFilesFilter.rs index 8dadbbe91b48..09a9907bf271 100644 --- a/src/runtime/cli/test/ChangedFilesFilter.rs +++ b/src/runtime/cli/test/ChangedFilesFilter.rs @@ -67,7 +67,7 @@ pub(crate) fn filter<'a>( test_files: &'a mut [Interned], changed_since: &[u8], ) -> core::result::Result, crate::Error> { - let top_level_dir: &[u8] = bun_resolver::fs::FileSystem::get().top_level_dir; + let top_level_dir: &[u8] = bun_resolver::fs::FileSystem::get().top_level_dir(); // If this process was restarted by the --watch file watcher, it // recorded exactly which files changed in this env var before diff --git a/src/runtime/cli/test/Scanner.rs b/src/runtime/cli/test/Scanner.rs index 3793b64510b0..a55a8c681002 100644 --- a/src/runtime/cli/test/Scanner.rs +++ b/src/runtime/cli/test/Scanner.rs @@ -100,13 +100,12 @@ impl<'a> Scanner<'a> { #[inline] fn top_level_dir(&self) -> &'static [u8] { - // SAFETY: field-precise projection; never spans the mutably-borrowed `fs` field. - unsafe { (*self.fs).top_level_dir } + bun_core::top_level_dir() } #[inline] fn filename_store(&self) -> &'static fs::FilenameStore { - // SAFETY: same as `top_level_dir`. + // SAFETY: field-precise projection; never spans the mutably-borrowed `fs` field. unsafe { (*self.fs).filename_store } } @@ -380,7 +379,7 @@ impl<'a> Scanner<'a> { } fs::EntryKind::File => { // already seen it! - if !entry.abs_path.is_empty() { + if !entry.abs_path().is_empty() { return; } @@ -416,8 +415,8 @@ impl<'a> Scanner<'a> { Ok(s) => s, Err(_) => bun_core::out_of_memory(), }; - entry.abs_path = Interned::from_static(stored); - self.test_files.push(entry.abs_path); + let abs_path = entry.abs_path_or_fill(|| Interned::from_static(stored)); + self.test_files.push(abs_path); } fs::EntryKind::Dangling => {} } diff --git a/src/runtime/cli/test/Timings.rs b/src/runtime/cli/test/Timings.rs index 463781ad395f..c562be046b51 100644 --- a/src/runtime/cli/test/Timings.rs +++ b/src/runtime/cli/test/Timings.rs @@ -109,7 +109,7 @@ impl Timings { } fn key_for(abs_path: &[u8]) -> Vec { - let rel = bun_paths::resolve_path::relative(FileSystem::get().top_level_dir, abs_path); + let rel = bun_paths::resolve_path::relative(FileSystem::get().top_level_dir(), abs_path); let mut key = rel.to_vec(); if cfg!(windows) { bun_paths::resolve_path::platform_to_posix_in_place(&mut key[..]); diff --git a/src/runtime/cli/test/parallel/runner.rs b/src/runtime/cli/test/parallel/runner.rs index 0272a3d8a68b..4aa42f934849 100644 --- a/src/runtime/cli/test/parallel/runner.rs +++ b/src/runtime/cli/test/parallel/runner.rs @@ -154,7 +154,7 @@ pub(crate) fn run_as_coordinator( files: sorted, costs, // SAFETY: FileSystem singleton is initialized before any test runner code runs. - cwd: FileSystem::get().top_level_dir, + cwd: FileSystem::get().top_level_dir(), argv, envps, // Coordinator borrows the workers slice while each Worker holds a raw @@ -672,15 +672,17 @@ pub(crate) fn run_as_worker( worker_flush_aggregates(wloop.reporter, vm_ref, ctx, &mut wloop.cmds); // Drain any backpressure-buffered frames before exit so the coordinator // sees repeat_bufs / coverage_file. - while wloop.cmds.channel.has_pending_writes() && !wloop.cmds.channel.done.get() { - // SAFETY: event_loop pointer is valid while vm lives. - unsafe { (*vm_ref.event_loop()).tick() }; - if !wloop.cmds.channel.has_pending_writes() || wloop.cmds.channel.done.get() { - break; + vm_ref.run_with_api_lock(|| { + while wloop.cmds.channel.has_pending_writes() && !wloop.cmds.channel.done.get() { + // SAFETY: event_loop pointer is valid while vm lives. + unsafe { (*vm_ref.event_loop()).tick() }; + if !wloop.cmds.channel.has_pending_writes() || wloop.cmds.channel.done.get() { + break; + } + // SAFETY: event_loop pointer is valid while vm lives. + unsafe { (*vm_ref.event_loop()).auto_tick() }; } - // SAFETY: event_loop pointer is valid while vm lives. - unsafe { (*vm_ref.event_loop()).auto_tick() }; - } + }); // Mirror TestCommand::exec's exit path so BUN_DESTRUCT_VM_ON_EXIT teardown // (lastChanceToFinalize) runs; bypassing it leaks JSC-owned native state. vm_ref.exit_handler.exit_code = 0; diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index 17bbca36c5d1..7d5dd0eed8cc 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -163,7 +163,7 @@ pub(crate) struct TestFailure { /// How a test file is named in the JUnit document: relative to the project /// root when inside it, else as given. pub(crate) fn junit_file_name(path: &[u8]) -> &[u8] { - let top = FileSystem::instance().top_level_dir; + let top = FileSystem::instance().top_level_dir(); if strings::has_prefix(path, top) { without_leading_path_separator(&path[top.len()..]) } else { @@ -338,7 +338,7 @@ impl TestFailure { } } body.push(b'\n'); - let dir = FileSystem::instance().top_level_dir; + let dir = FileSystem::instance().top_level_dir(); for frame in exception.stack.frames() { let source_url = frame.source_url.to_utf8(); let file = jsc::ZigStackFrame::relative_source_url(dir, source_url.slice()); @@ -894,16 +894,9 @@ impl JunitReporter { self.contents.extend_from_slice(b"\n"); } - let mut junit_path_buf = bun_paths::path_buffer_pool::get(); - - junit_path_buf[..path.len()].copy_from_slice(path); - junit_path_buf[path.len()] = 0; - - // SAFETY: junit_path_buf[path.len()] == 0 written above - let zpath = bun_core::ZStr::from_buf(&junit_path_buf[..], path.len()); match File::openat( Fd::cwd(), - zpath, + path, bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::TRUNC, 0o664, ) { @@ -1404,16 +1397,7 @@ impl CommandLineReporter { this.summary().fail += 1; if this.summary().fail == this.jest.bail { - this.print_summary(); - pretty_error!( - "\nBailed out after {} failure{}\n", - this.jest.bail, - if this.jest.bail == 1 { "" } else { "s" } - ); - Output::flush(); - this.write_junit_report_if_needed(); - this.write_timings_if_needed(); - Global::exit(1); + this.bail_out(VirtualMachine::get().as_mut()); } } } @@ -1439,6 +1423,38 @@ impl CommandLineReporter { Output::print_start_end(bun::start_time(), bun::time::nano_timestamp()); } + /// Prints that `--bail` hit its failure count, then exits with code 1. Tears down the VM the same way `exec` does. + pub(crate) fn bail_out(&mut self, vm: &mut VirtualMachine) -> ! { + self.print_summary(); + pretty_error!( + "\nBailed out after {} failure{}\n", + self.jest.bail, + if self.jest.bail == 1 { "" } else { "s" } + ); + Output::flush(); + self.write_junit_report_if_needed(); + self.write_timings_if_needed(); + + vm.exit_handler.exit_code = 1; + vm.exit_handler.skip_exit_listeners = skip_exit_listeners(self); + vm.exit_handler.requested = true; + let vm_ptr: *mut VirtualMachine = vm; + // SAFETY: `vm_ptr` reborrows the live `&mut VirtualMachine`; + // `run_with_api_lock` takes `&self` only, so the closure holds the + // unique mutable access on this single-threaded path. + vm.run_with_api_lock(|| unsafe { (*vm_ptr).on_exit() }); + // Exit listeners need the runner, so release the `bun:test` GC roots and clear `RUNNER` + // after they run. Finalizers in the teardown GC must not see a half-torn-down `TestRunner`. + self.jest.bun_test_root.deinit_for_exit(); + // SAFETY: `RUNNER` is a `RacyCell` touched only from the single JS thread; + // no concurrent reader exists on this shutdown path. + unsafe { + jest::Jest::RUNNER.write(None); + } + // SAFETY: as above; `global_exit()` diverges, so the closure is the sole mutator. + vm.run_with_api_lock(|| unsafe { (*vm_ptr).global_exit() }) + } + /// Like the JUnit report, called before every exit path (including bail) so measured durations aren't lost. pub(crate) fn write_timings_if_needed(&mut self) { if self.jest.test_options.update_timings @@ -1473,7 +1489,7 @@ impl CommandLineReporter { // SAFETY: thread-local Box pinned for the thread; sole `&mut` for the // collection loop below (single-threaded CLI report path). let map = unsafe { &mut *map.as_ptr() }; - let relative_dir = FileSystem::get().top_level_dir; + let relative_dir = FileSystem::get().top_level_dir(); let ignore_patterns: Vec> = (opts .ignore_patterns .iter() @@ -1496,24 +1512,26 @@ impl CommandLineReporter { } } + /// Returns false if `lcov.info` could not be written. pub(crate) fn generate_code_coverage( &mut self, vm: &mut VirtualMachine, opts: &mut CodeCoverageOptions, - ) { + ) -> bool { let _trace = bun::perf::trace("TestCommand.printCodeCoverage"); if ByteRangeMapping::map().is_none_or(|m| { // SAFETY: see `for_each_coverage_report`. unsafe { m.as_ref() }.is_empty() }) { - return; + return true; } let mut reports: Vec> = Vec::new(); Self::for_each_coverage_report(vm, opts, |report| reports.push(report.into_owned())); if let Err(err) = print_coverage_reports(opts, &reports) { Output::err(err, "Failed to write lcov.info", ()); - Global::exit(1); + return false; } + true } } @@ -1536,7 +1554,7 @@ fn print_coverage_reports_( opts: &mut CodeCoverageOptions, reports: &[CodeCoverageReport<'_>], ) -> bun_sys::Result<()> { - let relative_dir = FileSystem::get().top_level_dir; + let relative_dir = FileSystem::get().top_level_dir(); let thresholds = opts.fractions; let fractions: Vec = reports.iter().map(|r| r.fraction(&thresholds)).collect(); @@ -1646,14 +1664,6 @@ fn write_lcov_report( let _ = coverage::lcov::write_format(report, relative_dir, &mut contents); } - let mut fs = crate::node::fs::NodeFS::default(); - let _ = fs.mkdir_recursive(&crate::node::fs::args::Mkdir { - path: crate::node::PathLike::borrowed(&opts.reports_directory), - always_return_none: true, - recursive: true, - ..Default::default() - }); - let mut rand = [0u8; 8]; bun_boringssl_sys::rand_bytes(&mut rand); let mut tmpname: Vec = Vec::new(); @@ -1662,12 +1672,34 @@ fn write_lcov_report( ".lcov.info.{}.tmp", bun_core::fmt::hex_lower(&rand) ); + let too_long = || { + bun_sys::Error::from_code(bun_sys::E::ENAMETOOLONG, bun_sys::Tag::open) + .with_path(&opts.reports_directory) + }; + let mut tmp_buf = bun_paths::path_buffer_pool::get(); + let tmp_path = resolve_path::join_abs_string_buf_z_checked::( + relative_dir, + &mut tmp_buf, + &[&opts.reports_directory, &tmpname], + ) + .ok_or_else(too_long)?; let mut buf = bun_paths::path_buffer_pool::get(); - let tmp_path = resolve_path::join_abs_string_buf_z::( + let path = resolve_path::join_abs_string_buf_z_checked::( relative_dir, &mut buf, - &[&opts.reports_directory, &tmpname], - ); + &[&opts.reports_directory, b"lcov.info"], + ) + .ok_or_else(too_long)?; + if let Some(dir) = bun_path::dirname(path.as_bytes()) { + let mut fs = crate::node::fs::NodeFS::default(); + let _ = fs.mkdir_recursive(&crate::node::fs::args::Mkdir { + path: crate::node::PathLike::borrowed(dir), + always_return_none: true, + recursive: true, + ..Default::default() + }); + } + let file = File::openat( Fd::cwd(), tmp_path, @@ -1677,15 +1709,7 @@ fn write_lcov_report( let written = file.write_all(&contents); drop(file); let moved = match written { - Ok(()) => bun_sys::move_file_z( - Fd::cwd(), - tmp_path, - Fd::cwd(), - resolve_path::join_abs_string_z::( - relative_dir, - &[&opts.reports_directory, b"lcov.info"], - ), - ), + Ok(()) => bun_sys::move_file_z(Fd::cwd(), tmp_path, Fd::cwd(), path), err => err, }; if moved.is_err() { @@ -2054,7 +2078,7 @@ impl TestCommand { pretty_errorln!( "Test filter {} had no matches in --cwd={}", bun_fmt::quote(arg), - bun_fmt::quote(FileSystem::instance().top_level_dir) + bun_fmt::quote(FileSystem::instance().top_level_dir()) ); } else { pretty_errorln!( @@ -2130,20 +2154,32 @@ impl TestCommand { // Own the joined path in a hoisted buffer and borrow from it. let dir_to_scan_owned: Vec; - let dir_to_scan: &[u8] = 'brk: { + let (dir_to_scan, fits): (&[u8], bool) = 'brk: { if !ctx.debug.test_directory.is_empty() { - dir_to_scan_owned = resolve_path::join_abs::( - scanner.fs().top_level_dir, - &ctx.debug.test_directory, - ) - .into(); - break 'brk &dir_to_scan_owned; + let mut buf = bun_path::path_buffer_pool::get(); + let Some(joined) = + resolve_path::join_abs_string_buf_z_checked::( + scanner.fs().top_level_dir(), + &mut buf, + &[&ctx.debug.test_directory], + ) + else { + break 'brk (&ctx.debug.test_directory[..], false); + }; + dir_to_scan_owned = joined.as_bytes().into(); + break 'brk (&dir_to_scan_owned[..], true); } - break 'brk scanner.fs().top_level_dir; + break 'brk (scanner.fs().top_level_dir(), true); }; - match scanner.scan(dir_to_scan) { + // A root that's too long for a path buffer can't be opened either. + let scanned = if fits { + scanner.scan(dir_to_scan) + } else { + Err(scanner::ScanError::DoesNotExist) + }; + match scanned { Ok(()) => {} Err(scanner::ScanError::OutOfMemory) => bun::out_of_memory(), Err(scanner::ScanError::DoesNotExist) => { @@ -2151,7 +2187,7 @@ impl TestCommand { pretty_errorln!( "Failed to scan non-existent root directory for tests: {} in --cwd={}", bun_fmt::quote(dir_to_scan), - bun_fmt::quote(FileSystem::instance().top_level_dir) + bun_fmt::quote(FileSystem::instance().top_level_dir()) ); } else { pretty_errorln!( @@ -2411,6 +2447,7 @@ impl TestCommand { .unwrap() .snapshots .write_snapshot_file()?; + let mut write_coverage_success = true; if reporter.summary().pass > 20 && !Output::is_ai_agent() && !reporter.reporters.dots @@ -2463,7 +2500,7 @@ impl TestCommand { // Be very clear to ai. Output::err_generic( "0 test files matching **{{.test,.spec,_test_,_spec_}}.{{js,ts,jsx,tsx}} in --cwd={}", - (bun_fmt::quote(FileSystem::instance().top_level_dir),), + (bun_fmt::quote(FileSystem::instance().top_level_dir()),), ); } else { // Be friendlier to humans. @@ -2475,7 +2512,7 @@ impl TestCommand { if Output::is_ai_agent() { pretty_errorln!( "The following filters did not match any test files in --cwd={}:", - bun_fmt::quote(FileSystem::instance().top_level_dir) + bun_fmt::quote(FileSystem::instance().top_level_dir()) ); } else { pretty_errorln!( @@ -2526,7 +2563,7 @@ impl TestCommand { pretty_error!("\n"); if coverage_options.enabled && !ran_parallel { - reporter.generate_code_coverage(vm, &mut coverage_options); + write_coverage_success = reporter.generate_code_coverage(vm, &mut coverage_options); } // `Summary` is `Copy`; take a value snapshot so the `&mut` from @@ -2693,6 +2730,7 @@ impl TestCommand { && coverage_options.fractions.failing && coverage_options.fail_on_low_coverage) || !write_snapshots_success + || !write_coverage_success || reporter.jest.unhandled_errors_between_tests > 0 { vm.exit_handler.exit_code = 1; @@ -2870,7 +2908,6 @@ impl TestCommand { .filename_store .append_slice(resolution.path_pair.primary.text) .expect("oom"); - let file_title = resolve_path::relative(FileSystem::instance().top_level_dir, file_path); let file_id = jest::Jest::runner() .unwrap() .get_or_put_file(file_path) @@ -2916,6 +2953,9 @@ impl TestCommand { bun_jsc::cpp::JSMock__forgetPendingModulePatches(global); } + // Recompute on every repeat because the reporters overwrite `relative`'s buffer while the file runs. + let file_title = + resolve_path::relative(FileSystem::instance().top_level_dir(), file_path); // SAFETY: `set()` reads only `reporter.{worker_ipc_file_idx, reporters}` // and writes only `current_file` — disjoint fields. Fresh raw-ptr // split (not the defer-captured `reporter_ptr`) keeps the borrows @@ -2961,33 +3001,8 @@ impl TestCommand { reporter.summary().fail += 1; if reporter.jest.bail == reporter.summary().fail { - reporter.print_summary(); - pretty_error!( - "\nBailed out after {} failure{}\n", - reporter.jest.bail, - if reporter.jest.bail == 1 { "" } else { "s" } - ); - reporter.write_junit_report_if_needed(); - reporter.write_timings_if_needed(); - - vm.exit_handler.exit_code = 1; - vm.is_shutting_down = true; - // `global_exit()` diverges, so the `exit_file()` defer - // above never fires. Release the active file's - // `Strong`s and the preload-hook scope here so - // `Zig__GlobalObject__destructOnExit()`'s `collectNow()` can reclaim them, - // then clear `RUNNER` so finalizers can't observe a - // partially-torn-down `TestRunner`. - // SAFETY: single-threaded; raw-ptr reborrow mirrors the - // defer's escape. - unsafe { - (*bun_test_root_ptr).deinit_for_exit(); - jest::Jest::RUNNER.write(None); - } - let vm_ptr = std::ptr::from_mut::(vm); - // SAFETY: global_exit diverges; `vm_ptr` is a fresh - // raw-ptr reborrow of the exclusive `vm` borrow. - unsafe { (*vm_ptr).run_with_api_lock(|| (&mut *vm_ptr).global_exit()) }; + // This never returns, so the `exit_file()` defer above doesn't run. + reporter.bail_out(vm); } return Ok(()); diff --git a/src/runtime/cli/update_interactive_command.rs b/src/runtime/cli/update_interactive_command.rs index 90e759d64de3..6c40dc57d30d 100644 --- a/src/runtime/cli/update_interactive_command.rs +++ b/src/runtime/cli/update_interactive_command.rs @@ -348,7 +348,7 @@ impl UpdateInteractiveCommand { let bump = crate::cli::cli_arena(); for (workspace_path, workspace_update_idxs) in workspace_groups.iter() { - let root_dir = FileSystem::get().top_level_dir; + let root_dir = FileSystem::get().top_level_dir(); let mut path_buf = bun_paths::path_buffer_pool::get(); let package_json_path = Self::build_package_json_path(root_dir, workspace_path, &mut path_buf); @@ -408,7 +408,7 @@ impl UpdateInteractiveCommand { manager: &mut PackageManager, catalog_updates: &StringHashMap<(Box<[u8]>, bool)>, ) -> crate::Result<()> { - let root_dir = FileSystem::get().top_level_dir; + let root_dir = FileSystem::get().top_level_dir(); let mut path_buf = bun_paths::path_buffer_pool::get(); let package_json_path = Self::build_package_json_path(root_dir, b"", &mut path_buf); diff --git a/src/runtime/cli/upgrade_command.rs b/src/runtime/cli/upgrade_command.rs index 2cba3f5e5983..0ab50a055aef 100644 --- a/src/runtime/cli/upgrade_command.rs +++ b/src/runtime/cli/upgrade_command.rs @@ -822,7 +822,7 @@ impl UpgradeCommand { let Some(unzip_exe) = which( &mut unzip_path_buf, env_loader.map.get(b"PATH").unwrap_or(b""), - filesystem.top_level_dir, + filesystem.top_level_dir(), b"unzip", ) else { let _ = sys::unlinkat(&save_dir, tmpname); diff --git a/src/runtime/crypto/PBKDF2.rs b/src/runtime/crypto/PBKDF2.rs index 5d103ad03fe8..3ef936c1d71e 100644 --- a/src/runtime/crypto/PBKDF2.rs +++ b/src/runtime/crypto/PBKDF2.rs @@ -5,7 +5,7 @@ use bun_jsc::{ ArrayBuffer, CallFrame, JSGlobalObject, JSValue, Job, JobContext, JsResult, JsThread, Strong, }; -use crate::node::{Flavor, StringObjects, StringOrBuffer, ThreadIsolated, ThreadIsolatedArg}; +use crate::node::{Flavor, StringOrBuffer, ThreadIsolated, ThreadIsolatedArg}; use crate::crypto::evp::{self, Algorithm}; @@ -173,12 +173,7 @@ impl PBKDF2 { length: keylen, algorithm, }; - out.salt = match StringOrBuffer::from_js_maybe_async( - global_this, - arg1, - flavor, - StringObjects::Allow, - )? { + out.salt = match StringOrBuffer::from_js(global_this, arg1)? { Some(v) => v, None => { return Err(global_this.throw_invalid_argument_type_value( @@ -193,12 +188,7 @@ impl PBKDF2 { return Err(global_this.throw_invalid_arguments(format_args!("salt is too long"))); } - out.password = match StringOrBuffer::from_js_maybe_async( - global_this, - arg0, - flavor, - StringObjects::Allow, - )? { + out.password = match StringOrBuffer::from_js(global_this, arg0)? { Some(v) => v, None => { return Err(global_this.throw_invalid_argument_type_value( @@ -213,12 +203,6 @@ impl PBKDF2 { return Err(global_this.throw_invalid_arguments(format_args!("password is too long"))); } - if flavor == Flavor::Sync { - if let StringOrBuffer::Buffer(buffer) = &mut out.salt { - buffer.buffer = ArrayBuffer::from_typed_array(global_this, buffer.buffer.value); - } - } - let callback = match flavor { Flavor::Async => { if !arg5.is_function() { @@ -233,6 +217,19 @@ impl PBKDF2 { Flavor::Sync => JSValue::UNDEFINED, }; + // Re-read the salt because a String-object password's `toString` can resize or detach it. + match flavor { + Flavor::Sync => { + if let StringOrBuffer::Buffer(buffer) = &mut out.salt { + buffer.buffer = ArrayBuffer::from_typed_array(global_this, buffer.buffer.value); + } + } + Flavor::Async => { + out.salt.make_thread_isolated_copy(global_this)?; + out.password.make_thread_isolated_copy(global_this)?; + } + } + Ok((out, callback)) } @@ -242,7 +239,7 @@ impl PBKDF2 { call_frame: &CallFrame, ) -> JsResult<(ThreadIsolated, JSValue)> { let (data, callback) = Self::from_js(global_this, call_frame, Flavor::Async)?; - // SAFETY: parsed with `Flavor::Async`. + // SAFETY: `Flavor::Async` copied the buffers and thread-isolated the strings. Ok((unsafe { ThreadIsolated::new(data) }, callback)) } } diff --git a/src/runtime/crypto/pwhash.rs b/src/runtime/crypto/pwhash.rs index 34673983ed7a..0d38ca36885b 100644 --- a/src/runtime/crypto/pwhash.rs +++ b/src/runtime/crypto/pwhash.rs @@ -104,6 +104,7 @@ pub(crate) mod argon2 { E::DecodingFail | E::IncorrectType | E::IncorrectVersion => { crate::Error::InvalidEncoding } + E::MemoryAllocationFailed => crate::Error::Alloc(bun_alloc::AllocError), E::OutputTooShort | E::OutputTooLong | E::PwdTooShort diff --git a/src/runtime/dispatch.rs b/src/runtime/dispatch.rs index 8855e5167a2b..c1ad1c666d0d 100644 --- a/src/runtime/dispatch.rs +++ b/src/runtime/dispatch.rs @@ -276,6 +276,10 @@ pub(crate) fn run_task( // SAFETY: boxed in `on_raw_libuv_complete`; the arm consumes it. unsafe { bun_core::heap::take(cast_ptr!(crate::dns_jsc::LibuvCompleteHolder)) }.run(); } + #[cfg(windows)] + task_tag::UvDnsPollFree => { + crate::dns_jsc::UvDnsPoll::destroy(cast_ptr!(crate::dns_jsc::UvDnsPoll)); + } task_tag::ValkeyDeferredClose => { // SAFETY: boxed at the enqueue site; the arm consumes it. unsafe { @@ -501,6 +505,26 @@ pub(crate) fn run_task( task_tag::NapiFinalizerTask => { NapiFinalizerTask::run_on_js_thread(cast_ptr!(NapiFinalizerTask))?; } + task_tag::UvLoopDispatch => { + #[cfg(unix)] + { + use crate::napi::uv_posix::UvLoop; + // SAFETY: `schedule_dispatch` posted this VM's live loop. + unsafe { UvLoop::dispatch(cast_ptr!(UvLoop)) }; + } + #[cfg(not(unix))] + unreachable!("posix-only"); + } + task_tag::UvHandleClose => { + #[cfg(unix)] + { + use crate::napi::uv_posix::UvHandle; + // SAFETY: `uv_close` posted this handle. + unsafe { UvHandle::run_close(cast_ptr!(UvHandle)) }?; + } + #[cfg(not(unix))] + unreachable!("posix-only"); + } // ── JSC scheduler / module loader ──────────────────────────────── task_tag::JSCDeferredWorkTask => { @@ -728,7 +752,7 @@ fn run_task_cold(task: Task) { /// `release_task_unrun` track `bun_event_loop::task_tag::COUNT`. Bump when /// adding a variant — and give it an arm in both. const _: () = assert!( - task_tag::COUNT == 83, + task_tag::COUNT == 86, "dispatch::run_task / release_task_unrun arm count out of sync with bun_event_loop::task_tag", ); @@ -1251,9 +1275,7 @@ pub(crate) unsafe fn __bun_fire_timer( Ok(()) } EventLoopTimerTag::DevServerMemoryVisualizerTick => { - // SAFETY: per fn contract; `t` is the `memory_visualizer_timer` - // field of a live DevServer. - DevServer::emit_memory_visualizer_message_timer(unsafe { &mut *t }, unsafe { &*now }); + DevServer::emit_memory_visualizer_message_timer(t); Ok(()) } EventLoopTimerTag::BunTest => { @@ -1296,6 +1318,22 @@ pub(crate) unsafe fn __bun_fire_timer( crate::node::quic::QuicEndpoint::on_timer_fire(c); Ok(()) } + EventLoopTimerTag::UvTimer => { + #[cfg(unix)] + { + use crate::napi::uv_posix::UvTimerNode; + let c: *mut UvTimerNode = owner!(UvTimerNode, event_loop_timer); + // SAFETY: per fn contract, `c` is a started `uv_timer_t`'s node. + unsafe { UvTimerNode::on_fire(c) } + } + #[cfg(not(unix))] + { + if cfg!(debug_assertions) { + unreachable!("UvTimer timer on Windows, where addons get libuv's"); + } + Ok(()) + } + } }; fired } @@ -1481,6 +1519,18 @@ fn __bun_release_task_unrun(task: bun_event_loop::Task) { task_tag::ShellAsyncCpTask => release!(crate::node::fs::ShellAsyncCpTask), task_tag::StreamPending => release!(StreamPending), task_tag::ThreadSafeFunction => release!(ThreadSafeFunction), + task_tag::UvLoopDispatch => { + #[cfg(unix)] + release!(crate::napi::uv_posix::UvLoop); + #[cfg(not(unix))] + unreachable!("posix-only tag"); + } + task_tag::UvHandleClose => { + #[cfg(unix)] + release!(crate::napi::uv_posix::UvHandle); + #[cfg(not(unix))] + unreachable!("posix-only tag"); + } task_tag::ValkeyDeferredClose => { release!(crate::valkey_jsc::js_valkey::ValkeyDeferredClose) } @@ -1491,6 +1541,12 @@ fn __bun_release_task_unrun(task: bun_event_loop::Task) { #[cfg(not(windows))] unreachable!("windows-only tag"); } + task_tag::UvDnsPollFree => { + #[cfg(windows)] + release!(crate::dns_jsc::UvDnsPoll); + #[cfg(not(windows))] + unreachable!("windows-only tag"); + } task_tag::WindowsNamedPipeContext => { #[cfg(windows)] release!(crate::socket::WindowsNamedPipeContext); diff --git a/src/runtime/dns_jsc/dns.rs b/src/runtime/dns_jsc/dns.rs index 19736a8ac4d8..74e2b72876cd 100644 --- a/src/runtime/dns_jsc/dns.rs +++ b/src/runtime/dns_jsc/dns.rs @@ -2258,8 +2258,10 @@ pub(crate) mod internal { // To preserve memory, we use a 32 bit timestamp // However, we're almost out of time to use 32 bit timestamps for anything // So we set the epoch to January 1st, 2024 instead. + // + // Every VM's JS thread and the HTTP thread share the cache, so ignore fake timers. fn get_cache_timestamp() -> u32 { - (bun::Timespec::now(bun::TimespecMockMode::AllowMockedTime).ms_unsigned() / 1000) as u32 + (bun::Timespec::now(bun::TimespecMockMode::ForceRealTime).ms_unsigned() / 1000) as u32 } fn is_nearly_full(&self) -> bool { @@ -3650,6 +3652,12 @@ pub(crate) struct UvDnsPoll { // `Resolver::deref`, which may write/free `*this`. pub parent: *mut Resolver, pub socket: c_ares::ares_socket_t, + /// Number of `on_dns_poll_uv` calls for this handle on the stack. libuv + /// reads `poll` again after each one returns. + poll_cb_depth: u32, + /// True if a nested `uv_run` called `on_close_uv` while `poll_cb_depth` was + /// non-zero. The outermost `on_dns_poll_uv` call then queues the free. + closed: bool, pub poll: libuv::uv_poll_t, } @@ -3659,11 +3667,14 @@ impl UvDnsPoll { bun_core::heap::into_raw(Box::new(Self { parent, socket, + poll_cb_depth: 0, + closed: false, poll: bun_core::ffi::zeroed(), })) } - fn destroy(this: *mut Self) { + pub(crate) fn destroy(this: *mut Self) { + // SAFETY: `this` came from `heap::into_raw` in `new`, and nothing uses it after this call. unsafe { drop(bun_core::heap::take(this)) }; } @@ -3673,6 +3684,18 @@ impl UvDnsPoll { } } +#[cfg(windows)] +impl bun_event_loop::Taskable for UvDnsPoll { + const TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::UvDnsPollFree; + unsafe fn release_unrun(this: *mut Self) { + Self::destroy(this); + } + /// The task doesn't run JS. + unsafe fn context(_: *const Self) -> bun_event_loop::ContextId { + bun_event_loop::ContextId::NONE + } +} + #[derive(Clone, Copy)] pub(crate) enum CacheHit { Inflight(*mut get_addr_info_request::PendingCacheKey), // BORROW_FIELD into resolver buffer @@ -4651,34 +4674,44 @@ impl Resolver { ) { let poll = UvDnsPoll::from_poll(watcher); // SAFETY: `poll` is the live `UvDnsPoll` recovered from libuv's `watcher` - // via `from_poll` (libuv guarantees the handle outlives this callback). + // via `from_poll`. `on_close_uv` doesn't free it while `poll_cb_depth` is non-zero. // `parent` is the heap-allocated Resolver back-ptr (set in // `on_dns_socket_state`); it is kept alive across `Channel::process` by the // `_guard` below. `channel` is non-null because c-ares // must have been initialized for this poll callback to fire. unsafe { + (*poll).poll_cb_depth += 1; let parent: *mut Resolver = (*poll).parent; let vm = (*parent).vm.get(); - let _exit = vm.enter_event_loop_scope(); - // SAFETY: `parent` is the live heap-allocated Resolver back-ptr. - let _guard = RefPtr::init_ref(parent); - // channel must be non-null here as c_ares must have been initialized if we're receiving callbacks - let channel = (*parent).channel.get().unwrap(); - if status < 0 { - // an error occurred. just pretend that the socket is both readable and writable. - // https://github.com/nodejs/node/blob/8a41d9b636be86350cd32847c3f89d327c4f6ff7/src/cares_wrap.cc#L93 - (*channel).process((*poll).socket, true, true); - } else { - (*channel).process( - (*poll).socket, - events & libuv::UV_READABLE != 0, - events & libuv::UV_WRITABLE != 0, - ); - } + { + // Dropping `_exit` drains microtasks. That JS can run a nested `uv_run`, + // which calls `on_close_uv` for a socket c-ares closed below. + let _exit = vm.enter_event_loop_scope(); + // SAFETY: `parent` is the live heap-allocated Resolver back-ptr. + let _guard = RefPtr::init_ref(parent); + // channel must be non-null here as c_ares must have been initialized if we're receiving callbacks + let channel = (*parent).channel.get().unwrap(); + if status < 0 { + // an error occurred. just pretend that the socket is both readable and writable. + // https://github.com/nodejs/node/blob/8a41d9b636be86350cd32847c3f89d327c4f6ff7/src/cares_wrap.cc#L93 + (*channel).process((*poll).socket, true, true); + } else { + (*channel).process( + (*poll).socket, + events & libuv::UV_READABLE != 0, + events & libuv::UV_WRITABLE != 0, + ); + } - // See `on_dns_poll` for why this re-check follows `ares_process_fd`. - if !(*parent).any_requests_pending() { - (*parent).remove_timer(); + // See `on_dns_poll` for why this re-check follows `ares_process_fd`. + if !(*parent).any_requests_pending() { + (*parent).remove_timer(); + } + } + (*poll).poll_cb_depth -= 1; + if (*poll).poll_cb_depth == 0 && (*poll).closed { + // The task queue isn't drained until this libuv callback returns. + vm.as_mut().enqueue_task(jsc::Task::init(poll)); } } } @@ -4689,6 +4722,13 @@ impl Resolver { // to `uv_close`, which was `&mut UvDnsPoll::poll` (a `uv_poll_t` whose // header is `uv_handle_t`); `from_poll` recovers the containing struct. let poll = UvDnsPoll::from_poll(watcher.cast()); + // SAFETY: only the code below and the task `on_dns_poll_uv` queues free the struct. + unsafe { + if (*poll).poll_cb_depth > 0 { + (*poll).closed = true; + return; + } + } UvDnsPoll::destroy(poll); } diff --git a/src/runtime/dns_jsc/mod.rs b/src/runtime/dns_jsc/mod.rs index 8c98f18c67bf..5d195a75eb2c 100644 --- a/src/runtime/dns_jsc/mod.rs +++ b/src/runtime/dns_jsc/mod.rs @@ -11,6 +11,8 @@ #[path = "dns.rs"] mod dns_body; #[cfg(windows)] +pub(crate) use dns_body::UvDnsPoll; +#[cfg(windows)] pub(crate) use dns_body::lib_uv_backend::LibuvCompleteHolder; pub(crate) use dns_body::netc; diff --git a/src/runtime/ffi/FFI.h b/src/runtime/ffi/FFI.h index 9628e3b0a8b8..b9766f618728 100644 --- a/src/runtime/ffi/FFI.h +++ b/src/runtime/ffi/FFI.h @@ -64,6 +64,7 @@ typedef enum { } napi_status; BUN_FFI_IMPORT void* NapiHandleScope__open(void* napi_env, bool detached); BUN_FFI_IMPORT void NapiHandleScope__close(void* napi_env, void* handleScope); +BUN_FFI_IMPORT bool NapiEnv__throwPendingException(void* napi_env); BUN_FFI_IMPORT extern struct NapiEnv Bun__thisFFIModuleNapiEnv; #endif @@ -133,8 +134,21 @@ typedef void* JSContext; // The value is 6. // On ARM64_32, the value is something else but it really doesn't matter for our case // However, I don't want this to subtly break amidst future upgrades to JavaScriptCore -#define LOAD_ARGUMENTS_FROM_CALL_FRAME \ - int64_t *argsPtr = (int64_t*)((size_t*)callFrame + Bun_FFI_PointerOffsetToArgumentsList) +// +// The call frame only contains the arguments the caller passed. +// If the caller passed fewer than `count`, read from a copy padded with undefined. +static int64_t* Bun_FFI_padArguments(int64_t* passed, int32_t passedCount, int64_t* padded, int32_t count) { + int32_t i = 0; + for (; i < passedCount; i++) padded[i] = passed[i]; + for (; i < count; i++) padded[i] = TagValueUndefined; + return padded; +} + +#define LOAD_ARGUMENTS_FROM_CALL_FRAME(count) \ + int64_t *argsPtr = (int64_t*)((size_t*)callFrame + Bun_FFI_PointerOffsetToArgumentsList); \ + int32_t passedCount = ((EncodedJSValue*)callFrame)[Bun_FFI_PointerOffsetToArgumentCountIncludingThis].asBits.payload - 1; \ + int64_t paddedArgs[count]; \ + if (passedCount < count) argsPtr = Bun_FFI_padArguments(argsPtr, passedCount, paddedArgs, count) @@ -142,10 +156,10 @@ static bool JSVALUE_IS_CELL(EncodedJSValue val) __attribute__((__always_inline__ static bool JSVALUE_IS_INT32(EncodedJSValue val) __attribute__((__always_inline__)); static bool JSVALUE_IS_NUMBER(EncodedJSValue val) __attribute__((__always_inline__)); -static uint64_t JSVALUE_TO_UINT64(EncodedJSValue value) __attribute__((__always_inline__)); -static int64_t JSVALUE_TO_INT64(EncodedJSValue value) __attribute__((__always_inline__)); -uint64_t JSVALUE_TO_UINT64_SLOW(EncodedJSValue value); -int64_t JSVALUE_TO_INT64_SLOW(EncodedJSValue value); +// Converts an argument to the ABIType `abiType` the way dlopen() does. If it throws, it sets `*threw` and the wrapper must return. +uint64_t JSVALUE_TO_SLOT_SLOW(void* jsGlobalObject, int32_t abiType, bool* threw, int64_t value); +static uint64_t JSVALUE_TO_UINT64(void* jsGlobalObject, int32_t abiType, bool* threw, EncodedJSValue value) __attribute__((__always_inline__)); +static int64_t JSVALUE_TO_INT64(void* jsGlobalObject, int32_t abiType, bool* threw, EncodedJSValue value) __attribute__((__always_inline__)); EncodedJSValue UINT64_TO_JSVALUE_SLOW(void* jsGlobalObject, uint64_t val); EncodedJSValue INT64_TO_JSVALUE_SLOW(void* jsGlobalObject, int64_t val); @@ -159,7 +173,8 @@ static EncodedJSValue FLOAT_TO_JSVALUE(float val) __attribute__((__always_inline static EncodedJSValue BOOLEAN_TO_JSVALUE(bool val) __attribute__((__always_inline__)); static EncodedJSValue PTR_TO_JSVALUE(void* ptr) __attribute__((__always_inline__)); -static void* JSVALUE_TO_PTR(EncodedJSValue val) __attribute__((__always_inline__)); +static void* JSVALUE_TO_PTR(void* jsGlobalObject, int32_t abiType, bool* threw, EncodedJSValue val) __attribute__((__always_inline__)); +static void* JSVALUE_TO_BUFFER(void* jsGlobalObject, int32_t abiType, bool* threw, EncodedJSValue val) __attribute__((__always_inline__)); static int32_t JSVALUE_TO_INT32(EncodedJSValue val) __attribute__((__always_inline__)); static float JSVALUE_TO_FLOAT(EncodedJSValue val) __attribute__((__always_inline__)); static double JSVALUE_TO_DOUBLE(EncodedJSValue val) __attribute__((__always_inline__)); @@ -207,8 +222,9 @@ static uint64_t JSVALUE_TO_TYPED_ARRAY_LENGTH(EncodedJSValue val) { // Now, they're stored at the beginning of the 64-bit value // This behavior change enables the JIT to handle it better // It also is better readability when console.log(myPtr) -static void* JSVALUE_TO_PTR(EncodedJSValue val) { - if (val.asInt64 == TagValueNull) +static void* JSVALUE_TO_PTR(void* jsGlobalObject, int32_t abiType, bool* threw, EncodedJSValue val) { + // null or undefined + if ((val.asInt64 & ~UndefinedTag) == TagValueNull) return 0; if (JSCELL_IS_TYPED_ARRAY(val)) { @@ -219,9 +235,22 @@ static void* JSVALUE_TO_PTR(EncodedJSValue val) { return (void*)(uintptr_t)JSVALUE_TO_INT32(val); } - // Assume the JSValue is a double - val.asInt64 -= DoubleEncodeOffset; - return (void*)(uintptr_t)val.asDouble; + if (JSVALUE_IS_NUMBER(val)) { + val.asInt64 -= DoubleEncodeOffset; + return (void*)(uintptr_t)val.asDouble; + } + + // Accepts a JSCallback, an ArrayBuffer, a BigInt or an object with its own `ptr` data property. Throws on anything else. + return (void*)JSVALUE_TO_SLOT_SLOW(jsGlobalObject, abiType, threw, val.asInt64); +} + +static void* JSVALUE_TO_BUFFER(void* jsGlobalObject, int32_t abiType, bool* threw, EncodedJSValue val) { + if (JSCELL_IS_TYPED_ARRAY(val)) { + return JSVALUE_TO_TYPED_ARRAY_VECTOR(val); + } + + // Always throws. + return (void*)JSVALUE_TO_SLOT_SLOW(jsGlobalObject, abiType, threw, val.asInt64); } static EncodedJSValue PTR_TO_JSVALUE(void* ptr) { @@ -311,7 +340,7 @@ static bool JSVALUE_TO_BOOL(EncodedJSValue val) { } -static uint64_t JSVALUE_TO_UINT64(EncodedJSValue value) { +static uint64_t JSVALUE_TO_UINT64(void* jsGlobalObject, int32_t abiType, bool* threw, EncodedJSValue value) { if (JSVALUE_IS_INT32(value)) { return (uint64_t)JSVALUE_TO_INT32(value); } @@ -324,9 +353,10 @@ static uint64_t JSVALUE_TO_UINT64(EncodedJSValue value) { return (uint64_t)JSVALUE_TO_TYPED_ARRAY_LENGTH(value); } - return JSVALUE_TO_UINT64_SLOW(value); + // Accepts a BigInt. Throws on anything else. + return JSVALUE_TO_SLOT_SLOW(jsGlobalObject, abiType, threw, value.asInt64); } -static int64_t JSVALUE_TO_INT64(EncodedJSValue value) { +static int64_t JSVALUE_TO_INT64(void* jsGlobalObject, int32_t abiType, bool* threw, EncodedJSValue value) { if (JSVALUE_IS_INT32(value)) { return (int64_t)JSVALUE_TO_INT32(value); } @@ -335,7 +365,7 @@ static int64_t JSVALUE_TO_INT64(EncodedJSValue value) { return (int64_t)JSVALUE_TO_DOUBLE(value); } - return JSVALUE_TO_INT64_SLOW(value); + return (int64_t)JSVALUE_TO_SLOT_SLOW(jsGlobalObject, abiType, threw, value.asInt64); } static EncodedJSValue UINT64_TO_JSVALUE(void* jsGlobalObject, uint64_t val) { diff --git a/src/runtime/ffi/FFIObject.rs b/src/runtime/ffi/FFIObject.rs index 510d8ae872e2..d2874ad74fdd 100644 --- a/src/runtime/ffi/FFIObject.rs +++ b/src/runtime/ffi/FFIObject.rs @@ -264,11 +264,22 @@ pub(crate) mod reader { }; let mut addr = base; if let Some(off_value) = arguments.get(1) { - let off = off_value.to_int32(); - if off < 0 { - addr = addr.saturating_sub(off.unsigned_abs() as usize); - } else { - addr = addr.saturating_add(off as usize); + if off_value.is_number() { + if !off_value.as_number().is_finite() { + return Err(global_object.throw_invalid_arguments(format_args!( + "byteOffset must be a finite number" + ))); + } + let off = off_value.to_int64(); + if off < 0 { + addr = addr + .saturating_sub(usize::try_from(off.unsigned_abs()).unwrap_or(usize::MAX)); + } else { + addr = addr.saturating_add(usize::try_from(off).unwrap_or(usize::MAX)); + } + } else if !off_value.is_empty_or_undefined_or_null() { + return Err(global_object + .throw_invalid_arguments(format_args!("Expected number for byteOffset"))); } } if addr == 0 { diff --git a/src/runtime/ffi/abi_type.rs b/src/runtime/ffi/abi_type.rs index 56e82304f981..09e71f3a4eb5 100644 --- a/src/runtime/ffi/abi_type.rs +++ b/src/runtime/ffi/abi_type.rs @@ -106,44 +106,55 @@ bun_core::comptime_string_map! { // ToCFormatter / ToJSFormatter. Indexed by `self as usize`. // ───────────────────────────────────────────────────────────────────────────── +/// Selects the `FFI.h` function a generated wrapper calls to convert an argument. +enum ToC { + /// Either [`ToCFormatter`] writes the conversion inline or this isn't an argument type. + Special, + /// `f(arg)` + Infallible(&'static str), + /// `f(JS_GLOBAL_OBJECT, , &threw, arg)`. Can throw from `JSVALUE_TO_SLOT_SLOW`. + Fallible(&'static str), +} + struct AbiRow { c_type: &'static [u8], - to_c_macro: Option<&'static str>, + to_c: ToC, to_js: Option<(&'static str, &'static str)>, } #[rustfmt::skip] static ABI_TABLE: [AbiRow; 22] = { + use ToC::*; const fn r( c_type: &'static [u8], - to_c_macro: Option<&'static str>, + to_c: ToC, to_js: Option<(&'static str, &'static str)>, ) -> AbiRow { - AbiRow { c_type, to_c_macro, to_js } + AbiRow { c_type, to_c, to_js } } [ - /* Char */ r(b"char", Some("JSVALUE_TO_INT32("), Some(("INT32_TO_JSVALUE((int32_t)", ")"))), - /* Int8T */ r(b"int8_t", Some("JSVALUE_TO_INT32("), Some(("INT32_TO_JSVALUE((int32_t)", ")"))), - /* Uint8T */ r(b"uint8_t", Some("JSVALUE_TO_INT32("), Some(("INT32_TO_JSVALUE((int32_t)", ")"))), - /* Int16T */ r(b"int16_t", Some("JSVALUE_TO_INT32("), Some(("INT32_TO_JSVALUE((int32_t)", ")"))), - /* Uint16T */ r(b"uint16_t", Some("JSVALUE_TO_INT32("), Some(("INT32_TO_JSVALUE((int32_t)", ")"))), - /* Int32T */ r(b"int32_t", Some("JSVALUE_TO_INT32("), Some(("INT32_TO_JSVALUE((int32_t)", ")"))), - /* Uint32T */ r(b"uint32_t", Some("JSVALUE_TO_INT32("), Some(("UINT32_TO_JSVALUE(", ")"))), - /* Int64T */ r(b"int64_t", Some("JSVALUE_TO_INT64("), Some(("INT64_TO_JSVALUE_SLOW(JS_GLOBAL_OBJECT, ", ")"))), - /* Uint64T */ r(b"uint64_t", Some("JSVALUE_TO_UINT64("), Some(("UINT64_TO_JSVALUE_SLOW(JS_GLOBAL_OBJECT, ", ")"))), - /* Double */ r(b"double", Some("JSVALUE_TO_DOUBLE("), Some(("DOUBLE_TO_JSVALUE(", ")"))), - /* Float */ r(b"float", Some("JSVALUE_TO_FLOAT("), Some(("FLOAT_TO_JSVALUE(", ")"))), - /* Bool */ r(b"bool", Some("JSVALUE_TO_BOOL("), Some(("BOOLEAN_TO_JSVALUE(", ")"))), - /* Ptr */ r(b"void*", Some("JSVALUE_TO_PTR("), Some(("PTR_TO_JSVALUE(", ")"))), - /* Void */ r(b"void", None, None), - /* CString */ r(b"void*", Some("JSVALUE_TO_PTR("), Some(("PTR_TO_JSVALUE(", ")"))), - /* I64Fast */ r(b"int64_t", Some("JSVALUE_TO_INT64("), Some(("INT64_TO_JSVALUE(JS_GLOBAL_OBJECT, (int64_t)", ")"))), - /* U64Fast */ r(b"uint64_t", Some("JSVALUE_TO_UINT64("), Some(("UINT64_TO_JSVALUE(JS_GLOBAL_OBJECT, ", ")"))), - /* Function */ r(b"void*", Some("JSVALUE_TO_PTR("), Some(("PTR_TO_JSVALUE(", ")"))), - /* NapiEnv */ r(b"napi_env", None, None), - /* NapiValue */ r(b"napi_value", None, Some(("((EncodedJSValue) {.asNapiValue = ", " } )"))), - /* Buffer */ r(b"void*", Some("JSVALUE_TO_TYPED_ARRAY_VECTOR("), None), - /* BufferLen */ r(b"uint64_t", None, None), + /* Char */ r(b"char", Infallible("JSVALUE_TO_INT32"), Some(("INT32_TO_JSVALUE((int32_t)", ")"))), + /* Int8T */ r(b"int8_t", Infallible("JSVALUE_TO_INT32"), Some(("INT32_TO_JSVALUE((int32_t)", ")"))), + /* Uint8T */ r(b"uint8_t", Infallible("JSVALUE_TO_INT32"), Some(("INT32_TO_JSVALUE((int32_t)", ")"))), + /* Int16T */ r(b"int16_t", Infallible("JSVALUE_TO_INT32"), Some(("INT32_TO_JSVALUE((int32_t)", ")"))), + /* Uint16T */ r(b"uint16_t", Infallible("JSVALUE_TO_INT32"), Some(("INT32_TO_JSVALUE((int32_t)", ")"))), + /* Int32T */ r(b"int32_t", Infallible("JSVALUE_TO_INT32"), Some(("INT32_TO_JSVALUE((int32_t)", ")"))), + /* Uint32T */ r(b"uint32_t", Infallible("JSVALUE_TO_INT32"), Some(("UINT32_TO_JSVALUE(", ")"))), + /* Int64T */ r(b"int64_t", Fallible("JSVALUE_TO_INT64"), Some(("INT64_TO_JSVALUE_SLOW(JS_GLOBAL_OBJECT, ", ")"))), + /* Uint64T */ r(b"uint64_t", Fallible("JSVALUE_TO_UINT64"), Some(("UINT64_TO_JSVALUE_SLOW(JS_GLOBAL_OBJECT, ", ")"))), + /* Double */ r(b"double", Infallible("JSVALUE_TO_DOUBLE"), Some(("DOUBLE_TO_JSVALUE(", ")"))), + /* Float */ r(b"float", Infallible("JSVALUE_TO_FLOAT"), Some(("FLOAT_TO_JSVALUE(", ")"))), + /* Bool */ r(b"bool", Infallible("JSVALUE_TO_BOOL"), Some(("BOOLEAN_TO_JSVALUE(", ")"))), + /* Ptr */ r(b"void*", Fallible("JSVALUE_TO_PTR"), Some(("PTR_TO_JSVALUE(", ")"))), + /* Void */ r(b"void", Special, None), + /* CString */ r(b"void*", Fallible("JSVALUE_TO_PTR"), Some(("PTR_TO_JSVALUE(", ")"))), + /* I64Fast */ r(b"int64_t", Fallible("JSVALUE_TO_INT64"), Some(("INT64_TO_JSVALUE(JS_GLOBAL_OBJECT, (int64_t)", ")"))), + /* U64Fast */ r(b"uint64_t", Fallible("JSVALUE_TO_UINT64"), Some(("UINT64_TO_JSVALUE(JS_GLOBAL_OBJECT, ", ")"))), + /* Function */ r(b"void*", Fallible("JSVALUE_TO_PTR"), Some(("PTR_TO_JSVALUE(", ")"))), + /* NapiEnv */ r(b"napi_env", Special, None), + /* NapiValue */ r(b"napi_value", Special, None), + /* Buffer */ r(b"void*", Fallible("JSVALUE_TO_BUFFER"), None), + /* BufferLen */ r(b"uint64_t", Special, None), ] }; @@ -208,6 +219,11 @@ impl ABIType { matches!(self, ABIType::Double | ABIType::Float) } + /// True if converting an argument of this type can throw. See [`ToC::Fallible`]. + pub(crate) fn arg_conversion_can_throw(self) -> bool { + matches!(self.row().to_c, ToC::Fallible(_)) + } + pub(crate) fn to_c(self, symbol: &[u8]) -> ToCFormatter<'_> { ToCFormatter { tag: self, symbol } } @@ -233,18 +249,21 @@ pub(crate) struct ToCFormatter<'a> { impl fmt::Display for ToCFormatter<'_> { fn fmt(&self, writer: &mut fmt::Formatter<'_>) -> fmt::Result { - let row = self.tag.row(); - let Some(macro_) = row.to_c_macro else { - return match self.tag { + let symbol = BStr::new(self.symbol); + match self.tag.row().to_c { + ToC::Infallible(function) => write!(writer, "{function}({symbol})"), + ToC::Fallible(function) => write!( + writer, + "{function}(JS_GLOBAL_OBJECT, {}, &threw, {symbol})", + self.tag as i32 + ), + ToC::Special => match self.tag { ABIType::Void => Ok(()), ABIType::NapiEnv => writer.write_str("((napi_env)&Bun__thisFFIModuleNapiEnv)"), - ABIType::NapiValue => write!(writer, "{}.asNapiValue", BStr::new(self.symbol)), + ABIType::NapiValue => write!(writer, "{symbol}.asNapiValue"), _ => unreachable!(), - }; - }; - writer.write_str(macro_)?; - fmt::Display::fmt(BStr::new(self.symbol), writer)?; - writer.write_str(")") + }, + } } } @@ -260,6 +279,12 @@ impl fmt::Display for ToJSFormatter<'_> { None => match self.tag { ABIType::Void => Ok(()), ABIType::NapiEnv => writer.write_str("((napi_env)&Bun__thisFFIModuleNapiEnv)"), + // NULL has the same bits as the empty JSValue. Return `undefined` like Node does. + ABIType::NapiValue => write!( + writer, + "({sym} ? ((EncodedJSValue) {{.asNapiValue = {sym} }}) : ValueUndefined)", + sym = BStr::new(self.symbol) + ), ABIType::Buffer => writer.write_str("0"), _ => unreachable!(), }, diff --git a/src/runtime/ffi/ffi.classes.ts b/src/runtime/ffi/ffi.classes.ts index 3fc7bd28aa8c..12ea47c5ebc4 100644 --- a/src/runtime/ffi/ffi.classes.ts +++ b/src/runtime/ffi/ffi.classes.ts @@ -18,6 +18,6 @@ export default [ getter: "getSymbols", }, }, - values: ["symbolsValue"], + values: ["symbolsValue", "functionsValue"], }), ]; diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs index f6fbb13d66e2..6a3372a10963 100644 --- a/src/runtime/ffi/ffi_body.rs +++ b/src/runtime/ffi/ffi_body.rs @@ -64,12 +64,14 @@ fn dangerously_run_without_jit_protections(func: impl FnOnce() -> R) -> R { func() } +/// Mirrors `FFIFields` in `src/jsc/bindings/ffi.cpp`. #[repr(C)] struct Offsets { js_array_buffer_view_offset_of_length: u32, js_array_buffer_view_offset_of_byte_offset: u32, js_array_buffer_view_offset_of_vector: u32, js_cell_offset_of_type: u32, + call_frame_argument_count_including_this_slot: u32, } unsafe extern "C" { @@ -148,10 +150,14 @@ fn create_jsc_ffi_function( mod exposed_to_ffi { use super::{JSGlobalObject, JSValue}; unsafe extern "C" { - #[link_name = "JSC__JSValue__toInt64"] - pub(super) fn JSVALUE_TO_INT64(value: JSValue) -> i64; - #[link_name = "JSC__JSValue__toUInt64NoTruncate"] - pub(super) fn JSVALUE_TO_UINT64(value: JSValue) -> u64; + /// Defined in `JSCFFIBridge.cpp`. + #[link_name = "Bun__FFI__jsValueToSlotSlow"] + pub(super) fn JSVALUE_TO_SLOT_SLOW( + global: *mut JSGlobalObject, + abi_type: i32, + threw: *mut bool, + value: JSValue, + ) -> u64; #[link_name = "JSC__JSValue__fromInt64NoTruncate"] pub(super) fn INT64_TO_JSVALUE(global: *mut JSGlobalObject, i: i64) -> JSValue; #[link_name = "JSC__JSValue__fromUInt64NoTruncate"] @@ -1121,7 +1127,7 @@ impl FFI { object.get_own(global_this, &bun_core::String::borrow_utf8(b"source"))? { if source_value.is_array() { - compile_c.source = Source::Files(Vec::new()); + let mut files = Vec::new(); let mut iter = source_value.array_iterator(global_this)?; while let Some(value) = iter.next()? { if !value.is_string() { @@ -1131,10 +1137,16 @@ impl FFI { value, )); } - if let Source::Files(files) = &mut compile_c.source { - files.push(value.to_bun_string(global_this)?.to_owned_slice_z()); - } + files.push(value.to_bun_string(global_this)?.to_owned_slice_z()); + } + if files.is_empty() { + return Err(global_this.throw_invalid_argument_value_custom( + b"source", + source_value, + b"must have at least one file", + )); } + compile_c.source = Source::Files(files); } else if !source_value.is_string() { return Err(global_this.throw_invalid_argument_type_value( b"source", @@ -1188,7 +1200,9 @@ impl FFI { let napi_env = make_napi_env_if_needed(compile_c.symbols.map.values(), global_this); let obj = JSValue::create_empty_object(global_this, compile_c.symbols.map.len()); - for function in compile_c.symbols.map.values_mut() { + // `close()` closes the functions in this array. It can't use `obj` because JS can modify it. + let functions = JSValue::create_empty_array(global_this, compile_c.symbols.map.len())?; + for (i, function) in (0u32..).zip(compile_c.symbols.map.values_mut()) { // Clone the name before `compile(&mut self)` so the // immutable borrow of `function.base_name` doesn't overlap. let function_name = function.base_name.clone(); @@ -1223,6 +1237,7 @@ impl FFI { ); // `cb` is rooted by the `symbolsValue` cached own-property set below. obj.put(global_this, symbol_name, cb); + functions.put_index(global_this, i, cb)?; } } } @@ -1237,6 +1252,11 @@ impl FFI { let js_object = lib.to_js(global_this); symbols_value_set_cached(js_object, global_this, obj); + crate::generated_classes::js_FFI::functions_value_set_cached( + js_object, + global_this, + functions, + ); Ok(js_object) } @@ -1309,8 +1329,26 @@ impl FFI { } #[bun_jsc::host_fn(method)] - pub(crate) fn close(&self, _global_this: &JSGlobalObject, _: &CallFrame) -> JsResult { + pub(crate) fn close( + &self, + global_this: &JSGlobalObject, + callframe: &CallFrame, + ) -> JsResult { + unsafe extern "C" { + fn Bun__FFI__closeFunctions(global: *const JSGlobalObject, library: JSValue) -> bool; + } jsc::mark_binding(); + // SAFETY: thin FFI wrapper. The C++ side type-checks the cell (dynamicDowncast) before use. + let is_running = unsafe { Bun__FFI__closeFunctions(global_this, callframe.this()) }; + if is_running { + // The running call returns into the compiled code, so leak it instead of letting `do_close` free it. + self.shared_state.set(None); + self.functions.with_mut(|functions| { + for function in functions.values_mut() { + function.state = None; + } + }); + } self.do_close(); Ok(JSValue::UNDEFINED) } @@ -1323,7 +1361,16 @@ impl FFI { if let Some(dylib) = self.dylib.replace(None) { dylib.close(); } - if let Some(state) = self.shared_state.take() { + // C code with a `napi_env` can register callbacks into itself that are never unregistered, so leak it. + let has_napi_env = self + .functions + .get() + .values() + .iter() + .any(Function::needs_napi_env); + if let Some(state) = self.shared_state.take() + && !has_napi_env + { // SAFETY: state is a valid TCC::State pointer; we have exclusive ownership unsafe { TCC::State::destroy(state.as_ptr()) }; } @@ -1560,7 +1607,14 @@ impl FFI { return ret; } }; - obj.put(global, symbol_name, cb); + // Symbol names are user input, so one can be an array index like "0". + let key = bun_core::String::borrow_utf8(function_name.as_bytes()); + if let Err(err) = obj.put_may_be_index(global, &key, cb) { + dylib.close(); + // SAFETY: lib_ptr is the live, JS-owned FFI allocation (from into_raw). + unsafe { &*lib_ptr }.do_close(); + return Err(err); + } } // SAFETY: lib_ptr is the live, JS-owned FFI allocation (from into_raw). @@ -1645,7 +1699,13 @@ impl FFI { return err; } }; - obj.put(global, symbol_name, cb); + // Symbol names are user input, so one can be an array index like "0". + let key = bun_core::String::borrow_utf8(function_name.as_bytes()); + if let Err(err) = obj.put_may_be_index(global, &key, cb) { + // SAFETY: lib_ptr is the live, JS-owned FFI allocation (from into_raw). + unsafe { &*lib_ptr }.do_close(); + return Err(err); + } } // SAFETY: lib_ptr is the live, JS-owned FFI allocation (from into_raw). @@ -2095,14 +2155,12 @@ impl Function { ZIG_REPR_TYPE JSFunctionCall(void* JS_GLOBAL_OBJECT, void* callFrame) {\n", )?; - if self.needs_handle_scope() { - writer.write_all( - b" void* handleScope = NapiHandleScope__open(&Bun__thisFFIModuleNapiEnv, false);\n", - )?; - } - if !self.arg_types.is_empty() { - writer.write_all(b" LOAD_ARGUMENTS_FROM_CALL_FRAME;\n")?; + writeln!( + writer, + " LOAD_ARGUMENTS_FROM_CALL_FRAME({});", + self.arg_types.len() + )?; for (i, arg) in self.arg_types.iter().enumerate() { if *arg == ABIType::NapiEnv { write!( @@ -2141,6 +2199,35 @@ impl Function { } let mut arg_buf = [0u8; 512]; + arg_buf[0..3].copy_from_slice(b"arg"); + + // Convert first so a conversion that throws returns before the handle scope opens and the native call runs. + let mut declared_threw = false; + for (i, arg) in self.arg_types.iter().enumerate() { + if !arg.arg_conversion_can_throw() { + continue; + } + if !declared_threw { + declared_threw = true; + writer.write_all(b" bool threw = false;\n")?; + } + let length_buf = bun_core::fmt::print_int(&mut arg_buf[3..], i); + let arg_name = &arg_buf[0..3 + length_buf]; + writer.write_all(b" ")?; + arg.typename(writer)?; + write!( + writer, + " converted{} = {};\n if (threw) return 0;\n", + i, + arg.to_c(arg_name) + )?; + } + + if self.needs_handle_scope() { + writer.write_all( + b" void* handleScope = NapiHandleScope__open(&Bun__thisFFIModuleNapiEnv, false);\n", + )?; + } writer.write_all(b" ")?; if self.return_type != ABIType::Void { @@ -2149,7 +2236,6 @@ impl Function { } write!(writer, "{}(", BStr::new(self.base_name.as_bytes()))?; first = true; - arg_buf[0..3].copy_from_slice(b"arg"); for (i, arg) in self.arg_types.iter().enumerate() { if !first { writer.write_all(b", ")?; @@ -2159,7 +2245,9 @@ impl Function { let length_buf = bun_core::fmt::print_int(&mut arg_buf[3..], i); let arg_name = &arg_buf[0..3 + length_buf]; - if arg.needs_a_cast_in_c() { + if arg.arg_conversion_can_throw() { + write!(writer, "converted{}", i)?; + } else if arg.needs_a_cast_in_c() { write!(writer, "{}", arg.to_c(arg_name))?; } else { writer.write_all(arg_name)?; @@ -2175,7 +2263,8 @@ impl Function { if self.needs_handle_scope() { writer.write_all( - b" NapiHandleScope__close(&Bun__thisFFIModuleNapiEnv, handleScope);\n", + b" NapiHandleScope__close(&Bun__thisFFIModuleNapiEnv, handleScope);\n\ + \x20 if (NapiEnv__throwPendingException(&Bun__thisFFIModuleNapiEnv)) return 0;\n", )?; } @@ -2490,6 +2579,10 @@ impl CompilerRT { "Bun_FFI_PointerOffsetToArgumentsList", bun_jsc::sizes::BUN_FFI_POINTER_OFFSET_TO_ARGUMENTS_LIST as i64, ), + ( + "Bun_FFI_PointerOffsetToArgumentCountIncludingThis", + offsets.call_frame_argument_count_including_this_slot as i64, + ), ( "JSArrayBufferView__offsetOfLength", offsets.js_array_buffer_view_offset_of_length as i64, @@ -2528,6 +2621,7 @@ impl CompilerRT { unsafe extern "C" { fn NapiHandleScope__open(env: *mut napi::NapiEnv, escapable: bool) -> *mut c_void; fn NapiHandleScope__close(env: *mut napi::NapiEnv, current: *mut c_void); + fn NapiEnv__throwPendingException(env: *mut napi::NapiEnv) -> bool; } state .add_symbol( @@ -2541,17 +2635,17 @@ impl CompilerRT { NapiHandleScope__close as *const c_void, ) .expect("unreachable"); - state .add_symbol( - zstr!("JSVALUE_TO_INT64_SLOW"), - WORKAROUND.jsvalue_to_int64 as *const c_void, + zstr!("NapiEnv__throwPendingException"), + NapiEnv__throwPendingException as *const c_void, ) .expect("unreachable"); + state .add_symbol( - zstr!("JSVALUE_TO_UINT64_SLOW"), - WORKAROUND.jsvalue_to_uint64 as *const c_void, + zstr!("JSVALUE_TO_SLOT_SLOW"), + exposed_to_ffi::JSVALUE_TO_SLOT_SLOW as *const c_void, ) .expect("unreachable"); state @@ -2570,15 +2664,11 @@ impl CompilerRT { } struct MyFunctionSStructWorkAround { - jsvalue_to_int64: unsafe extern "C" fn(JSValue) -> i64, - jsvalue_to_uint64: unsafe extern "C" fn(JSValue) -> u64, int64_to_jsvalue: unsafe extern "C" fn(*mut JSGlobalObject, i64) -> JSValue, uint64_to_jsvalue: unsafe extern "C" fn(*mut JSGlobalObject, u64) -> JSValue, } static WORKAROUND: MyFunctionSStructWorkAround = MyFunctionSStructWorkAround { - jsvalue_to_int64: exposed_to_ffi::JSVALUE_TO_INT64, - jsvalue_to_uint64: exposed_to_ffi::JSVALUE_TO_UINT64, int64_to_jsvalue: exposed_to_ffi::INT64_TO_JSVALUE, uint64_to_jsvalue: exposed_to_ffi::UINT64_TO_JSVALUE, }; diff --git a/src/runtime/image/Image.rs b/src/runtime/image/Image.rs index b31492c3c963..11eb789f000a 100644 --- a/src/runtime/image/Image.rs +++ b/src/runtime/image/Image.rs @@ -704,7 +704,7 @@ impl Image { /// documented and `.shared`/`.resizable` are refused at construction. The /// codec layer is hardened so a hostile mid-decode mutation degrades to /// `DecodeFailed`, not OOB/heap-leak — see `codec_jpeg.rs` cropping + - /// post-check, `codec_webp.rs` dim re-check. (If the attacker already runs + /// post-check, `codec_webp.rs` owned canvas. (If the attacker already runs /// JS in-process the threat model is moot anyway; the surface that matters /// is hostile *bytes*, which the codec validation handles.) fn pin_for_task( diff --git a/src/runtime/image/codec_webp.rs b/src/runtime/image/codec_webp.rs index 6296196d7a3a..f6a9e69d5d1a 100644 --- a/src/runtime/image/codec_webp.rs +++ b/src/runtime/image/codec_webp.rs @@ -9,7 +9,13 @@ use crate::encoded_wrap_free; unsafe extern "C" { pub(crate) fn WebPGetInfo(data: *const u8, len: usize, w: *mut c_int, h: *mut c_int) -> c_int; - fn WebPDecodeRGBA(data: *const u8, len: usize, w: *mut c_int, h: *mut c_int) -> *mut u8; + fn WebPDecodeRGBAInto( + data: *const u8, + len: usize, + output: *mut u8, + output_len: usize, + output_stride: c_int, + ) -> *mut u8; fn WebPEncodeRGBA( rgba: *const u8, w: c_int, @@ -31,7 +37,7 @@ unsafe extern "C" { // ─── libwebpmux / libwebpdemux ────────────────────────────────────────────── // WebP carries colour profiles (and EXIF/XMP) in a VP8X RIFF container that // wraps the VP8/VP8L bitstream. `WebPEncodeRGBA` only emits the bare -// bitstream chunk, and `WebPDecodeRGBA` only reads it — neither touches +// bitstream chunk, and `WebPDecodeRGBAInto` only reads it — neither touches // the surrounding chunks. To pull an ICCP chunk out of an input (decode) // or to attach one to an output (encode) we go through the separate // demux/mux APIs, which operate on the whole RIFF file. Both are @@ -134,31 +140,28 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result()) } - }); // `bytes` is a borrowed view of a JS ArrayBuffer the user can still WRITE - // (the pin only blocks detach), so a hostile caller can swap in a smaller - // WebP between WebPGetInfo and WebPDecodeRGBA. libwebp re-parses on the - // second call and writes the actual decoded dims back into cw/ch — reject - // any mismatch instead of trusting the probe and over-reading the - // smaller allocation. (Same race the CG shim guards at :298.) - if u32::try_from(cw).ok() != Some(w) || u32::try_from(ch).ok() != Some(h) { + // (the pin only blocks detach), so the decoder can see a different image + // than the probe did. libwebp fails if that image is bigger than `out`. + // If it's smaller, the rest of `out` is zero. + let stride: usize = (w as usize) * 4; + let mut out: Vec = vec![0; stride * (h as usize)]; + // SAFETY: `bytes` is a valid readable slice and `out` is a valid writable one. + let decoded = unsafe { + WebPDecodeRGBAInto( + bytes.as_ptr(), + bytes.len(), + out.as_mut_ptr(), + out.len(), + c_int::try_from(stride).map_err(|_| codecs::Error::DecodeFailed)?, + ) + }; + if decoded.is_null() { return Err(codecs::Error::DecodeFailed); } - let len: usize = (w as usize) * (h as usize) * 4; - // SAFETY: WebPDecodeRGBA returns a buffer of w*h*4 bytes on success. - let out: Vec = unsafe { core::slice::from_raw_parts(ptr, len) }.to_vec(); // Extract the ICCP chunk (if any) from the RIFF container. A plain - // VP8/VP8L WebP with no VP8X wrapper has no ICCP — `WebPDemux` still - // succeeds, `WEBP_FF_FORMAT_FLAGS` returns 0, and we skip the chunk + // VP8/VP8L WebP with no VP8X wrapper has no ICCP, and we skip the chunk // walk. The chunk iterator hands back a borrowed view into `bytes`; // dupe into the global allocator to match JPEG/PNG ownership so the // pipeline can free it uniformly. Propagate OutOfMemory on the dupe @@ -169,6 +172,13 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result> = 'blk: { + // Byte 20 is the flags byte of a leading VP8X chunk. + if bytes.len() <= 20 || &bytes[12..16] != b"VP8X" || u32::from(bytes[20]) & ICCP_FLAG == 0 { + break 'blk None; + } + // The demuxer reads each chunk tag more than once and crashes if the + // reads disagree, so give it a copy that JS can't write to. + let bytes = bytes.to_vec(); let data = WebPData { bytes: bytes.as_ptr(), size: bytes.len(), diff --git a/src/runtime/ipc.rs b/src/runtime/ipc.rs index 9f4ac201827f..a2a9ea193726 100644 --- a/src/runtime/ipc.rs +++ b/src/runtime/ipc.rs @@ -153,7 +153,7 @@ impl IncomingBuffer { fn init(mode: Mode) -> IncomingBuffer { match mode { Mode::Advanced => IncomingBuffer::Advanced(Vec::::default()), - Mode::Json => IncomingBuffer::Json(JSONLineBuffer::default()), + Mode::Json => IncomingBuffer::Json(JSONLineBuffer::new(json::max_line_len())), } } } @@ -262,6 +262,11 @@ mod advanced { const HEADER_LENGTH: usize = size_of::() + size_of::(); // HEADER_LENGTH is a 5-byte compile-time constant; narrowing to u32 is provably safe. const HEADER_LENGTH_U32: u32 = HEADER_LENGTH as u32; + + /// Returns the longest body the deserializer's `WTF::Vector` can store without crashing. + fn max_message_len() -> usize { + BunString::max_length() + } // v2 added `SerializedMessageWithBuffers`. The peer's advertised version is // debug-logged, never consulted, so mixed-version pairs only break when a // Buffer-bearing message actually crosses to a v1 peer. @@ -334,7 +339,8 @@ mod advanced { || x == IPCMessageType::SerializedInternalMessage as u8 || x == IPCMessageType::SerializedMessageWithBuffers as u8 => { - if message_len > u32::MAX - HEADER_LENGTH_U32 { + // Reject based on the header, before buffering the body. + if message_len as usize > max_message_len() { return Err(IPCDecodeError::InvalidFormat); } // `header_length + message_len` would be evaluated as u32; a peer-controlled @@ -453,6 +459,12 @@ mod json { // 2 is internal // ["[{\d\.] is regular + /// Returns the longest line, not counting the newline, that can become a string. + pub(super) fn max_line_len() -> usize { + // The longest string in UTF-8 (at most 3 bytes per code unit), plus an internal message's tag byte. + BunString::max_length().saturating_mul(3).saturating_add(1) + } + pub(super) fn decode_ipc_message( data: &[u8], global_this: &JSGlobalObject, @@ -894,6 +906,9 @@ enum ContinueSendReason { OnWritable, } +/// The transport returns the number of bytes written as an `i32`. +const MAX_WRITE_LEN: usize = i32::MAX as usize; + #[derive(bun_ptr::CellRefCounted)] pub(crate) struct SendQueue { ref_count: Cell, @@ -1536,6 +1551,7 @@ impl SendQueue { enum Done { AwaitAck, Completed(SendHandle), + Capped, Partial, NoProgress, Error, @@ -1543,7 +1559,10 @@ impl SendQueue { let done = self.queue.with_mut(|queue| { let first = &mut queue[0]; let to_send_len = first.data.list.len() - first.data.cursor; - if n as usize == to_send_len { + let Ok(written) = usize::try_from(n) else { + return Done::Error; + }; + if written == to_send_len { if first.handle.is_some() { // the message was fully written, but it had a handle. // we must wait for ACK or NACK before sending any more messages. @@ -1561,12 +1580,16 @@ impl SendQueue { // shift the queue and try to send the next item immediately. Done::Completed(queue.remove(0)) } - } else if n > 0 && n < i32::try_from(first.data.list.len()).expect("int cast") { + } else if written > 0 && written < to_send_len { // the item was partially sent; update the cursor and wait for writable to send the rest // (if we tried to send a handle, a partial write means the handle wasn't sent yet.) - first.data.cursor += usize::try_from(n).expect("int cast"); - Done::Partial - } else if n == 0 { + first.data.cursor += written; + if written == MAX_WRITE_LEN { + Done::Capped + } else { + Done::Partial + } + } else if written == 0 { // no bytes written; wait for writable Done::NoProgress } else { @@ -1581,6 +1604,10 @@ impl SendQueue { item.complete(&global_this); // call the callback & deinit self.continue_send(&global_this, ContinueSendReason::OnWritable); } + Done::Capped => { + // The transport accepted all `MAX_WRITE_LEN` bytes, so it won't send a writable event. + self.continue_send(&global_this, ContinueSendReason::OnWritable); + } Done::Partial | Done::NoProgress => {} Done::Error => { // error. close socket. @@ -1705,7 +1732,7 @@ impl SendQueue { let first = &queue[0]; let data = &first.data.list[first.data.cursor..]; log!("SendQueue#write len {}", data.len()); - let write_len = data.len().min(i32::MAX as usize); + let write_len = data.len().min(MAX_WRITE_LEN); Box::from(&data[0..write_len]) }); @@ -1760,6 +1787,7 @@ impl SendQueue { let first = &queue[0]; let data = &first.data.list[first.data.cursor..]; log!("SendQueue#write len {}", data.len()); + let data = &data[..data.len().min(MAX_WRITE_LEN)]; if let Some(fd_unwrapped) = fd { socket.write_fd(data, fd_unwrapped.native()) } else { @@ -2279,6 +2307,10 @@ fn decode_next_json(incoming: &JsCell, global: &JSGlobalObject) unreachable!() }; let Some(msg) = json_buf.next() else { + if json_buf.is_too_long() { + // Fail the same way as a complete line that's too long to become a string. + return DecodeStep::Fail(IPCDecodeError::Js(JsError::OutOfMemory)); + } return DecodeStep::Wait; }; match decode_ipc_message(Mode::Json, msg.data, global, Some(msg.newline_pos)) { diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 5e6e7e37adbc..4a9da411e5f9 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -84,6 +84,8 @@ pub(crate) struct RuntimeState { /// leaked per-VM (PORTING.md §Forbidden: leaking only for true /// process-lifetime singletons via `OnceLock`, which a per-VM arena is not). pub(crate) transpiler_arena: Box, + /// Moved from `InitOptions::owned_env_loader`. `vm.transpiler` points to it. + pub(crate) owned_env_loader: Option>, /// `vm.body_value_pool` — pooled storage for `Body.Value` /// (`Request.body` payloads). /// Boxed because `HiveAllocator` is `Fallback, 256>` @@ -103,6 +105,10 @@ pub(crate) struct RuntimeState { pub(crate) cron_jobs: Vec>, /// `bun lint`: what runs the rules of JavaScript plugins in this VM. pub(crate) lint: core::cell::OnceCell>, + /// The `uv_loop_t` that addons get for this VM. Stored inline because addons + /// keep its address for as long as the VM lives. + #[cfg(unix)] + pub(crate) uv_loop: crate::napi::uv_posix::UvLoop, } thread_local! { @@ -131,7 +137,7 @@ pub(crate) fn runtime_state() -> *mut RuntimeState { /// Note: `bun_jsc::VirtualMachine.timer` is a `()` placeholder; /// the real `All` lives in [`RuntimeState::timer`] until that slot widens. /// Null only before [`init_runtime_state`] has run (e.g. `bun_jsc` unit tests -/// with no high tier, or `Bun__Timer__getNextID` racing init). +/// with no high tier). /// /// Returns `*mut` (NOT `&mut`) so callers that are themselves fields of `All` /// (`DateHeaderTimer`, `EventLoopDelayMonitor`, `FakeTimers`) can dereference @@ -351,6 +357,7 @@ unsafe fn init_runtime_state( // allocations use the same heap as the global allocator and skip the // `mi_heap_new`/`mi_heap_destroy` pair. transpiler_arena: Box::new(bun_alloc::Arena::borrowing_default()), + owned_env_loader: opts.owned_env_loader.take(), body_value_pool: { // `Box::new(ManuallyDrop::new(HiveAllocator::init()))` still builds the // ~100 KB pool in a stack temporary before moving it into the box (see @@ -372,11 +379,18 @@ unsafe fn init_runtime_state( wake_ctx: None, cron_jobs: Vec::new(), lint: core::cell::OnceCell::new(), + // SAFETY: `vm` is the live VM being initialized. `VirtualMachine::init` + // wrote `handle` before calling this hook (the wake context below reads + // it the same way). + #[cfg(unix)] + uv_loop: unsafe { + crate::napi::uv_posix::UvLoop::new(ptr::NonNull::new_unchecked(vm), (*vm).handle()) + }, })); RUNTIME_STATE.with(|c| c.set(state)); // `Timespec::now_allow_mocked_time` reads `bun_core::mock_time` directly; - // `FakeTimers::CurrentTime::{set,clear}` write that storage so timers + // `FakeTimers::{set_now,clear_now}` write that storage so timers // scheduled under `jest.useFakeTimers()` use the mocked epoch. // ── vm.transpiler ──────────────────────────────────────────────────── @@ -421,7 +435,14 @@ unsafe fn init_runtime_state( // Forward `opts.env_loader` so the VM // shares the caller's `DotEnv.Loader` (e.g. `bun test` writes // `NODE_ENV=test` into it after init). - match bun_bundler::Transpiler::init(arena, log, args, opts.env_loader.map(|p| p.as_ptr())) { + // SAFETY: same as the arena above. + let env_loader: Option<*mut bun_dotenv::Loader> = unsafe { + match (*state).owned_env_loader.as_deref_mut() { + Some(owned) => Some(ptr::from_mut(owned)), + None => opts.env_loader.map(|p| p.as_ptr()), + } + }; + match bun_bundler::Transpiler::init(arena, log, args, env_loader) { Ok(transpiler) => { // SAFETY: `vm` is the unique freshly-boxed VM; `transpiler` // field is zero-init'd uninhabited memory (never dropped). @@ -702,7 +723,7 @@ fn generate_entry_point(_vm: &VirtualMachine, watch: bool, entry_path: &[u8]) -> /// /// Error mapping: resolver `Failure` returns the resolver error, /// `Pending`/`NotFound` returns `error.ModuleNotFound`, -/// `JSModuleLoader.import` throwing returns `error.JSError`. +/// `JSModuleLoader.import` throwing returns a rejected promise. /// /// # Safety /// `vm` is the live per-thread VM. @@ -724,6 +745,8 @@ unsafe fn load_preloads(vm: *mut VirtualMachine) -> bun_jsc::CrateResult<*mut JS // SAFETY: per fn contract. unsafe { (*vm_for_guard).is_in_preload = false }; } + // SAFETY: per fn contract. + let kept_for_this_run = core::mem::take(unsafe { &mut (*vm).preloads_kept_for_reload }); // SAFETY: `vm.global` is set during `VirtualMachine::init` and outlives the VM. let global: *mut JSGlobalObject = unsafe { &*vm }.global; @@ -732,7 +755,7 @@ unsafe fn load_preloads(vm: *mut VirtualMachine) -> bun_jsc::CrateResult<*mut JS // failure and `VirtualMachine::init` propagates it via `?`, so a VM that // failed to build its transpiler never reaches `load_preloads` (this hook // only runs via `reload_entry_point*`, which operate on an already-`Ok` VM). - let top_level_dir: *const [u8] = Fs::FileSystem::get().top_level_dir; + let top_level_dir: *const [u8] = Fs::FileSystem::get().top_level_dir(); // SAFETY: per fn contract. let global_cache = if unsafe { &*vm }.standalone_module_graph.is_none() { GlobalCache::read_only @@ -820,11 +843,15 @@ unsafe fn load_preloads(vm: *mut VirtualMachine) -> bun_jsc::CrateResult<*mut JS let promise: *mut JSInternalPromise = match JSModuleLoader::import_ptr(global, &module_name) { Ok(p) => p.as_ptr(), - Err(_) => { - // The exception is - // already pending on `global`; bubble the tag so - // `reload_entry_point` forwards it. - return Err(bun_jsc::CrateError::JSError); + // Report a preload that fails to resolve the same way as one that fails to load. + Err(err) => { + let rejected = JSInternalPromise::rejected_promise_with_caught_exception( + JSGlobalObject::opaque_ref(global), + err, + )?; + // The caller reports the error, so don't also report it as an unhandled rejection. + rejected.set_handled(); + return Ok(ptr::from_mut(rejected)); } }; @@ -833,47 +860,9 @@ unsafe fn load_preloads(vm: *mut VirtualMachine) -> bun_jsc::CrateResult<*mut JS let _protected = JSValue::from_cell(promise).protected(); // ── wait ──────────────────────────────────────────────────────── - // HMR `pending_internal_promise` swap loop; non-watcher path uses - // `wait_for_promise` directly. - { - // SAFETY: per fn contract. - if unsafe { &*vm }.is_watcher_enabled() { - // pending_internal_promise can change if hot module reloading is - // enabled. - // SAFETY: `el` is the live per-thread event loop. - let el = unsafe { &*vm }.event_loop(); - loop { - // SAFETY: `pending_internal_promise` was set just above (or - // swapped by HMR to another live cell); `status()` is a - // read-only FFI call on a live JSC heap cell. - let pip = unsafe { &*vm } - .pending_internal_promise() - .unwrap_or(promise); - // SAFETY: `pip` is a live JSC heap cell (set just above or - // the protected `promise` fallback). - if unsafe { &*pip }.status() != PromiseStatus::Pending { - break; - } - // SAFETY: `el` is the live per-thread event loop. - unsafe { (*el).tick() }; - // SAFETY: per fn contract — `vm` is the live per-thread VM. - let pip = unsafe { &*vm } - .pending_internal_promise() - .unwrap_or(promise); - // SAFETY: `pip` is a live JSC heap cell (see above). - if unsafe { &*pip }.status() == PromiseStatus::Pending { - // SAFETY: per fn contract — short-lived `&mut *vm` for the - // dispatched `auto_tick` hook (same shape as the - // non-watcher `wait_for_promise` arm). - unsafe { (*vm).auto_tick() }; - } - } - } else { - // SAFETY: per fn contract — short-lived `&mut *vm`; `promise` is a - // live protected JSC heap cell. - let _ = unsafe { (*vm).wait_for_promise(AnyPromise::Internal(promise)) }; - } - } + // SAFETY: per fn contract — short-lived `&mut *vm`; `promise` is a live + // protected JSC heap cell. + let _ = unsafe { (*vm).wait_for_promise(AnyPromise::Internal(promise)) }; // SAFETY: `promise` is a live (still-protected) JSC heap cell. if unsafe { &*promise }.status() == PromiseStatus::Rejected { @@ -891,12 +880,15 @@ unsafe fn load_preloads(vm: *mut VirtualMachine) -> bun_jsc::CrateResult<*mut JS // Under --isolate each test file gets // a fresh global, so preloads must re-execute for every file. Otherwise, // only load preloads once. - // SAFETY: per fn contract. - if !unsafe { &*vm }.test_isolation_enabled { + // SAFETY: per fn contract — `vm` is the live per-thread VM. + let vm = unsafe { &mut *vm }; + // If another reload was deferred while this reload's preloads ran, keep the list once so it can rerun them. + vm.preloads_kept_for_reload = + vm.hot_reload_deferred && vm.hot_reload_counter != 0 && !kept_for_this_run; + if !vm.test_isolation_enabled && !vm.preloads_kept_for_reload { // `Vec::clear` drops the `Box<[u8]>` // payloads but keeps capacity. - // SAFETY: per fn contract — `vm` is the live per-thread VM. - unsafe { (*vm).preload.clear() }; + vm.preload.clear(); } Ok(ptr::null_mut()) @@ -1014,6 +1006,16 @@ unsafe fn auto_tick(vm: *mut VirtualMachine) { return; } + // ── addons' uv_idle_t / uv_prepare_t ──────────────────────────────── + // Run them before reading `has_pending_immediate` because their callbacks can queue work. + // SAFETY: `state` is the live per-thread `RuntimeState`. This only borrows + // the `uv_loop` field, so it doesn't alias the `timer` accesses that + // callbacks make through `runtime_state()`. + #[cfg(unix)] + let uv_idle_started = unsafe { (*state).uv_loop.before_poll() }; + #[cfg(not(unix))] + let uv_idle_started = false; + // Call `ctx.timer.getTimeout(..)` ONLY inside // `if (loop.isActive())` — `get_timeout` has side effects (pops + fires // due `WTFTimer` heap entries), so it must stay guarded by `is_active()` @@ -1022,9 +1024,10 @@ unsafe fn auto_tick(vm: *mut VirtualMachine) { // Read `immediate_tasks` AFTER // `tickImmediateTasks` swaps `next_immediate_tasks` in, so this // reflects next-tick immediates (queued during the drain above). - // SAFETY: `el` is the live per-thread event loop. + // A started idle handle keeps the poll from blocking, like in libuv. // SAFETY: `el` is the live per-thread event loop. let has_pending_immediate = has_yielded_tasks + || uv_idle_started || !unsafe { &*el }.immediate_tasks.is_empty() || unsafe { &*el }.has_pending_tasks(); // Fold the QUIC deadline into the poll timeout. @@ -1083,6 +1086,9 @@ unsafe fn auto_tick(vm: *mut VirtualMachine) { #[cfg(unix)] { + // ── addons' uv_check_t (libuv runs them right after its poll) ──── + // SAFETY: same as `before_poll` above. + unsafe { (*state).uv_loop.after_poll() }; // Note (§Forbidden aliased-&mut): `drain_timers` fires user // `setTimeout` callbacks which may re-enter `timer::All::insert`/ // `remove` via `runtime_state()`. Pass raw `*mut Self` so no @@ -1157,10 +1163,16 @@ unsafe fn auto_tick_active(vm: *mut VirtualMachine) { return; } + // SAFETY: see the matching call in `auto_tick`. + #[cfg(unix)] + let uv_idle_started = unsafe { (*state).uv_loop.before_poll() }; + #[cfg(not(unix))] + let uv_idle_started = false; + { - // SAFETY: `el` is the live per-thread event loop. // SAFETY: `el` is the live per-thread event loop. let has_pending_immediate = has_yielded_tasks + || uv_idle_started || !unsafe { &*el }.immediate_tasks.is_empty() || unsafe { &*el }.has_pending_tasks(); // SAFETY: `loop_` is the live per-thread uws loop. @@ -1208,6 +1220,8 @@ unsafe fn auto_tick_active(vm: *mut VirtualMachine) { #[cfg(unix)] { + // SAFETY: see the matching calls in `auto_tick`. + unsafe { (*state).uv_loop.after_poll() }; // SAFETY: `state` is the live per-thread `RuntimeState`; see Note // on `auto_tick` re: aliased-&mut across `fire()`. unsafe { timer::All::drain_timers(&mut (*state).timer, vm.cast()) }; @@ -1769,8 +1783,7 @@ fn stop_active_handles(vm: &mut VirtualMachine, reason: bun_jsc::StopReason) -> // SAFETY: single JS thread, no re-entry while we hold the field borrow. if !all.is_null() && unsafe { (*all).fake_timers.is_active() } { let global = vm.global(); - // SAFETY: as above; only touches `fake_timers.active` and the - // `CURRENT_TIME` static. + // SAFETY: as above. Only touches `fake_timers`' clock. unsafe { (*all).fake_timers.reset_for_isolation(global) }; } if !all.is_null() { @@ -1797,9 +1810,9 @@ unsafe fn retroactively_report_discovered_tests( agent: *mut bun_jsc::debugger::TestReporterHandle, next_test_id: i32, ) -> i32 { - use crate::test_runner::bun_test::{DescribeScope, Phase, TestScheduleEntry}; + use crate::test_runner::bun_test::{Phase, TestScheduleEntry}; use crate::test_runner::jest::Jest; - use bun_jsc::debugger::{TestReporterHandle, TestType}; + use bun_jsc::debugger::TestType; let Some(runner) = Jest::runner() else { return next_test_id; @@ -1826,77 +1839,58 @@ unsafe fn retroactively_report_discovered_tests( let mut max_id: i32 = next_test_id; - // Recursively report all discovered tests starting from root scope. - retroactively_report_scope( - agent, - &mut active_file.collection.root_scope, - -1, - &mut max_id, - &source_url, - ); - - return max_id; - - fn retroactively_report_scope( - agent: *mut TestReporterHandle, - scope: &mut DescribeScope, - parent_id: i32, - max_id: &mut i32, - source_url: &bun_core::String, - ) { - for entry in scope.entries.iter_mut() { - match entry { - TestScheduleEntry::Describe(describe) => { - if describe.base.test_id_for_debugger == 0 { - *max_id += 1; - let test_id = *max_id; - // Assign the ID so start/end events will fire during - // execution. - describe.base.test_id_for_debugger = test_id; - let name = bun_core::String::from_bytes( - describe.base.name.as_deref().unwrap_or(b"(unnamed)"), - ); - // SAFETY: `agent` is a live C++ handle (fn contract). - unsafe { &mut *agent }.report_test_found_with_location( - test_id, - &name, - TestType::Describe, - parent_id, - source_url, - describe.base.line_no as i32, - ); - // Recursively report children with this describe as - // parent. - retroactively_report_scope(agent, describe, test_id, max_id, source_url); - } else { - // Already has ID, just recurse with existing ID as - // parent. - let existing = describe.base.test_id_for_debugger; - retroactively_report_scope(agent, describe, existing, max_id, source_url); - } + // Use an explicit stack because describe() can nest to any depth. + let mut open: Vec<(core::slice::IterMut<'_, TestScheduleEntry>, i32)> = + vec![(active_file.collection.root_scope.entries.iter_mut(), -1)]; + while let Some((children, parent_id)) = open.last_mut() { + let parent_id = *parent_id; + let Some(entry) = children.next() else { + open.pop(); + continue; + }; + match entry { + TestScheduleEntry::Describe(describe) => { + if describe.base.test_id_for_debugger == 0 { + max_id += 1; + // Assign the ID so start/end events will fire during execution. + describe.base.test_id_for_debugger = max_id; + let name = bun_core::String::from_bytes( + describe.base.name.as_deref().unwrap_or(b"(unnamed)"), + ); + // SAFETY: `agent` is a live C++ handle (fn contract). + unsafe { &mut *agent }.report_test_found_with_location( + max_id, + &name, + TestType::Describe, + parent_id, + &source_url, + describe.base.line_no as i32, + ); } - TestScheduleEntry::TestCallback(test_entry) => { - if test_entry.base.test_id_for_debugger == 0 { - *max_id += 1; - let test_id = *max_id; - test_entry.base.test_id_for_debugger = test_id; - let name = bun_core::String::from_bytes( - test_entry.base.name.as_deref().unwrap_or(b"(unnamed)"), - ); - // SAFETY: `agent` is a live C++ handle (fn contract). - unsafe { &mut *agent }.report_test_found_with_location( - test_id, - &name, - TestType::Test, - parent_id, - source_url, - test_entry.base.line_no as i32, - ); - } + let describe_id = describe.base.test_id_for_debugger; + open.push((describe.entries.iter_mut(), describe_id)); + } + TestScheduleEntry::TestCallback(test_entry) => { + if test_entry.base.test_id_for_debugger == 0 { + max_id += 1; + test_entry.base.test_id_for_debugger = max_id; + let name = bun_core::String::from_bytes( + test_entry.base.name.as_deref().unwrap_or(b"(unnamed)"), + ); + // SAFETY: `agent` is a live C++ handle (fn contract). + unsafe { &mut *agent }.report_test_found_with_location( + max_id, + &name, + TestType::Test, + parent_id, + &source_url, + test_entry.base.line_no as i32, + ); } } } } + max_id } // ════════════════════════════════════════════════════════════════════════════ @@ -1949,6 +1943,15 @@ fn console_print_runtime_object_inner( }; } + // When JS throws, `write_format` returns a plain error and the exception is still pending. + fn rethrow_pending(global: &JSGlobalObject, write_format_failed: bool) -> JsResult { + if write_format_failed && global.has_exception() { + return Err(bun_jsc::JsError::Thrown); + } + Ok(true) + } + let global = formatter.global_this; + // SAFETY: `as_` returns a non-null `*mut T` only when `value` wraps a // live `T` cell; conservative stack scan keeps `value` alive for the // duration of each branch. @@ -1956,39 +1959,39 @@ fn console_print_runtime_object_inner( let mut w = AsFmt::new(writer_); // SAFETY: `as_` returned a non-null `*mut Response` to the live native // wrapper backing `value`; `value` is on-stack so GC keeps it alive. - let _ = unsafe { &mut *response }.write_format::<_, _, C>(formatter, &mut w); - return Ok(true); + let result = unsafe { &mut *response }.write_format::<_, _, C>(formatter, &mut w); + return rethrow_pending(global, result.is_err()); } if let Some(request) = value.as_::() { let mut w = AsFmt::new(writer_); // SAFETY: `as_` returned a non-null `*mut Request` to the live native // wrapper backing `value`; `value` is on-stack so GC keeps it alive. - let _ = unsafe { &mut *request }.write_format::<_, _, C>(value, formatter, &mut w); - return Ok(true); + let result = unsafe { &mut *request }.write_format::<_, _, C>(value, formatter, &mut w); + return rethrow_pending(global, result.is_err()); } if let Some(build) = value.as_::() { let mut w = AsFmt::new(writer_); // SAFETY: `as_` returned a non-null `*mut BuildArtifact` to the live // native wrapper backing `value`; GC keeps it alive (see above). - let _ = unsafe { &*build }.write_format::<_, _, C>(value, formatter, &mut w); - return Ok(true); + let result = unsafe { &*build }.write_format::<_, _, C>(value, formatter, &mut w); + return rethrow_pending(global, result.is_err()); } if let Some(blob) = value.as_::() { let mut w = AsFmt::new(writer_); // SAFETY: `as_` returned a non-null `*mut Blob` to the live native // wrapper backing `value`; GC keeps it alive (see above). - let _ = unsafe { &mut *blob }.write_format::<_, _, C>(formatter, &mut w); - return Ok(true); + let result = unsafe { &mut *blob }.write_format::<_, _, C>(formatter, &mut w); + return rethrow_pending(global, result.is_err()); } if let Some(s3client) = value.as_class_ref::() { let mut w = AsFmt::new(writer_); - let _ = s3client.write_format::<_, _, C>(formatter, &mut w); - return Ok(true); + let result = s3client.write_format::<_, _, C>(formatter, &mut w); + return rethrow_pending(global, result.is_err()); } if let Some(archive) = value.as_class_ref::() { let mut w = AsFmt::new(writer_); - let _ = archive.write_format::<_, _, C>(formatter, &mut w); - return Ok(true); + let result = archive.write_format::<_, _, C>(formatter, &mut w); + return rethrow_pending(global, result.is_err()); } if bun_jsc::FetchHeaders::cast_(value, formatter.global_this.vm()).is_some() { if let Some(to_json_function) = value.get(formatter.global_this, "toJSON")? { @@ -2017,10 +2020,10 @@ fn console_print_runtime_object_inner( if let Some(timer) = value.as_class_ref::() { let internals = &timer.internals; let id = internals.id; - formatter.add_for_new_line("Timeout(# ) ".len() + bun_core::fmt::digit_count(id.max(0))); + formatter.add_for_new_line("Timeout(# ) ".len() + bun_core::fmt::digit_count(id)); let mut w = AsFmt::new(writer_); if internals.flags.get().kind() == crate::timer::Kind::SetInterval { - formatter.add_for_new_line("repeats ".len() + bun_core::fmt::digit_count(id.max(0))); + formatter.add_for_new_line("repeats ".len() + bun_core::fmt::digit_count(id)); let _ = write!( w, "{}Timeout{} {}(#{}{}{}{}, repeats){}", @@ -2051,7 +2054,7 @@ fn console_print_runtime_object_inner( } if let Some(immediate) = value.as_class_ref::() { let id = immediate.internals.id; - formatter.add_for_new_line("Immediate(# ) ".len() + bun_core::fmt::digit_count(id.max(0))); + formatter.add_for_new_line("Immediate(# ) ".len() + bun_core::fmt::digit_count(id)); let mut w = AsFmt::new(writer_); let _ = write!( w, @@ -3156,7 +3159,8 @@ fn transpile_source_code_of_any_provenance( path.text, None, ) - }; + } + .map_err(|_| global_object.throw_string_too_long())?; resolved_source.is_commonjs_module = is_commonjs_module; resolved_source.module_info = module_info; resolved_source.bytecode_cache = node_compile_cache_blob.unwrap_or_default(); @@ -3450,7 +3454,7 @@ fn transpile_source_code_of_any_provenance( // need to copy the ~12 borrowed slices out (perf: was a // per-asset-import `url::URL::clone`). let origin = unsafe { &(*jsc_vm).origin }; - let top_level_dir = Fs::FileSystem::get().top_level_dir; + let top_level_dir = Fs::FileSystem::get().top_level_dir(); crate::api::bun_object::get_public_path_with_asset_prefix( specifier, top_level_dir, diff --git a/src/runtime/napi/mod.rs b/src/runtime/napi/mod.rs index 3f9d79df8520..2dd6508a326f 100644 --- a/src/runtime/napi/mod.rs +++ b/src/runtime/napi/mod.rs @@ -12,6 +12,8 @@ pub(crate) use napi_body::{ }; pub(crate) mod libc_check; +#[cfg(unix)] +pub(crate) mod uv_posix; // ─── compiling free items ──────────────────────────────────────────────────── diff --git a/src/runtime/napi/napi_body.rs b/src/runtime/napi/napi_body.rs index 76421fc09949..e87efd6cc3f0 100644 --- a/src/runtime/napi/napi_body.rs +++ b/src/runtime/napi/napi_body.rs @@ -167,6 +167,10 @@ impl NapiEnv { Self::set_last_error(Some(self), NapiStatus::pending_exception) } + pub(crate) fn cannot_run_js(&self) -> napi_status { + Self::set_last_error(Some(self), NapiStatus::cannot_run_js) + } + /// Runs `completion` (an addon's `complete` or `call_js`) in `context`, the one whose script /// asked for the work: what the callback opens is that context's. Once the context has /// stopped the callback still runs, since it owns memory only it can free, but functions @@ -477,7 +481,7 @@ pub(crate) enum NapiStatus { // detachable_arraybuffer_expected = 20, // would_deadlock = 21, // no_external_buffers_allowed = 22, - // cannot_run_js = 23, + cannot_run_js = 23, } /// This is not an `enum` so that the enum values cannot be trivially returned from NAPI functions, @@ -1962,17 +1966,24 @@ impl napi_async_work { drop(unsafe { bun_core::heap::take(this) }); } - pub(crate) fn schedule(&mut self) { + /// Returns false if the VM is closed. The addon still has to delete the work. + pub(crate) fn schedule(&mut self) -> bool { if self.scheduled { - return; + return true; + } + let vm = self.global.bun_vm(); + // A finalizer can call this during the GC that destroys the heap. + if vm.closed() { + return false; } self.scheduled = true; self.poll_ref.ref_(bun_io::js_vm_ctx()); // The work object belongs to the addon and `execute` receives this // env, so the VM waits for it (Node likewise settles its threadpool // requests before an environment is freed). - self.ticket = Some(self.global.bun_vm().ticket()); + self.ticket = Some(vm.ticket()); WorkPool::schedule(&raw mut self.task); + true } pub(crate) unsafe fn run_from_thread_pool(task: *mut WorkPoolTask) { @@ -2334,7 +2345,9 @@ extern "C" fn napi_queue_async_work(env_: napi_env, work_: *mut napi_async_work) return env.invalid_arg(); }; debug_assert!(core::ptr::eq(env.to_js(), work.global.as_ptr())); - work.schedule(); + if !work.schedule() { + return env.cannot_run_js(); + } env.ok() } @@ -2368,8 +2381,8 @@ extern "C" fn napi_get_node_version( #[cfg(windows)] type napi_event_loop = *mut bun_sys::windows::libuv::Loop; -#[cfg(not(windows))] -type napi_event_loop = *mut EventLoop; +#[cfg(unix)] +type napi_event_loop = *mut super::uv_posix::UvLoop; #[unsafe(no_mangle)] extern "C" fn napi_get_uv_event_loop(env_: napi_env, loop_: *mut napi_event_loop) -> napi_status { @@ -2382,13 +2395,16 @@ extern "C" fn napi_get_uv_event_loop(env_: napi_env, loop_: *mut napi_event_loop // TODO(@190n) investigate *loop_out = VirtualMachine::get().uv_loop(); } - #[cfg(not(windows))] + #[cfg(unix)] { - // there is no uv event loop on posix, we use our event loop handle. - // SAFETY: `VirtualMachine::event_loop` already yields `*mut EventLoop`; - // no const→mut cast needed. - // SAFETY: bun_vm() never null for a Bun-owned global. - *loop_out = env.to_js().bun_vm().event_loop(); + // Bun doesn't run libuv on POSIX. The addon gets this VM's `UvLoop`, and + // `uv_posix` implements the uv_* functions on this VM's event loop. + // SAFETY: the env's VM is alive while the env is. + let uv_loop = unsafe { super::uv_posix::UvLoop::of_vm(env.to_js().bun_vm()) }; + if uv_loop.is_null() { + return env.generic_failure(); + } + *loop_out = uv_loop; } env.ok() } @@ -4220,12 +4236,11 @@ mod posix_platform_specific_v8_apis { // uv_* symbol references (posix DCE suppression) // ────────────────────────────────────────────────────────────────────────── +/// Lists only the uv_* symbols defined in C and C++. `uv_posix` exports its own. #[cfg(unix)] mod uv_functions_to_export { unsafe extern "C" { pub(super) fn uv_accept(); - pub(super) fn uv_async_init(); - pub(super) fn uv_async_send(); pub(super) fn uv_available_parallelism(); pub(super) fn uv_backend_fd(); pub(super) fn uv_backend_timeout(); @@ -4233,13 +4248,8 @@ mod uv_functions_to_export { pub(super) fn uv_barrier_init(); pub(super) fn uv_barrier_wait(); pub(super) fn uv_buf_init(); - pub(super) fn uv_cancel(); pub(super) fn uv_chdir(); - pub(super) fn uv_check_init(); - pub(super) fn uv_check_start(); - pub(super) fn uv_check_stop(); pub(super) fn uv_clock_gettime(); - pub(super) fn uv_close(); pub(super) fn uv_cond_broadcast(); pub(super) fn uv_cond_destroy(); pub(super) fn uv_cond_init(); @@ -4249,7 +4259,6 @@ mod uv_functions_to_export { pub(super) fn uv_cpu_info(); pub(super) fn uv_cpumask_size(); pub(super) fn uv_cwd(); - pub(super) fn uv_default_loop(); pub(super) fn uv_disable_stdio_inheritance(); pub(super) fn uv_dlclose(); pub(super) fn uv_dlerror(); @@ -4332,11 +4341,7 @@ mod uv_functions_to_export { pub(super) fn uv_handle_set_data(); pub(super) fn uv_handle_size(); pub(super) fn uv_handle_type_name(); - pub(super) fn uv_has_ref(); pub(super) fn uv_hrtime(); - pub(super) fn uv_idle_init(); - pub(super) fn uv_idle_start(); - pub(super) fn uv_idle_stop(); pub(super) fn uv_if_indextoiid(); pub(super) fn uv_if_indextoname(); pub(super) fn uv_inet_ntop(); @@ -4347,8 +4352,6 @@ mod uv_functions_to_export { pub(super) fn uv_ip4_name(); pub(super) fn uv_ip6_addr(); pub(super) fn uv_ip6_name(); - pub(super) fn uv_is_active(); - pub(super) fn uv_is_closing(); pub(super) fn uv_is_readable(); pub(super) fn uv_is_writable(); pub(super) fn uv_key_create(); @@ -4377,7 +4380,6 @@ mod uv_functions_to_export { pub(super) fn uv_mutex_lock(); pub(super) fn uv_mutex_trylock(); pub(super) fn uv_mutex_unlock(); - pub(super) fn uv_now(); pub(super) fn uv_once(); pub(super) fn uv_open_osfhandle(); pub(super) fn uv_os_environ(); @@ -4415,19 +4417,14 @@ mod uv_functions_to_export { pub(super) fn uv_poll_init_socket(); pub(super) fn uv_poll_start(); pub(super) fn uv_poll_stop(); - pub(super) fn uv_prepare_init(); - pub(super) fn uv_prepare_start(); - pub(super) fn uv_prepare_stop(); pub(super) fn uv_print_active_handles(); pub(super) fn uv_print_all_handles(); pub(super) fn uv_process_get_pid(); pub(super) fn uv_process_kill(); - pub(super) fn uv_queue_work(); pub(super) fn uv_random(); pub(super) fn uv_read_start(); pub(super) fn uv_read_stop(); pub(super) fn uv_recv_buffer_size(); - pub(super) fn uv_ref(); pub(super) fn uv_replace_allocator(); pub(super) fn uv_req_get_data(); pub(super) fn uv_req_get_type(); @@ -4489,13 +4486,6 @@ mod uv_functions_to_export { pub(super) fn uv_thread_setaffinity(); pub(super) fn uv_thread_setname(); pub(super) fn uv_thread_setpriority(); - pub(super) fn uv_timer_again(); - pub(super) fn uv_timer_get_due_in(); - pub(super) fn uv_timer_get_repeat(); - pub(super) fn uv_timer_init(); - pub(super) fn uv_timer_set_repeat(); - pub(super) fn uv_timer_start(); - pub(super) fn uv_timer_stop(); pub(super) fn uv_translate_sys_error(); pub(super) fn uv_try_write(); pub(super) fn uv_try_write2(); @@ -4527,8 +4517,6 @@ mod uv_functions_to_export { pub(super) fn uv_udp_try_send(); pub(super) fn uv_udp_try_send2(); pub(super) fn uv_udp_using_recvmmsg(); - pub(super) fn uv_unref(); - pub(super) fn uv_update_time(); pub(super) fn uv_uptime(); pub(super) fn uv_utf16_length_as_wtf8(); pub(super) fn uv_utf16_to_wtf8(); @@ -4707,11 +4695,10 @@ pub(crate) fn fix_dead_code_elimination() { // `uv_functions_to_export` module above. #[cfg(unix)] { + super::uv_posix::fix_dead_code_elimination(); use uv_functions_to_export::*; keep_symbols!( uv_accept, - uv_async_init, - uv_async_send, uv_available_parallelism, uv_backend_fd, uv_backend_timeout, @@ -4719,13 +4706,8 @@ pub(crate) fn fix_dead_code_elimination() { uv_barrier_init, uv_barrier_wait, uv_buf_init, - uv_cancel, uv_chdir, - uv_check_init, - uv_check_start, - uv_check_stop, uv_clock_gettime, - uv_close, uv_cond_broadcast, uv_cond_destroy, uv_cond_init, @@ -4735,7 +4717,6 @@ pub(crate) fn fix_dead_code_elimination() { uv_cpu_info, uv_cpumask_size, uv_cwd, - uv_default_loop, uv_disable_stdio_inheritance, uv_dlclose, uv_dlerror, @@ -4818,11 +4799,7 @@ pub(crate) fn fix_dead_code_elimination() { uv_handle_set_data, uv_handle_size, uv_handle_type_name, - uv_has_ref, uv_hrtime, - uv_idle_init, - uv_idle_start, - uv_idle_stop, uv_if_indextoiid, uv_if_indextoname, uv_inet_ntop, @@ -4833,8 +4810,6 @@ pub(crate) fn fix_dead_code_elimination() { uv_ip4_name, uv_ip6_addr, uv_ip6_name, - uv_is_active, - uv_is_closing, uv_is_readable, uv_is_writable, uv_key_create, @@ -4863,7 +4838,6 @@ pub(crate) fn fix_dead_code_elimination() { uv_mutex_lock, uv_mutex_trylock, uv_mutex_unlock, - uv_now, uv_once, uv_open_osfhandle, uv_os_environ, @@ -4901,19 +4875,14 @@ pub(crate) fn fix_dead_code_elimination() { uv_poll_init_socket, uv_poll_start, uv_poll_stop, - uv_prepare_init, - uv_prepare_start, - uv_prepare_stop, uv_print_active_handles, uv_print_all_handles, uv_process_get_pid, uv_process_kill, - uv_queue_work, uv_random, uv_read_start, uv_read_stop, uv_recv_buffer_size, - uv_ref, uv_replace_allocator, uv_req_get_data, uv_req_get_type, @@ -4975,13 +4944,6 @@ pub(crate) fn fix_dead_code_elimination() { uv_thread_setaffinity, uv_thread_setname, uv_thread_setpriority, - uv_timer_again, - uv_timer_get_due_in, - uv_timer_get_repeat, - uv_timer_init, - uv_timer_set_repeat, - uv_timer_start, - uv_timer_stop, uv_translate_sys_error, uv_try_write, uv_try_write2, @@ -5013,8 +4975,6 @@ pub(crate) fn fix_dead_code_elimination() { uv_udp_try_send, uv_udp_try_send2, uv_udp_using_recvmmsg, - uv_unref, - uv_update_time, uv_uptime, uv_utf16_length_as_wtf8, uv_utf16_to_wtf8, diff --git a/src/runtime/napi/uv_posix.rs b/src/runtime/napi/uv_posix.rs new file mode 100644 index 000000000000..f134297a518c --- /dev/null +++ b/src/runtime/napi/uv_posix.rs @@ -0,0 +1,1461 @@ +//! Implements the event loop part of libuv's API for N-API addons on POSIX: +//! `uv_default_loop`, the `uv_async_t`, `uv_idle_t`, `uv_prepare_t`, `uv_check_t` +//! and `uv_timer_t` handles, `uv_queue_work`, and the `uv_handle_t` functions. +//! Every other `uv_*` symbol is a crash stub (uv-posix-stubs.c) or a header-only +//! polyfill (uv-posix-polyfills.c). Bun doesn't run a libuv loop on POSIX, so +//! these functions run on the VM's event loop. They follow libuv's ABI and +//! threading rules: +//! +//! - The addon allocates each handle or request and reads `data`, `loop` and +//! `type` from it ([`UvHandle`], [`UvReq`]). Bun owns the fields after those. +//! - Only `uv_async_send` may be called off the loop's JS thread. A handle is valid +//! until its `close_cb` has run, and a request until its `after_work_cb` has run. +//! - A `uv_loop_t*` points to a [`UvLoop`]. Each VM has one. Only its `data` word is ABI. +//! - `uv_async_send` sets the handle's `pending` flag and posts at most one +//! dispatch task per loop through the VM's [`VmHandle`], where libuv writes to an +//! eventfd. The task walks the loop's handles on the JS thread like `uv__async_io`. +//! - `auto_tick` (jsc_hooks.rs) runs idle and prepare handles before it polls and +//! check handles after. It doesn't block while an idle handle is started. +//! - A timer is an [`EventLoopTimer`] in the VM's timer heap. It's too big for the +//! handle, so it's heap-allocated in a [`UvTimerNode`]. +//! - `uv_queue_work` schedules a [`Job`] that runs `work_cb` on the thread pool and +//! `after_work_cb` when it completes. +//! - A handle keeps the process alive while it is started and ref'd ([`RefState`]). + +use core::cell::Cell; +use core::ffi::{CStr, c_char, c_int, c_uint, c_void}; +use core::ptr::NonNull; +use core::sync::atomic::{AtomicI32, AtomicU8, AtomicU32, Ordering}; + +use bun_core::{Timespec, TimespecMockMode}; +use bun_event_loop::{ContextId, TaskTag, Taskable, task_tag}; +use bun_io::KeepAlive; +use bun_jsc::event_loop::ConcurrentTaskItem as ConcurrentTask; +use bun_jsc::virtual_machine::VirtualMachine; +use bun_jsc::{ + Completion, JSGlobalObject, Job, JobContext, JsCell, JsError, JsPtr, JsResult, JsThread, + LoopKind, Posted, VmHandle, +}; + +use crate::jsc_hooks::{RuntimeState, timer_all_mut}; +use crate::timer::{EventLoopTimer, EventLoopTimerState, EventLoopTimerTag}; + +bun_output::declare_scope!(uv, hidden); + +unsafe extern "C" { + /// Crashes the same way the stubs do. `name` must be static. Never returns. + fn CrashHandler__unsupportedUVFunction(name: *const c_char); + /// Returns null while the GC is sweeping. `HandleScopeImpl__close` accepts null. + fn HandleScopeImpl__open(global: *const JSGlobalObject) -> *mut c_void; + fn HandleScopeImpl__close(global: *const JSGlobalObject, scope: *mut c_void); +} + +#[cold] +fn unsupported(function: &'static CStr) -> ! { + // SAFETY: `function` is a static NUL-terminated string. + unsafe { CrashHandler__unsupportedUVFunction(function.as_ptr()) }; + unreachable!("CrashHandler__unsupportedUVFunction returned"); +} + +/// Holds a handle scope open while an addon's callback runs on the JS thread. Node's +/// headers inline `v8::HandleScope`, so the addon's own scopes keep their handles in +/// the scope Bun opened. +struct CallbackScope<'a> { + global: &'a JSGlobalObject, + scope: *mut c_void, +} + +impl<'a> CallbackScope<'a> { + fn open(global: &'a JSGlobalObject) -> CallbackScope<'a> { + CallbackScope { + global, + // SAFETY: `global` is alive, and this is its JS thread. + scope: unsafe { HandleScopeImpl__open(global) }, + } + } +} + +impl Drop for CallbackScope<'_> { + fn drop(&mut self) { + // SAFETY: `scope` came from `HandleScopeImpl__open` on `global`, and the addon + // closed the scopes it opened inside it. + unsafe { HandleScopeImpl__close(self.global, self.scope) }; + } +} + +// `UV_E*` on posix is `-errno` (uv/errno.h). +const UV_EINVAL: c_int = -libc::EINVAL; +const UV_EBUSY: c_int = -libc::EBUSY; +const UV_ECANCELED: c_int = -libc::ECANCELED; + +// Indexes into uv.h's `UV_HANDLE_TYPE_MAP` and `UV_REQ_TYPE_MAP`. +const UV_ASYNC: c_uint = 1; +const UV_CHECK: c_uint = 2; +const UV_IDLE: c_uint = 6; +const UV_PREPARE: c_uint = 9; +const UV_TIMER: c_uint = 13; +const UV_WORK: c_uint = 7; + +// Same `flags` values as libuv (src/uv-common.h). +const UV_HANDLE_CLOSING: c_uint = 0x01; +const UV_HANDLE_CLOSED: c_uint = 0x02; + +// `sizeof` each struct on 64-bit unix. The addon allocates them, so Bun's versions can't be bigger. +const UV_ASYNC_T_SIZE: usize = 128; +const UV_WATCHER_T_SIZE: usize = 120; +const UV_TIMER_T_SIZE: usize = 152; +const UV_WORK_T_SIZE: usize = 128; + +type UvAsyncCb = unsafe extern "C" fn(*mut UvAsync); +/// `uv_idle_cb`, `uv_prepare_cb` and `uv_check_cb` all have this signature. +type UvWatcherCb = unsafe extern "C" fn(*mut UvWatcher); +type UvTimerCb = unsafe extern "C" fn(*mut UvTimer); +type UvCloseCb = unsafe extern "C" fn(*mut UvHandle); +type UvWorkCb = unsafe extern "C" fn(*mut UvWork); +type UvAfterWorkCb = unsafe extern "C" fn(*mut UvWork, c_int); + +/// Tracks whether a handle keeps the loop alive. Like in libuv, it does while it's +/// both started (`uv_is_active`) and ref'd (`uv_has_ref`, the default). +struct RefState { + keep_alive: KeepAlive, + referenced: bool, + active: bool, +} + +impl RefState { + fn new() -> RefState { + RefState { + keep_alive: KeepAlive::default(), + referenced: true, + active: false, + } + } + + fn set_active(&mut self, active: bool) { + self.active = active; + self.sync(); + } + + fn set_referenced(&mut self, referenced: bool) { + self.referenced = referenced; + self.sync(); + } + + fn sync(&mut self) { + if self.active && self.referenced { + self.keep_alive.ref_(bun_io::js_vm_ctx()); + } else { + self.keep_alive.unref(bun_io::js_vm_ctx()); + } + } +} + +// ────────────────────────────────────────────────────────────────────────── +// uv_loop_t +// ────────────────────────────────────────────────────────────────────────── + +#[repr(u8)] +#[derive(Clone, Copy, PartialEq, Eq)] +enum DispatchState { + /// No dispatch task is queued. + Idle = 0, + /// A dispatch task is queued, or the running one will queue another. + Pending = 1, + /// The dispatch task is walking the handles. + Running = 2, +} + +/// Backs `uv_loop_t`. It's a field of the VM's `RuntimeState`, so it lives exactly +/// as long as the VM ([`Self::of_vm`]). +#[repr(C)] +pub(crate) struct UvLoop { + /// `uv_loop_t.data`. Only the addon reads and writes it. Must be at offset 0 like in uv.h. + data: Cell<*mut c_void>, + vm: NonNull, + /// Lets `uv_async_send` post to the JS thread from any thread. + handle: VmHandle, + dispatch_state: AtomicU8, + /// Async handles that are initialized and not closed. JS thread only. + asyncs: JsCell>>, + /// Handles the running dispatch pass hasn't visited yet, in reverse order. + /// `uv_close` removes a handle from both lists. JS thread only. + dispatching: JsCell>>, + idles: WatcherList, + prepares: WatcherList, + checks: WatcherList, +} + +const _: () = assert!(core::mem::offset_of!(UvLoop, data) == 0); + +/// Lists the started handles of one watcher type in the order they were started. +/// `walk` is libuv's `uv__run_idle` and friends. JS thread only. +struct WatcherList { + started: JsCell>>, + /// Handles the running walk hasn't reached yet, in reverse order. Each one moves + /// back to `started` before its callback runs, so a callback can stop it. + walking: JsCell>>, + /// True while a walk is on the stack. A nested event loop run skips the walk. + in_walk: Cell, +} + +impl WatcherList { + fn new() -> WatcherList { + WatcherList { + started: JsCell::new(Vec::new()), + walking: JsCell::new(Vec::new()), + in_walk: Cell::new(false), + } + } + + fn add(&self, watcher: NonNull) { + self.started.with_mut(|started| started.push(watcher)); + } + + fn remove(&self, watcher: NonNull) { + self.started + .with_mut(|started| started.retain(|w| *w != watcher)); + self.walking + .with_mut(|walking| walking.retain(|w| *w != watcher)); + } + + fn is_empty(&self) -> bool { + self.started.get().is_empty() + } + + fn walk(&self, vm: &VirtualMachine) { + if self.is_empty() || self.in_walk.replace(true) { + return; + } + self.started.with_mut(|started| { + self.walking.with_mut(|walking| { + debug_assert!(walking.is_empty()); + core::mem::swap(started, walking); + walking.reverse(); + }) + }); + let global = vm.global(); + while let Some(watcher) = self.walking.with_mut(Vec::pop) { + self.started.with_mut(|started| started.push(watcher)); + // SAFETY: a started handle is initialized and not closed (`uv_close` + // stops it first), and the addon keeps it alive until then. + let Some(cb) = (unsafe { (*watcher.as_ptr()).cb }) else { + continue; + }; + { + let _scope = vm.enter_event_loop_scope(); + let _handles = CallbackScope::open(global); + // SAFETY: calls the addon's callback on the loop thread with a + // handle it started. + unsafe { cb(watcher.as_ptr()) }; + } + if global.has_exception() + && bun_jsc::task::report_error_or_terminate(global, JsError::Thrown).is_err() + { + break; + } + } + // Handles only remain here after a termination. + self.walking.with_mut(|walking| { + if !walking.is_empty() { + self.started + .with_mut(|started| started.extend(walking.drain(..).rev())); + } + }); + self.in_walk.set(false); + } +} + +impl UvLoop { + /// Called by `init_runtime_state` on the JS thread. `handle` is `vm`'s [`VmHandle`]. + pub(crate) fn new(vm: NonNull, handle: VmHandle) -> UvLoop { + UvLoop { + data: Cell::new(core::ptr::null_mut()), + vm, + handle, + dispatch_state: AtomicU8::new(DispatchState::Idle as u8), + asyncs: JsCell::new(Vec::new()), + dispatching: JsCell::new(Vec::new()), + idles: WatcherList::new(), + prepares: WatcherList::new(), + checks: WatcherList::new(), + } + } + + /// Runs the idle handles, then the prepare handles. `auto_tick` calls this before + /// it polls. Returns true if an idle handle is started, so the poll must not block. + pub(crate) fn before_poll(&self) -> bool { + if self.idles.is_empty() && self.prepares.is_empty() { + return false; + } + // SAFETY: the VM owns the `RuntimeState` that contains this loop, and this is + // the JS thread. + let vm = unsafe { self.vm.as_ref() }; + if !vm.script_allowed() { + return false; + } + self.idles.walk(vm); + self.prepares.walk(vm); + !self.idles.is_empty() + } + + /// Runs the check handles. `auto_tick` calls this after it polls. + pub(crate) fn after_poll(&self) { + if self.checks.is_empty() { + return; + } + // SAFETY: same as `before_poll`. + let vm = unsafe { self.vm.as_ref() }; + if vm.script_allowed() { + self.checks.walk(vm); + } + } + + fn watchers(&self, type_: c_uint) -> &WatcherList { + match type_ { + UV_IDLE => &self.idles, + UV_PREPARE => &self.prepares, + _ => { + debug_assert_eq!(type_, UV_CHECK); + &self.checks + } + } + } + + /// Returns `vm`'s loop. Returns null before `init_runtime_state` and after + /// `VirtualMachine::destroy`, which clears the field before freeing the state. + /// + /// # Safety + /// `vm` hasn't been freed (the main thread's VM never is). Any thread can call + /// this because `runtime_state` doesn't change between those two writes, and an + /// addon stops its threads in its cleanup hooks, which run before the second write. + pub(crate) unsafe fn of_vm(vm: *const VirtualMachine) -> *mut UvLoop { + // SAFETY: fn contract. + let state = unsafe { (*vm).runtime_state }.cast::(); + if state.is_null() { + return core::ptr::null_mut(); + } + // SAFETY: `runtime_state` is `vm`'s boxed `RuntimeState`, and `&raw mut` + // doesn't create a reference. + unsafe { &raw mut (*state).uv_loop } + } + + /// JS thread only. + fn global(&self) -> &'static JSGlobalObject { + // SAFETY: the VM owns the `RuntimeState` that contains this loop, so the VM is alive. + unsafe { self.vm.as_ref() }.global() + } + + /// Queues the dispatch task unless one is already queued. Any thread can call this. + fn schedule_dispatch(&self) { + let prev = self + .dispatch_state + .swap(DispatchState::Pending as u8, Ordering::SeqCst); + if prev == DispatchState::Idle as u8 { + self.post_dispatch(); + } + } + + /// Posts the dispatch task. The state is `Pending`, so no other thread posts one. + fn post_dispatch(&self) { + let this: *const UvLoop = self; + let task = ConcurrentTask::create_from(this.cast_mut()); + if let Posted::Refused(task) = self.handle.post(LoopKind::Regular, task) { + // The VM is gone, so drop the callback like a closed libuv loop does. + // SAFETY: a refused task was never queued, so we own it. + unsafe { ConcurrentTask::release_refused(task) }; + self.dispatch_state + .store(DispatchState::Idle as u8, Ordering::SeqCst); + } + } + + /// Runs the task that `schedule_dispatch` posted, on the JS thread. + /// + /// # Safety + /// `this` is the loop of the VM that dispatched the task. + pub(crate) unsafe fn dispatch(this: *mut UvLoop) { + // SAFETY: this loop's VM dispatched the task and owns the `RuntimeState` + // that contains the loop, so `this` is live. Other threads only use + // shared references to it. + let this = unsafe { &*this }; + let global = this.global(); + this.dispatch_state + .store(DispatchState::Running as u8, Ordering::SeqCst); + // Don't run callbacks once the VM is stopping. Threadsafe functions do the same. + if global.bun_vm().script_allowed() { + this.run_pass(global); + } + // A send during the pass sets `Pending` instead of posting a task, and this + // then fails. The next pass is a new task, so a thread that keeps sending + // can't hold the event loop back from its timers and sockets. + if this + .dispatch_state + .compare_exchange( + DispatchState::Running as u8, + DispatchState::Idle as u8, + Ordering::SeqCst, + Ordering::SeqCst, + ) + .is_err() + { + this.post_dispatch(); + } + } + + /// Calls `async_cb` for every handle that was sent, like libuv's `uv__async_io`. + /// Each handle moves back to `asyncs` before its callback runs, so `uv_close` + /// from any callback finds it in one of the two lists. Handles initialized + /// during the pass wait for the next one. + fn run_pass(&self, global: &JSGlobalObject) { + self.asyncs.with_mut(|live| { + self.dispatching.with_mut(|todo| { + debug_assert!(todo.is_empty()); + core::mem::swap(live, todo); + todo.reverse(); + }) + }); + while let Some(async_) = self.dispatching.with_mut(Vec::pop) { + self.asyncs.with_mut(|live| live.push(async_)); + // SAFETY: a registered handle is initialized and not closed. + // `uv_close` unregisters it before returning, and libuv's contract + // requires the addon to keep an unclosed handle's memory alive. + if !unsafe { UvAsync::take_pending(async_) } { + continue; + } + bun_output::scoped_log!(uv, "uv_async_t {:?}: async_cb", async_); + // SAFETY: same as above. + if let Some(async_cb) = unsafe { (*async_.as_ptr()).async_cb } { + let _handles = CallbackScope::open(global); + // SAFETY: calls the addon's callback on the loop thread with the + // handle it initialized. + unsafe { async_cb(async_.as_ptr()) }; + } + if global.has_exception() + && bun_jsc::task::report_error_or_terminate(global, JsError::Thrown).is_err() + { + break; + } + } + // Handles only remain here after a termination. + self.dispatching.with_mut(|todo| { + if !todo.is_empty() { + self.asyncs + .with_mut(|live| live.extend(todo.drain(..).rev())); + } + }); + } + + /// JS thread only. + fn register(&self, async_: NonNull) { + self.asyncs.with_mut(|live| live.push(async_)); + } + + /// JS thread only. Uses `retain` because a handle that was initialized twice is in + /// the list twice. + fn unregister(&self, async_: NonNull) { + self.asyncs.with_mut(|live| live.retain(|h| *h != async_)); + self.dispatching + .with_mut(|todo| todo.retain(|h| *h != async_)); + } +} + +/// `schedule_dispatch` posts the loop as a task. +impl Taskable for UvLoop { + const TAG: TaskTag = task_tag::UvLoopDispatch; + /// Drops the pending sends like a closed libuv loop does. The VM owns the loop, so + /// don't free it. + unsafe fn release_unrun(this: *mut Self) { + // SAFETY: fn contract. The VM that releases the task owns the loop. + unsafe { &(*this).dispatch_state }.store(DispatchState::Idle as u8, Ordering::SeqCst); + } + /// The task covers every addon's handles, so it isn't tied to one context. + unsafe fn context(_: *const Self) -> ContextId { + ContextId::NONE + } +} + +/// Implements `uv_loop_t* uv_default_loop(void)`. Returns the main thread's loop on +/// every thread, even in a Worker, like Node does. `napi_get_uv_event_loop` returns +/// the Worker's loop. +#[unsafe(no_mangle)] +pub(crate) extern "C" fn uv_default_loop() -> *mut UvLoop { + let Some(vm) = VirtualMachine::get_main_thread_vm() else { + return core::ptr::null_mut(); + }; + // SAFETY: the main thread's VM is never freed. + unsafe { UvLoop::of_vm(vm) } +} + +/// Returns `vm`'s loop for `node::GetCurrentEventLoop`, the same one +/// `napi_get_uv_event_loop` returns. +/// +/// # Safety +/// Same as [`UvLoop::of_vm`]. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn Bun__VirtualMachine__uvLoop( + vm: *const VirtualMachine, +) -> *mut UvLoop { + // SAFETY: fn contract. + unsafe { UvLoop::of_vm(vm) } +} + +// ────────────────────────────────────────────────────────────────────────── +// uv_handle_t / uv_async_t +// ────────────────────────────────────────────────────────────────────────── + +/// Matches `UV_HANDLE_FIELDS` + `UV_HANDLE_PRIVATE_FIELDS` (uv.h, uv/unix.h), which +/// every handle starts with. Addons read `data`, `loop` and `type`. The rest is private. +#[repr(C)] +pub(crate) struct UvHandle { + data: *mut c_void, + loop_: *mut UvLoop, + type_: c_uint, + close_cb: Option, + handle_queue: [*mut c_void; 2], + u: [*mut c_void; 4], + next_closing: *mut c_void, + flags: c_uint, +} + +const _: () = assert!(core::mem::offset_of!(UvHandle, data) == 0); +const _: () = assert!(core::mem::offset_of!(UvHandle, loop_) == 8); +const _: () = assert!(core::mem::offset_of!(UvHandle, type_) == 16); +const _: () = assert!(core::mem::offset_of!(UvHandle, close_cb) == 24); +const _: () = assert!(core::mem::offset_of!(UvHandle, flags) == 88); +const _: () = assert!(core::mem::size_of::() == 96); + +impl UvHandle { + /// Same as libuv's `uv__handle_init`. Doesn't touch `data`, which belongs to the addon. + fn init(this: *mut UvHandle, loop_: *mut UvLoop, type_: c_uint) { + // SAFETY: `this` is the addon's handle memory, and this is the loop thread. + // Write field by field because the memory is uninitialized. + unsafe { + (&raw mut (*this).loop_).write(loop_); + (&raw mut (*this).type_).write(type_); + (&raw mut (*this).close_cb).write(None); + (&raw mut (*this).handle_queue).write([core::ptr::null_mut(); 2]); + (&raw mut (*this).u).write([core::ptr::null_mut(); 4]); + (&raw mut (*this).next_closing).write(core::ptr::null_mut()); + (&raw mut (*this).flags).write(0); + } + } +} + +/// Matches `struct uv_async_s`. Bun's fields replace `UV_ASYNC_PRIVATE_FIELDS`. +#[repr(C)] +pub(crate) struct UvAsync { + handle: UvHandle, + async_cb: Option, + /// Active from init until close. + ref_state: RefState, + /// Number of senders past their first `pending` check, like libuv's busy counter. + /// `uv_close` waits for it to reach 0. + busy: AtomicI32, + /// A send sets it, the dispatch pass clears it, and `uv_close` sets it permanently. + pending: AtomicI32, +} + +const _: () = assert!(core::mem::offset_of!(UvAsync, handle) == 0); +const _: () = assert!(core::mem::offset_of!(UvAsync, async_cb) == 96); +const _: () = assert!(core::mem::size_of::() <= UV_ASYNC_T_SIZE); + +// SAFETY: never create a `&UvAsync` or `&mut UvAsync`. Other threads can be in +// `uv_async_send` on the handle, so access one field at a time through the raw pointer. +impl UvAsync { + /// Clears `pending` for the dispatch pass. Returns true if the handle was sent. + /// + /// # Safety + /// `this` is initialized and not closed. + unsafe fn take_pending(this: NonNull) -> bool { + // SAFETY: fn contract. + unsafe { &(*this.as_ptr()).pending }.swap(0, Ordering::SeqCst) != 0 + } + + /// Called by `uv_close`, like libuv's `uv__async_spin`. Makes later sends return + /// at their first check and waits for the senders already past it, so `close_cb` + /// can free the handle afterwards. + /// + /// # Safety + /// Same as [`Self::take_pending`]. + unsafe fn stop_sends(this: NonNull) { + // SAFETY: fn contract. + let (pending, busy) = unsafe { (&(*this.as_ptr()).pending, &(*this.as_ptr()).busy) }; + pending.store(1, Ordering::SeqCst); + let mut spins = 0u32; + while busy.load(Ordering::SeqCst) != 0 { + spins += 1; + if spins.is_multiple_of(1000) { + std::thread::yield_now(); + } else { + core::hint::spin_loop(); + } + } + } +} + +/// Implements `int uv_async_init(uv_loop_t*, uv_async_t*, uv_async_cb)`. The handle +/// starts active and ref'd, like in libuv. +/// +/// # Safety +/// `loop_` is null or one of this module's loops. `handle` points to +/// `sizeof(uv_async_t)` bytes that the addon keeps alive until its `close_cb` has run. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn uv_async_init( + loop_: *mut UvLoop, + handle: *mut UvAsync, + async_cb: Option, +) -> c_int { + let (Some(loop_ref), Some(async_)) = (NonNull::new(loop_), NonNull::new(handle)) else { + return UV_EINVAL; + }; + bun_output::scoped_log!(uv, "uv_async_t {:?}: uv_async_init", handle); + UvHandle::init(handle.cast::(), loop_, UV_ASYNC); + // SAFETY: fn contract. Write field by field because the addon's memory is uninitialized. + unsafe { + (&raw mut (*handle).async_cb).write(async_cb); + (&raw mut (*handle).ref_state).write(RefState::new()); + (&raw mut (*handle).busy).write(AtomicI32::new(0)); + (&raw mut (*handle).pending).write(AtomicI32::new(0)); + (*handle).ref_state.set_active(true); + } + // SAFETY: the loop is alive (fn contract), and this is the JS thread. + unsafe { loop_ref.as_ref() }.register(async_); + 0 +} + +/// Implements `int uv_async_send(uv_async_t*)`. Any thread can call this. Sends +/// before the next dispatch coalesce into one callback. After `uv_close` it returns +/// at the first check, like libuv's. +/// +/// # Safety +/// `handle` was initialized by `uv_async_init` and its `close_cb` has not run. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn uv_async_send(handle: *mut UvAsync) -> c_int { + // SAFETY: fn contract. The loop thread can be writing the plain fields in + // `uv_close`, so only read the atomics and `loop`, which nothing writes after init. + let (pending, busy, loop_) = + unsafe { (&(*handle).pending, &(*handle).busy, (*handle).handle.loop_) }; + if pending.load(Ordering::SeqCst) != 0 { + // Already scheduled, or closed. + return 0; + } + let _ = busy.fetch_add(1, Ordering::SeqCst); + if pending.swap(1, Ordering::SeqCst) == 0 { + // SAFETY: the loop outlives the handle, and `schedule_dispatch` is thread-safe. + unsafe { &*loop_ }.schedule_dispatch(); + } + let _ = busy.fetch_sub(1, Ordering::SeqCst); + 0 +} + +// ────────────────────────────────────────────────────────────────────────── +// uv_idle_t / uv_prepare_t / uv_check_t +// ────────────────────────────────────────────────────────────────────────── + +/// Matches `struct uv_idle_s`, `uv_prepare_s` and `uv_check_s`, which have the same +/// layout in uv.h. Bun's state replaces libuv's queue links. +#[repr(C)] +pub(crate) struct UvWatcher { + handle: UvHandle, + cb: Option, + ref_state: RefState, +} + +const _: () = assert!(core::mem::offset_of!(UvWatcher, handle) == 0); +const _: () = assert!(core::mem::offset_of!(UvWatcher, cb) == 96); +const _: () = assert!(core::mem::size_of::() <= UV_WATCHER_T_SIZE); + +impl UvWatcher { + /// Implements `uv_{idle,prepare,check}_init`. The handle starts stopped. + /// + /// # Safety + /// `loop_` is null or one of this module's loops. `handle` points to memory the + /// size of its type that the addon keeps alive until its `close_cb` has run. + unsafe fn init(loop_: *mut UvLoop, handle: *mut UvWatcher, type_: c_uint) -> c_int { + if loop_.is_null() || handle.is_null() { + return UV_EINVAL; + } + bun_output::scoped_log!(uv, "uv_handle_t {:?}: init as type {}", handle, type_); + UvHandle::init(handle.cast::(), loop_, type_); + // SAFETY: fn contract. Write field by field because the addon's memory is uninitialized. + unsafe { + (&raw mut (*handle).cb).write(None); + (&raw mut (*handle).ref_state).write(RefState::new()); + } + 0 + } + + /// Implements `uv_{idle,prepare,check}_start`. No-op on a started or closing + /// handle, like in libuv. The callback isn't replaced. + /// + /// # Safety + /// `handle` was initialized by [`Self::init`]. + unsafe fn start(handle: *mut UvWatcher, cb: Option) -> c_int { + // SAFETY: fn contract, on the loop thread. The loop outlives its handles. + unsafe { + if (*handle).ref_state.active || (*handle).handle.flags & UV_HANDLE_CLOSING != 0 { + return 0; + } + if cb.is_none() { + return UV_EINVAL; + } + (*handle).cb = cb; + (*(*handle).handle.loop_) + .watchers((*handle).handle.type_) + .add(NonNull::new_unchecked(handle)); + (*handle).ref_state.set_active(true); + } + 0 + } + + /// Implements `uv_{idle,prepare,check}_stop`. `uv_close` calls it too. + /// + /// # Safety + /// Same as [`Self::start`]. + unsafe fn stop(handle: *mut UvWatcher) { + // SAFETY: fn contract, on the loop thread. The loop outlives its handles. + unsafe { + if !(*handle).ref_state.active { + return; + } + (*(*handle).handle.loop_) + .watchers((*handle).handle.type_) + .remove(NonNull::new_unchecked(handle)); + (*handle).ref_state.set_active(false); + } + } +} + +/// Returns true if `handle` has type `type_`. libuv asserts on the wrong type, but +/// the entry points return `UV_EINVAL`. +/// +/// # Safety +/// `handle` points to an initialized handle. +unsafe fn watcher_has_type(handle: *mut UvWatcher, type_: c_uint) -> bool { + // SAFETY: fn contract. + unsafe { (*handle).handle.type_ == type_ } +} + +macro_rules! watcher_entry_points { + ($type_:expr, $init:ident, $start:ident, $stop:ident) => { + /// See [`UvWatcher::init`]. + /// + /// # Safety + /// Same as [`UvWatcher::init`]. + #[unsafe(no_mangle)] + pub(crate) unsafe extern "C" fn $init(loop_: *mut UvLoop, handle: *mut UvWatcher) -> c_int { + // SAFETY: fn contract. + unsafe { UvWatcher::init(loop_, handle, $type_) } + } + + /// See [`UvWatcher::start`]. + /// + /// # Safety + /// `handle` was initialized by one of this module's `uv_*_init`. + #[unsafe(no_mangle)] + pub(crate) unsafe extern "C" fn $start( + handle: *mut UvWatcher, + cb: Option, + ) -> c_int { + // SAFETY: fn contract. + if !unsafe { watcher_has_type(handle, $type_) } { + return UV_EINVAL; + } + // SAFETY: `handle` is a watcher of this type, checked above. + unsafe { UvWatcher::start(handle, cb) } + } + + /// See [`UvWatcher::stop`]. + /// + /// # Safety + /// `handle` was initialized by one of this module's `uv_*_init`. + #[unsafe(no_mangle)] + pub(crate) unsafe extern "C" fn $stop(handle: *mut UvWatcher) -> c_int { + // SAFETY: fn contract. + if !unsafe { watcher_has_type(handle, $type_) } { + return UV_EINVAL; + } + // SAFETY: `handle` is a watcher of this type, checked above. + unsafe { UvWatcher::stop(handle) }; + 0 + } + }; +} + +watcher_entry_points!(UV_IDLE, uv_idle_init, uv_idle_start, uv_idle_stop); +watcher_entry_points!( + UV_PREPARE, + uv_prepare_init, + uv_prepare_start, + uv_prepare_stop +); +watcher_entry_points!(UV_CHECK, uv_check_init, uv_check_start, uv_check_stop); + +// ────────────────────────────────────────────────────────────────────────── +// uv_timer_t +// ────────────────────────────────────────────────────────────────────────── + +/// Matches `struct uv_timer_s`. Bun's state replaces libuv's heap node and +/// deadlines. An [`EventLoopTimer`] is too big for that space, so the handle owns a +/// [`UvTimerNode`] from init to close. +#[repr(C)] +pub(crate) struct UvTimer { + handle: UvHandle, + timer_cb: Option, + node: *mut UvTimerNode, + repeat_ms: u64, + ref_state: RefState, +} + +const _: () = assert!(core::mem::offset_of!(UvTimer, handle) == 0); +const _: () = assert!(core::mem::offset_of!(UvTimer, timer_cb) == 96); +const _: () = assert!(core::mem::size_of::() <= UV_TIMER_T_SIZE); + +/// Links a `uv_timer_t` into the timer heap. `dispatch.rs` recovers it from +/// `event_loop_timer` when the timer fires. +pub(crate) struct UvTimerNode { + pub(crate) event_loop_timer: EventLoopTimer, + timer: *mut UvTimer, +} + +impl UvTimerNode { + /// Called after the heap pops the timer. Like libuv's `uv__run_timers`, it stops + /// the timer and re-arms a repeating one before it calls the callback, so the + /// callback can stop or close the handle. + /// + /// # Safety + /// `this` is a started timer's node, and this is the loop thread. + pub(crate) unsafe fn on_fire(this: *mut UvTimerNode) -> JsResult<()> { + // SAFETY: fn contract. `FIRED` tells `update`/`remove` that the node isn't + // in the heap. + let timer = unsafe { + (*this).event_loop_timer.state = EventLoopTimerState::FIRED; + (*this).timer + }; + // SAFETY: a started timer is initialized and not closed, and this is the loop thread. + let (cb, repeat_ms, loop_) = unsafe { + (*timer).ref_state.set_active(false); + ((*timer).timer_cb, (*timer).repeat_ms, (*timer).handle.loop_) + }; + if repeat_ms != 0 { + // SAFETY: same as above. + unsafe { UvTimer::arm(timer, repeat_ms) }; + } + bun_output::scoped_log!(uv, "uv_timer_t {:?}: timer_cb", timer); + // SAFETY: the loop outlives its handles. + let vm = unsafe { &*(*loop_).vm.as_ptr() }; + if let Some(cb) = cb { + let _scope = vm.enter_event_loop_scope(); + let _handles = CallbackScope::open(vm.global()); + // SAFETY: calls the addon's callback on the loop thread with its handle. + unsafe { cb(timer) }; + } + if vm.global().has_exception() { + return Err(JsError::Thrown); + } + Ok(()) + } +} + +impl UvTimer { + /// Schedules the timer to fire `timeout_ms` from now and marks it started. + /// + /// # Safety + /// `handle` is a timer that's initialized and not closed, and this is the loop thread. + unsafe fn arm(handle: *mut UvTimer, timeout_ms: u64) { + let due = Timespec::ms_from_now( + TimespecMockMode::ForceRealTime, + i64::try_from(timeout_ms).unwrap_or(i64::MAX), + ); + // SAFETY: fn contract. A timer owns its node until it's closed. + unsafe { + timer_all_mut().update(&raw mut (*(*handle).node).event_loop_timer, &due); + (*handle).ref_state.set_active(true); + } + } + + /// Implements `uv_timer_stop`. Checks the node's state before removing it from the + /// heap because a fired node is already out, and VM teardown unlinks timers + /// without telling their owners. + /// + /// # Safety + /// Same as [`Self::arm`]. + unsafe fn stop(handle: *mut UvTimer) { + // SAFETY: fn contract. + unsafe { + if !(*handle).ref_state.active { + return; + } + let event_loop_timer = &raw mut (*(*handle).node).event_loop_timer; + if (*event_loop_timer).state == EventLoopTimerState::ACTIVE { + timer_all_mut().remove(event_loop_timer); + } + (*handle).ref_state.set_active(false); + } + } + + /// Called by `uv_close`. Frees the node here instead of in the close task because + /// a stopping VM refuses the task. Nothing reads the node once the closing flag is set. + /// + /// # Safety + /// Same as [`Self::arm`], and `handle` is being closed for the first time. + unsafe fn close(handle: *mut UvTimer) { + // SAFETY: fn contract. `stop` removes the node from the heap, and + // `uv_timer_init` allocated `node` with `heap::alloc`. + unsafe { + UvTimer::stop(handle); + bun_core::heap::destroy((*handle).node); + (*handle).node = core::ptr::null_mut(); + } + } + + /// Returns true if `handle` is a timer. + /// + /// # Safety + /// `handle` points to an initialized handle. + unsafe fn check(handle: *mut UvTimer) -> bool { + // SAFETY: fn contract. + unsafe { (*handle).handle.type_ == UV_TIMER } + } +} + +/// Implements `int uv_timer_init(uv_loop_t*, uv_timer_t*)`. +/// +/// # Safety +/// Same as [`UvWatcher::init`]. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn uv_timer_init(loop_: *mut UvLoop, handle: *mut UvTimer) -> c_int { + if loop_.is_null() || handle.is_null() { + return UV_EINVAL; + } + bun_output::scoped_log!(uv, "uv_timer_t {:?}: uv_timer_init", handle); + UvHandle::init(handle.cast::(), loop_, UV_TIMER); + let node = bun_core::heap::alloc(UvTimerNode { + event_loop_timer: EventLoopTimer::init_paused(EventLoopTimerTag::UvTimer), + timer: handle, + }); + // SAFETY: fn contract. Write field by field because the addon's memory is uninitialized. + unsafe { + (&raw mut (*handle).timer_cb).write(None); + (&raw mut (*handle).node).write(node); + (&raw mut (*handle).repeat_ms).write(0); + (&raw mut (*handle).ref_state).write(RefState::new()); + } + 0 +} + +/// Implements `int uv_timer_start(uv_timer_t*, uv_timer_cb, uint64_t timeout, +/// uint64_t repeat)`. Restarts a timer that's already started. +/// +/// # Safety +/// `handle` was initialized by one of this module's `uv_*_init`. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn uv_timer_start( + handle: *mut UvTimer, + cb: Option, + timeout_ms: u64, + repeat_ms: u64, +) -> c_int { + // SAFETY: fn contract. + if !unsafe { UvTimer::check(handle) } || cb.is_none() { + return UV_EINVAL; + } + // SAFETY: `handle` is a timer, and this is the loop thread. + unsafe { + if (*handle).handle.flags & UV_HANDLE_CLOSING != 0 { + return UV_EINVAL; + } + (*handle).timer_cb = cb; + (*handle).repeat_ms = repeat_ms; + UvTimer::arm(handle, timeout_ms); + } + 0 +} + +/// Implements `int uv_timer_stop(uv_timer_t*)`. +/// +/// # Safety +/// Same as [`uv_timer_start`]. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn uv_timer_stop(handle: *mut UvTimer) -> c_int { + // SAFETY: fn contract. + if !unsafe { UvTimer::check(handle) } { + return UV_EINVAL; + } + // SAFETY: `handle` is a timer, and this is the loop thread. + unsafe { UvTimer::stop(handle) }; + 0 +} + +/// Implements `int uv_timer_again(uv_timer_t*)`. Restarts a repeating timer. Returns +/// `UV_EINVAL` if the timer was never started. +/// +/// # Safety +/// Same as [`uv_timer_start`]. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn uv_timer_again(handle: *mut UvTimer) -> c_int { + // SAFETY: fn contract. + if !unsafe { UvTimer::check(handle) } { + return UV_EINVAL; + } + // SAFETY: `handle` is a timer, and this is the loop thread. + unsafe { + if (*handle).timer_cb.is_none() { + return UV_EINVAL; + } + let repeat_ms = (*handle).repeat_ms; + if repeat_ms != 0 && (*handle).handle.flags & UV_HANDLE_CLOSING == 0 { + UvTimer::arm(handle, repeat_ms); + } + } + 0 +} + +/// Implements `void uv_timer_set_repeat(uv_timer_t*, uint64_t)`. The new value +/// applies from the next fire or `uv_timer_again`, like in libuv. +/// +/// # Safety +/// Same as [`uv_timer_start`]. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn uv_timer_set_repeat(handle: *mut UvTimer, repeat_ms: u64) { + // SAFETY: fn contract. + if unsafe { UvTimer::check(handle) } { + // SAFETY: `handle` is a timer, and this is the loop thread. + unsafe { (*handle).repeat_ms = repeat_ms }; + } +} + +/// Implements `uint64_t uv_timer_get_repeat(const uv_timer_t*)`. +/// +/// # Safety +/// Same as [`uv_timer_start`]. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn uv_timer_get_repeat(handle: *mut UvTimer) -> u64 { + // SAFETY: fn contract. + if !unsafe { UvTimer::check(handle) } { + return 0; + } + // SAFETY: `handle` is a timer, and this is the loop thread. + unsafe { (*handle).repeat_ms } +} + +/// Implements `uint64_t uv_timer_get_due_in(const uv_timer_t*)`. Returns 0 if the +/// timer is due or not started. +/// +/// # Safety +/// Same as [`uv_timer_start`]. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn uv_timer_get_due_in(handle: *mut UvTimer) -> u64 { + // SAFETY: fn contract. + if !unsafe { UvTimer::check(handle) } { + return 0; + } + // SAFETY: `handle` is a timer, and this is the loop thread. A started timer isn't + // closed, so it owns its node. + let due = unsafe { + if !(*handle).ref_state.active { + return 0; + } + (*(*handle).node).event_loop_timer.next + }; + let now = Timespec::now(TimespecMockMode::ForceRealTime); + u64::try_from(due.ms().wrapping_sub(now.ms())).unwrap_or(0) +} + +/// Implements `uint64_t uv_now(const uv_loop_t*)`. Returns the timer heap's clock in +/// milliseconds. libuv caches it per iteration, but this reads it every time. +#[unsafe(no_mangle)] +pub(crate) extern "C" fn uv_now(_loop: *const UvLoop) -> u64 { + u64::try_from(Timespec::now(TimespecMockMode::ForceRealTime).ms()).unwrap_or(0) +} + +/// Implements `void uv_update_time(uv_loop_t*)`. No-op because `uv_now` isn't cached. +#[unsafe(no_mangle)] +pub(crate) extern "C" fn uv_update_time(_loop: *mut UvLoop) {} + +// ────────────────────────────────────────────────────────────────────────── +// uv_handle_t (functions that accept any handle type) +// ────────────────────────────────────────────────────────────────────────── + +/// Returns `handle`'s type. Crashes like `function`'s stub if Bun doesn't implement +/// that handle type. +/// +/// # Safety +/// `handle` points to an initialized handle. +unsafe fn handle_type(handle: *mut UvHandle, function: &'static CStr) -> c_uint { + // SAFETY: fn contract. + match unsafe { (*handle).type_ } { + type_ @ (UV_ASYNC | UV_IDLE | UV_PREPARE | UV_CHECK | UV_TIMER) => type_, + _ => unsupported(function), + } +} + +/// Returns the handle's [`RefState`], whose offset depends on the handle type. +/// +/// # Safety +/// Same as [`handle_type`]. This is the loop thread, and the caller doesn't use the +/// returned pointer after it calls back into the addon. +unsafe fn ref_state(handle: *mut UvHandle, function: &'static CStr) -> *mut RefState { + // SAFETY: fn contract. `type` says which struct `handle` is the start of. + unsafe { + match handle_type(handle, function) { + UV_ASYNC => &raw mut (*handle.cast::()).ref_state, + UV_TIMER => &raw mut (*handle.cast::()).ref_state, + _ => &raw mut (*handle.cast::()).ref_state, + } + } +} + +/// `uv_close` posts the handle as a task. +impl Taskable for UvHandle { + const TAG: TaskTag = task_tag::UvHandleClose; + /// Still runs `close_cb` because that's where the addon frees the handle. Any JS + /// it tries to run is refused. + unsafe fn release_unrun(this: *mut Self) { + // On `Err` the exception is still pending, and the release dispatcher handles it. + // SAFETY: fn contract. + let _ = unsafe { UvHandle::run_close(this) }; + } + /// Not tied to one context, like [`UvLoop`]'s task. + unsafe fn context(_: *const Self) -> ContextId { + ContextId::NONE + } +} + +impl UvHandle { + /// Runs `close_cb` one turn after `uv_close`. The callback usually frees the + /// handle, so don't read it afterwards. + /// + /// # Safety + /// `uv_close` posted the task for `handle`, which the addon keeps alive until + /// `close_cb` has run. This is the loop thread. + pub(crate) unsafe fn run_close(handle: *mut UvHandle) -> JsResult<()> { + // SAFETY: fn contract. + let (close_cb, loop_) = unsafe { + (*handle).flags |= UV_HANDLE_CLOSED; + ((*handle).close_cb, (*handle).loop_) + }; + bun_output::scoped_log!(uv, "uv_handle_t {:?}: close_cb", handle); + // SAFETY: the VM owns the loop, so it outlives its handles. + let global = unsafe { &*loop_ }.global(); + if let Some(close_cb) = close_cb { + let _handles = CallbackScope::open(global); + // SAFETY: calls the addon's callback on the loop thread with its handle. + unsafe { close_cb(handle) }; + } + if global.has_exception() { + return Err(JsError::Thrown); + } + Ok(()) + } +} + +/// Implements `void uv_close(uv_handle_t*, uv_close_cb)`. Stops the handle immediately +/// and runs `close_cb` on a later turn, like libuv does. A second close is a no-op. +/// +/// # Safety +/// `handle` was initialized by one of this module's `uv_*_init`. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn uv_close(handle: *mut UvHandle, close_cb: Option) { + // SAFETY: fn contract. + let type_ = unsafe { handle_type(handle, c"uv_close") }; + // SAFETY: `handle` is initialized, and only the loop thread uses the plain fields. + let loop_ = unsafe { + if (*handle).flags & UV_HANDLE_CLOSING != 0 { + return; + } + (*handle).flags |= UV_HANDLE_CLOSING; + (*handle).close_cb = close_cb; + // The loop outlives its handles. + &*(*handle).loop_ + }; + bun_output::scoped_log!(uv, "uv_handle_t {:?}: uv_close", handle); + // SAFETY: `handle` was initialized as `type_` and wasn't closed before this call. + unsafe { + match type_ { + UV_ASYNC => { + let async_ = NonNull::new_unchecked(handle.cast::()); + UvAsync::stop_sends(async_); + loop_.unregister(async_); + (*async_.as_ptr()).ref_state.set_active(false); + } + UV_TIMER => UvTimer::close(handle.cast::()), + _ => UvWatcher::stop(handle.cast::()), + } + } + // The queued task keeps the loop alive until `close_cb` has run, like libuv's + // closing list. If the VM is gone, the task is refused and `close_cb` never runs. + let task = ConcurrentTask::create_from(handle); + if let Posted::Refused(task) = loop_.handle.post(LoopKind::Regular, task) { + // SAFETY: a refused task was never queued, so we own it. + unsafe { ConcurrentTask::release_refused(task) }; + } +} + +/// Implements `void uv_ref(uv_handle_t*)`. Idempotent. Only matters while the handle +/// is active. +/// +/// # Safety +/// Same as [`uv_close`]. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn uv_ref(handle: *mut UvHandle) { + // SAFETY: fn contract. + unsafe { (*ref_state(handle, c"uv_ref")).set_referenced(true) }; +} + +/// Implements `void uv_unref(uv_handle_t*)`. Idempotent. +/// +/// # Safety +/// Same as [`uv_close`]. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn uv_unref(handle: *mut UvHandle) { + // SAFETY: fn contract. + unsafe { (*ref_state(handle, c"uv_unref")).set_referenced(false) }; +} + +/// Implements `int uv_has_ref(const uv_handle_t*)`. +/// +/// # Safety +/// Same as [`uv_close`]. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn uv_has_ref(handle: *mut UvHandle) -> c_int { + // SAFETY: fn contract. + c_int::from(unsafe { (*ref_state(handle, c"uv_has_ref")).referenced }) +} + +/// Implements `int uv_is_active(const uv_handle_t*)`. A watcher or timer is active +/// while it's started. An async handle is active until it's closed because libuv +/// starts it in init. +/// +/// # Safety +/// Same as [`uv_close`]. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn uv_is_active(handle: *mut UvHandle) -> c_int { + // SAFETY: fn contract. + c_int::from(unsafe { (*ref_state(handle, c"uv_is_active")).active }) +} + +/// Implements `int uv_is_closing(const uv_handle_t*)`. Returns true once `uv_close` +/// has been called. +/// +/// # Safety +/// Same as [`uv_close`]. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn uv_is_closing(handle: *mut UvHandle) -> c_int { + // SAFETY: fn contract. + unsafe { handle_type(handle, c"uv_is_closing") }; + // SAFETY: `handle` is initialized, and this is the loop thread. + let flags = unsafe { (*handle).flags }; + c_int::from(flags & (UV_HANDLE_CLOSING | UV_HANDLE_CLOSED) != 0) +} + +// ────────────────────────────────────────────────────────────────────────── +// uv_req_t / uv_work_t +// ────────────────────────────────────────────────────────────────────────── + +/// Matches `UV_REQ_FIELDS` (uv.h), which every request starts with. Addons read +/// `data` and `type`. +#[repr(C)] +pub(crate) struct UvReq { + data: *mut c_void, + type_: c_uint, + reserved: [*mut c_void; 6], +} + +const _: () = assert!(core::mem::offset_of!(UvReq, data) == 0); +const _: () = assert!(core::mem::offset_of!(UvReq, type_) == 8); +const _: () = assert!(core::mem::size_of::() == 64); + +#[repr(u32)] +#[derive(Clone, Copy, PartialEq, Eq)] +enum WorkState { + /// In the thread pool's queue. `uv_cancel` can still cancel it. + Queued = 0, + /// `work_cb` has started or finished, so it's too late to cancel. + Running = 1, + /// `uv_cancel` canceled it, so `after_work_cb` gets `UV_ECANCELED`. + Cancelled = 2, +} + +/// Matches `struct uv_work_s`. Addons also read the three fields after `req`. Bun's +/// state replaces libuv's `struct uv__work`. +#[repr(C)] +pub(crate) struct UvWork { + req: UvReq, + loop_: *mut UvLoop, + work_cb: Option, + after_work_cb: Option, + /// A [`WorkState`]. The pool thread and `uv_cancel` race to change it from `Queued`. + state: AtomicU32, +} + +const _: () = assert!(core::mem::offset_of!(UvWork, req) == 0); +const _: () = assert!(core::mem::offset_of!(UvWork, loop_) == 64); +const _: () = assert!(core::mem::offset_of!(UvWork, work_cb) == 72); +const _: () = assert!(core::mem::offset_of!(UvWork, after_work_cb) == 80); +const _: () = assert!(core::mem::size_of::() <= UV_WORK_T_SIZE); + +/// Runs one `uv_queue_work` request as a [`Job`]. The job's off-thread state is the +/// request, which the addon keeps alive until `after_work_cb` has run. That covers +/// the job's whole lifetime. +/// +/// A stopping VM releases the job without calling `after_work_cb`, so the addon's +/// request leaks. Calling it isn't safe then. `work_cb` may never have run, and addons +/// that never call `uv_cancel` ignore the status. The V8 API would also still enter JS. +struct UvWorkJob; + +impl JobContext for UvWorkJob { + type OffThread = JsPtr; + type Js = (); + + // SAFETY: never create a reference to the whole request, because `run` on the pool + // thread can overlap `uv_cancel` on the loop thread. Access fields through the raw + // pointer and borrow only `state`. + + fn run(req: &mut JsPtr, done: Completion) -> Option> { + let req = req.as_ptr(); + // SAFETY: the request is alive for the job's lifetime (see `UvWorkJob`), and + // `work_cb` was written before the job was scheduled. + let started = unsafe { &(*req).state } + .compare_exchange( + WorkState::Queued as u32, + WorkState::Running as u32, + Ordering::SeqCst, + Ordering::SeqCst, + ) + .is_ok(); + // SAFETY: same as above. + if started && let Some(work_cb) = unsafe { (*req).work_cb } { + // SAFETY: calls the addon's callback on a pool thread, as libuv documents. + unsafe { work_cb(req) }; + } + Some(done) + } + + fn then(req: JsPtr, _: (), cx: &JsThread<'_>) -> JsResult<()> { + let req = req.as_ptr(); + // SAFETY: same as `run`. `state` doesn't change after the job is posted. + let (state, after_work_cb) = + unsafe { ((*req).state.load(Ordering::SeqCst), (*req).after_work_cb) }; + let status = if state == WorkState::Cancelled as u32 { + UV_ECANCELED + } else { + 0 + }; + bun_output::scoped_log!(uv, "uv_work_t {:?}: after_work_cb({})", req, status); + // The callback usually frees the request. + if let Some(after_work_cb) = after_work_cb { + let _handles = CallbackScope::open(cx.global()); + // SAFETY: calls the addon's callback on the loop thread. + unsafe { after_work_cb(req, status) }; + } + if cx.global().has_exception() { + return Err(JsError::Thrown); + } + Ok(()) + } +} + +/// Implements `int uv_queue_work(uv_loop_t*, uv_work_t*, uv_work_cb, uv_after_work_cb)`. +/// The request keeps the process alive, like an active request in libuv. +/// +/// # Safety +/// `loop_` is null or one of this module's loops. `req` points to `sizeof(uv_work_t)` +/// bytes that the addon keeps alive until its `after_work_cb` has run. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn uv_queue_work( + loop_: *mut UvLoop, + req: *mut UvWork, + work_cb: Option, + after_work_cb: Option, +) -> c_int { + let (Some(loop_ref), Some(req_ref), Some(_)) = ( + NonNull::new(loop_), + NonNull::new(req), + work_cb, // libuv also returns UV_EINVAL + ) else { + return UV_EINVAL; + }; + bun_output::scoped_log!(uv, "uv_work_t {:?}: uv_queue_work", req); + // SAFETY: fn contract. Write field by field because the addon's memory is + // uninitialized. Skip `data`, which belongs to the addon. + unsafe { + (&raw mut (*req).req.type_).write(UV_WORK); + (&raw mut (*req).req.reserved).write([core::ptr::null_mut(); 6]); + (&raw mut (*req).loop_).write(loop_); + (&raw mut (*req).work_cb).write(work_cb); + (&raw mut (*req).after_work_cb).write(after_work_cb); + (&raw mut (*req).state).write(AtomicU32::new(WorkState::Queued as u32)); + } + // SAFETY: the loop is alive (fn contract), and this is the JS thread. + let cx = unsafe { loop_ref.as_ref() } + .global() + .js_thread_of_caller_no_frame(); + // SAFETY: the request outlives the job (fn contract, see `UvWorkJob`). + Job::::schedule(&cx, unsafe { JsPtr::new(req_ref) }, ()); + 0 +} + +/// Implements `int uv_cancel(uv_req_t*)`. Like in libuv, it runs on the loop thread, +/// so it can't race `after_work_cb`. Returns the same codes as libuv: +/// - `0` if the work was canceled. `after_work_cb` then gets `UV_ECANCELED`. +/// - `UV_EBUSY` if the work already started. +/// - `UV_EINVAL` for any request type other than `uv_work_t`. +/// +/// # Safety +/// `req` is null or a queued request whose `after_work_cb` has not returned. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn uv_cancel(req: *mut UvReq) -> c_int { + if req.is_null() { + return UV_EINVAL; + } + // SAFETY: fn contract. `type` is written before the request is queued. + if unsafe { (*req).type_ } != UV_WORK { + return UV_EINVAL; + } + // SAFETY: `type` says this is a `UvWork`. Only touch its atomic because the pool + // thread can be in `run`. + let state = unsafe { &(*req.cast::()).state }; + match state.compare_exchange( + WorkState::Queued as u32, + WorkState::Cancelled as u32, + Ordering::SeqCst, + Ordering::SeqCst, + ) { + Ok(_) => 0, + Err(_) => UV_EBUSY, + } +} + +pub(crate) fn fix_dead_code_elimination() { + bun_core::keep_symbols!( + uv_async_init, + uv_async_send, + uv_cancel, + uv_check_init, + uv_check_start, + uv_check_stop, + uv_close, + uv_default_loop, + uv_has_ref, + uv_idle_init, + uv_idle_start, + uv_idle_stop, + uv_is_active, + uv_is_closing, + uv_now, + uv_prepare_init, + uv_prepare_start, + uv_prepare_stop, + uv_queue_work, + uv_ref, + uv_timer_again, + uv_timer_get_due_in, + uv_timer_get_repeat, + uv_timer_init, + uv_timer_set_repeat, + uv_timer_start, + uv_timer_stop, + uv_unref, + uv_update_time, + ); +} diff --git a/src/runtime/node/dir_iterator.rs b/src/runtime/node/dir_iterator.rs index 9a5f56a5e834..b6fe5a27156a 100644 --- a/src/runtime/node/dir_iterator.rs +++ b/src/runtime/node/dir_iterator.rs @@ -6,12 +6,13 @@ #![warn(unused_must_use)] +#[cfg(windows)] use core::mem::offset_of; use bun_core::RawSlice; -#[cfg(not(target_os = "macos"))] -use bun_sys::Tag; -use bun_sys::{self as sys, Fd}; +#[cfg(unix)] +use bun_sys::dirent; +use bun_sys::{self as sys, Fd, Tag}; // `Entry.Kind` is `bun_core::FileKind`, re-exported here as // `bun_sys::EntryKind` (and as `crate::node::types::DirentKind`). @@ -32,8 +33,8 @@ impl IteratorResult { #[inline] pub(crate) fn name_assume_z(&self) -> &bun_core::ZStr { let s = self.name.slice(); - // SAFETY: `d_name` is NUL-terminated by the kernel; `name` points at it - // with len excluding the NUL, so `[len] == 0`. + // SAFETY: `name` comes from a `dirent::Entry` (or from `from_w_path` on Windows). + // Both NUL-terminate it, and `len` excludes the NUL. unsafe { bun_core::ZStr::from_raw(s.as_ptr(), s.len()) } } } @@ -84,20 +85,11 @@ impl WrappedSelect for () {} #[cfg(target_os = "macos")] mod platform { use super::*; - use core::ptr::addr_of; - - /// Darwin's `struct dirent` (64-bit ino) leads with `d_ino: u64` (align 8); - /// a bare `[u8; N]` field has alignment 1, so wrap it to force 8-byte - /// alignment for the *first* record. Subsequent records are only 4-byte - /// aligned by the kernel (`d_reclen` rounds to 4), so reads still go - /// through `read_unaligned`. - #[repr(C, align(8))] - pub(crate) struct DirentBuf(pub [u8; 8192]); pub(crate) struct NewIterator { pub(crate) dir: Fd, pub(crate) seek: i64, - pub(crate) buf: DirentBuf, + pub(crate) buf: [u8; 8192], pub(crate) index: usize, pub(crate) end_index: usize, pub(crate) received_eof: bool, @@ -112,7 +104,11 @@ mod platform { fn next_darwin(&mut self) -> Result { 'start_over: loop { - if self.index >= self.end_index { + // A refill that reports more bytes than the buffer's size is malformed. + let Some(filled) = self.buf.get(..self.end_index) else { + return dirent::end_walk(&mut self.end_index, Tag::getdirentries64); + }; + if self.index >= filled.len() { if self.received_eof { return Ok(None); } @@ -126,13 +122,13 @@ mod platform { const GETDIRENTRIES64_EXTENDED_BUFSIZE: usize = 1024; const _: () = assert!(8192 >= GETDIRENTRIES64_EXTENDED_BUFSIZE); self.received_eof = false; - let len = self.buf.0.len(); + let len = self.buf.len(); // SAFETY: buf is 8192 writable bytes; seek is a valid *mut i64. let n = unsafe { sys::getdirentries64( self.dir, - self.buf.0.as_mut_ptr(), + self.buf.as_mut_ptr(), len, &raw mut self.seek, ) @@ -146,45 +142,22 @@ mod platform { self.index = 0; self.end_index = n; let eof_flag = u32::from_ne_bytes( - self.buf.0[len - 4..len] + self.buf[len - 4..len] .try_into() .expect("infallible: size matches"), ); - self.received_eof = self.end_index <= (self.buf.0.len() - 4) && eof_flag == 1; - } - // Records are variable-length; the kernel rounds `d_reclen` to - // 4 bytes, not 8, so subsequent entries are NOT aligned to - // `align_of::() == 8`. Additionally each on-disk - // record is shorter than `size_of::()` (1048), - // so forming a `&libc::dirent` would assert validity past the - // record / buffer end. Never materialize a reference — read - // each field through the raw pointer. - // SAFETY: self.index < self.end_index <= buf.len(); kernel - // wrote a valid (possibly 4-aligned) dirent record here. - let entry = unsafe { self.buf.0.as_ptr().add(self.index).cast::() }; - // SAFETY: `entry` points at a valid (possibly unaligned) - // dirent; addr_of! avoids creating intermediate references. - let d_reclen: u16 = unsafe { addr_of!((*entry).d_reclen).read_unaligned() }; - // SAFETY: same `entry` record as above. - let d_namlen: u16 = unsafe { addr_of!((*entry).d_namlen).read_unaligned() }; - // SAFETY: same `entry` record as above. - let d_ino: u64 = unsafe { addr_of!((*entry).d_ino).read_unaligned() }; - // SAFETY: same `entry` record as above. - let d_type: u8 = unsafe { addr_of!((*entry).d_type).read_unaligned() }; - let entry_idx = self.index; - self.index += d_reclen as usize; - - // d_name is `d_namlen` initialized bytes at a fixed offset within - // the record; slice it directly from `buf` (bounds-checked) instead - // of going through the raw `*const dirent`. - let name_off = entry_idx + offset_of!(libc::dirent, d_name); - let name = &self.buf.0[name_off..name_off + d_namlen as usize]; - - if name == b"." || name == b".." || d_ino == 0 { + self.received_eof = self.end_index <= (self.buf.len() - 4) && eof_flag == 1; continue 'start_over; } + let Some(record) = dirent::parse::(filled, self.index) else { + return dirent::end_walk(&mut self.end_index, Tag::getdirentries64); + }; + self.index = record.next; + let Some(entry) = record.entry else { + continue 'start_over; + }; - let entry_kind = match d_type { + let entry_kind = match entry.d_type { libc::DT_BLK => EntryKind::BlockDevice, libc::DT_CHR => EntryKind::CharacterDevice, libc::DT_DIR => EntryKind::Directory, @@ -197,7 +170,7 @@ mod platform { _ => EntryKind::Unknown, }; return Ok(Some(IteratorResult { - name: RawSlice::new(name), + name: RawSlice::new(entry.name), kind: entry_kind, })); } @@ -211,24 +184,17 @@ mod platform { #[cfg(target_os = "freebsd")] mod platform { use super::*; - use core::ptr::addr_of; // The `libc` crate binds neither `getdents` nor `getdirentries` on // FreeBSD, so declare the former here. unsafe extern "C" { - // SAFETY precondition: `buf` must be writable for `nbytes` bytes and - // dirent-aligned — raw-pointer contract, cannot be `safe fn`. + // Not a `safe fn` because `buf` must be writable for `nbytes` bytes. fn getdents(fd: core::ffi::c_int, buf: *mut core::ffi::c_char, nbytes: usize) -> isize; } - /// FreeBSD's `struct dirent` leads with `ino_t` (u64, align 8); a bare - /// `[u8; N]` field has alignment 1, so wrap it to force 8-byte alignment. - #[repr(C, align(8))] - pub(crate) struct DirentBuf(pub [u8; 8192]); - pub(crate) struct NewIterator { pub(crate) dir: Fd, - pub(crate) buf: DirentBuf, + pub(crate) buf: [u8; 8192], pub(crate) index: usize, pub(crate) end_index: usize, } @@ -236,13 +202,17 @@ mod platform { impl NewIterator { pub(crate) fn next(&mut self) -> Result { 'start_over: loop { - if self.index >= self.end_index { - // SAFETY: dir is a valid open fd; buf is dirent-aligned scratch. + // A refill that reports more bytes than the buffer's size is malformed. + let Some(filled) = self.buf.get(..self.end_index) else { + return dirent::end_walk(&mut self.end_index, Tag::getdents64); + }; + if self.index >= filled.len() { + // SAFETY: `dir` is an open fd and `buf` is writable for its full length. let rc = unsafe { getdents( self.dir.native(), - self.buf.0.as_mut_ptr().cast::(), - self.buf.0.len(), + self.buf.as_mut_ptr().cast::(), + self.buf.len(), ) }; if rc < 0 { @@ -259,29 +229,17 @@ mod platform { } self.index = 0; self.end_index = usize::try_from(rc).expect("int cast"); - } - // Records are variable-length; subsequent entries may not be - // 8-byte aligned. Never - // form a `&dirent` — read each field through the raw pointer. - // SAFETY: index < end_index ≤ 8192; kernel wrote a valid record. - let entry = unsafe { self.buf.0.as_ptr().add(self.index).cast::() }; - // SAFETY: entry points at a valid (possibly unaligned) dirent. - let d_reclen: u16 = unsafe { addr_of!((*entry).d_reclen).read_unaligned() }; - let d_namlen: u16 = unsafe { addr_of!((*entry).d_namlen).read_unaligned() }; - let d_fileno: u64 = unsafe { addr_of!((*entry).d_fileno).read_unaligned() }; - let d_type: u8 = unsafe { addr_of!((*entry).d_type).read_unaligned() }; - let entry_idx = self.index; - self.index += d_reclen as usize; - - // d_name is `d_namlen` bytes at a fixed offset within the record; - // slice it directly from `buf` (bounds-checked). - let name_off = entry_idx + offset_of!(libc::dirent, d_name); - let name = &self.buf.0[name_off..name_off + d_namlen as usize]; - if name == b"." || name == b".." || d_fileno == 0 { continue 'start_over; } + let Some(record) = dirent::parse::(filled, self.index) else { + return dirent::end_walk(&mut self.end_index, Tag::getdents64); + }; + self.index = record.next; + let Some(entry) = record.entry else { + continue 'start_over; + }; - let entry_kind: EntryKind = match d_type { + let entry_kind: EntryKind = match entry.d_type { libc::DT_BLK => EntryKind::BlockDevice, libc::DT_CHR => EntryKind::CharacterDevice, libc::DT_DIR => EntryKind::Directory, @@ -295,7 +253,7 @@ mod platform { _ => EntryKind::Unknown, }; return Ok(Some(IteratorResult { - name: RawSlice::new(name), + name: RawSlice::new(entry.name), kind: entry_kind, })); } @@ -310,18 +268,9 @@ mod platform { mod platform { use super::*; - /// `dirent64` leads with `d_ino: u64` (align 8); a bare `[u8; N]` field has - /// alignment 1, so wrap it to force 8-byte alignment of the buffer base. - /// The kernel pads `d_reclen` to a multiple of 8, so every record stays - /// 8-aligned as long as the base is. - #[repr(C, align(8))] - pub(crate) struct DirentBuf(pub [u8; 8192]); - const _: () = - assert!(core::mem::align_of::() >= core::mem::align_of::()); - pub(crate) struct NewIterator { pub(crate) dir: Fd, - pub(crate) buf: DirentBuf, + pub(crate) buf: [u8; 8192], pub(crate) index: usize, pub(crate) end_index: usize, } @@ -331,7 +280,11 @@ mod platform { /// with subsequent calls to `next`, as well as when this `Dir` is deinitialized. pub(crate) fn next(&mut self) -> Result { 'start_over: loop { - if self.index >= self.end_index { + // A refill that reports more bytes than the buffer's size is malformed. + let Some(filled) = self.buf.get(..self.end_index) else { + return dirent::end_walk(&mut self.end_index, Tag::getdents64); + }; + if self.index >= filled.len() { // glibc doesn't expose getdents64; go straight to the // raw syscall. // SAFETY: buf is valid for 8192 bytes; fd is a plain c_int. @@ -339,8 +292,8 @@ mod platform { libc::syscall( libc::SYS_getdents64, self.dir.native() as libc::c_long, - self.buf.0.as_mut_ptr(), - self.buf.0.len(), + self.buf.as_mut_ptr(), + self.buf.len(), ) }; if rc < 0 { @@ -354,39 +307,17 @@ mod platform { } self.index = 0; self.end_index = rc as usize; - } - // Records are variable-length; `libc::dirent64` declares - // `d_name: [c_char; 256]` but the kernel only writes up to - // `d_reclen` bytes, so forming a `&dirent64` could span past - // the filled region (or past `buf` near the end). Never form a - // reference — read each field through the raw pointer. - // SAFETY: index < end_index ≤ 8192; kernel wrote a valid - // record header at this offset. `DirentBuf` is align(8) and - // d_reclen is always a multiple of 8, so `entry` is 8-aligned. - let entry = unsafe { self.buf.0.as_ptr().add(self.index).cast::() }; - debug_assert!(entry.is_aligned()); - // SAFETY: entry points at a valid record header within buf. - let d_reclen: u16 = unsafe { core::ptr::addr_of!((*entry).d_reclen).read() }; - // SAFETY: see above. - let d_type: u8 = unsafe { core::ptr::addr_of!((*entry).d_type).read() }; - let entry_idx = self.index; - let next_index = entry_idx + d_reclen as usize; - self.index = next_index; - - // dirent64.d_name is NUL-terminated within [name_off, next_index); - // slice it directly from `buf` (bounds-checked) and scan for NUL - // instead of dereferencing the raw `*const dirent64`. - let name_off = entry_idx + offset_of!(libc::dirent64, d_name); - let region = &self.buf.0[name_off..next_index]; - let nul = bun_core::strings::index_of_char_usize(region, 0).unwrap_or(region.len()); - let name = ®ion[..nul]; - - // skip . and .. entries - if name == b"." || name == b".." { continue 'start_over; } + let Some(record) = dirent::parse::(filled, self.index) else { + return dirent::end_walk(&mut self.end_index, Tag::getdents64); + }; + self.index = record.next; + let Some(entry) = record.entry else { + continue 'start_over; + }; - let entry_kind: EntryKind = match d_type { + let entry_kind: EntryKind = match entry.d_type { libc::DT_BLK => EntryKind::BlockDevice, libc::DT_CHR => EntryKind::CharacterDevice, libc::DT_DIR => EntryKind::Directory, @@ -400,7 +331,7 @@ mod platform { _ => EntryKind::Unknown, }; return Ok(Some(IteratorResult { - name: RawSlice::new(name), + name: RawSlice::new(entry.name), kind: entry_kind, })); } @@ -761,7 +692,7 @@ where index: 0, end_index: 0, // zero-init avoids the invalid_value lint on [u8; N] - buf: platform::DirentBuf([0u8; 8192]), + buf: [0u8; 8192], received_eof: false, }, }; @@ -774,7 +705,7 @@ where index: 0, end_index: 0, // zero-init avoids the invalid_value lint on [u8; N] - buf: platform::DirentBuf([0u8; 8192]), + buf: [0u8; 8192], }, }; } @@ -786,7 +717,7 @@ where index: 0, end_index: 0, // zero-init avoids the invalid_value lint on [u8; N] - buf: platform::DirentBuf([0u8; 8192]), + buf: [0u8; 8192], }, }; } diff --git a/src/runtime/node/node_cluster_binding.bind.ts b/src/runtime/node/node_cluster_binding.bind.ts index 5f806f5bc8f6..ed01a0b6b5e2 100644 --- a/src/runtime/node/node_cluster_binding.bind.ts +++ b/src/runtime/node/node_cluster_binding.bind.ts @@ -9,7 +9,7 @@ export const onInternalMessageChild = b.fn({ }); export const setRef = b.fn({ args: { enabled: b.bool } }); export const channelIgnoreOneDisconnectEventListener = b.fn({}); -export const clusterValidateFd = b.fn({ args: { fd: b.f64 }, returns: b.i32 }); +export const clusterValidateFd = b.fn({ args: { fd: b.f64, wantDgram: b.bool }, returns: b.i32 }); export const clusterCloseHandle = b.fn({ args: { fd: b.f64 } }); /** `process[kHandle]` of a `cluster.Worker`, which takes any object as its process. */ diff --git a/src/runtime/node/node_cluster_binding.rs b/src/runtime/node/node_cluster_binding.rs index 7e828ee698c8..0fff06cb24f8 100644 --- a/src/runtime/node/node_cluster_binding.rs +++ b/src/runtime/node/node_cluster_binding.rs @@ -223,8 +223,8 @@ pub(crate) fn handle_internal_message_primary( // TODO: investigate if "ack" and "seq" are observable and if they're not, remove them entirely. if let Some(p) = message.get(global, "ack")? { - if !p.is_undefined() { - let ack = p.to_int32(); + if p.is_int32() { + let ack = p.as_int32(); let entry = ipc_data.internal_msg_queue.with_mut(|q| { let cb = q.callbacks.get(&ack).and_then(|s| s.get()); if q.callbacks.contains_key(&ack) { @@ -315,10 +315,9 @@ pub(crate) fn cluster_raw_bind(global: &JSGlobalObject, frame: &CallFrame) -> Js let port = arguments[2].to_int32(); let flags = arguments[3].to_int32(); - if address_type.is_string() || address_type.to_int32() == -1 { + if address_type.is_string() || (address_type.is_int32() && address_type.as_int32() == -1) { return Ok(JSValue::js_number_from_int32(-bun_sys::UV_E::NOTSUP)); } - let atype = address_type.to_int32(); let host_owned: Vec = if address.is_string() { let mut v = address.to_js_string_view(global)?.to_owned_slice(); @@ -332,7 +331,6 @@ pub(crate) fn cluster_raw_bind(global: &JSGlobalObject, frame: &CallFrame) -> Js } else { Some(b"0.0.0.0\0") }; - let _ = atype; let options: core::ffi::c_int = if flags & 1 != 0 { bun_uws::LIBUS_SOCKET_IPV6_ONLY @@ -414,8 +412,11 @@ pub(crate) fn cluster_raw_bind(global: &JSGlobalObject, frame: &CallFrame) -> Js } else { 4 }; + } else if address_type.is_int32() { + atype = address_type.as_int32(); } else { - atype = address_type.to_int32(); + // Node compares addressType with ===, so anything that isn't 6 or -1 means IPv4. + atype = 4; } fn last_neg_errno() -> JSValue { @@ -715,13 +716,24 @@ pub(crate) fn cluster_raw_bind(global: &JSGlobalObject, frame: &CallFrame) -> Js } } -pub(crate) fn cluster_validate_fd(fd: f64) -> i32 { +/// Returns 0 if `fd` is an unconnected socket of the wanted type, or a negative errno. +pub(crate) fn cluster_validate_fd(fd: f64, want_dgram: bool) -> i32 { #[cfg(not(windows))] { - let fd = fd as i32; + let number = fd; + let fd = number as i32; + // Reject a fractional fd like 3.5 instead of truncating it. + if f64::from(fd) != number { + return -bun_sys::UV_E::INVAL; + } if fd < 0 { return -bun_sys::UV_E::BADF; } + let wanted = if want_dgram { + libc::SOCK_DGRAM + } else { + libc::SOCK_STREAM + }; let mut ty: libc::c_int = 0; let mut len = core::mem::size_of::() as libc::socklen_t; // SAFETY: plain getsockopt on a caller-supplied fd; out-params are @@ -735,7 +747,7 @@ pub(crate) fn cluster_validate_fd(fd: f64) -> i32 { ) }; // node's createServerHandle: EINVAL for anything that cannot listen (e.g. a connected stdio socketpair), fd left untouched. - if rc != 0 || (ty != libc::SOCK_STREAM && ty != libc::SOCK_DGRAM) { + if rc != 0 || ty != wanted { return -bun_sys::UV_E::INVAL; } if ty == libc::SOCK_STREAM { @@ -754,7 +766,7 @@ pub(crate) fn cluster_validate_fd(fd: f64) -> i32 { } #[cfg(windows)] { - let _ = fd; + let _ = (fd, want_dgram); -bun_sys::UV_E::INVAL } } diff --git a/src/runtime/node/node_crypto_binding.rs b/src/runtime/node/node_crypto_binding.rs index 68502a6df84e..0f7e27e36810 100644 --- a/src/runtime/node/node_crypto_binding.rs +++ b/src/runtime/node/node_crypto_binding.rs @@ -13,7 +13,7 @@ use bun_jsc::{ JsThread, Protected, Strong, }; -use crate::node::{Flavor, StringObjects, StringOrBuffer, ThreadIsolated, ThreadIsolatedArg}; +use crate::node::{Flavor, StringOrBuffer, ThreadIsolated, ThreadIsolatedArg}; // `&JSGlobalObject` is ABI-identical to a non-null pointer; remaining params // are by-value `JSValue`, so no caller-side preconditions remain. @@ -821,11 +821,6 @@ mod _impl { ] = call_frame.arguments_as_array::<5>(); let mut maybe_options_value: Option = Some(options_arg); let mut callback = callback_arg; - let flavor = if IS_ASYNC { - Flavor::Async - } else { - Flavor::Sync - }; if IS_ASYNC { if callback.is_undefined() { @@ -834,13 +829,7 @@ mod _impl { } } - let Some(password) = StringOrBuffer::from_js_maybe_async( - global, - password_value, - flavor, - StringObjects::Allow, - )? - else { + let Some(password) = StringOrBuffer::from_js(global, password_value)? else { return Err(global.throw_invalid_argument_type_value( b"password", b"string, ArrayBuffer, Buffer, TypedArray, or DataView", @@ -848,13 +837,7 @@ mod _impl { )); }; - let Some(salt) = StringOrBuffer::from_js_maybe_async( - global, - salt_value, - flavor, - StringObjects::Allow, - )? - else { + let Some(salt) = StringOrBuffer::from_js(global, salt_value)? else { return Err(global.throw_invalid_argument_type_value( b"salt", b"string, ArrayBuffer, Buffer, TypedArray, or DataView", @@ -992,16 +975,18 @@ mod _impl { ctx.check_scrypt_params(global)?; - if IS_ASYNC { - return Ok((ctx, callback)); - } - + // An option getter can detach or resize the buffers, so read them again. for input in [&mut ctx.password, &mut ctx.salt] { - if let StringOrBuffer::Buffer(buffer) = input { + if IS_ASYNC { + input.make_thread_isolated_copy(global)?; + } else if let StringOrBuffer::Buffer(buffer) = input { buffer.buffer = ArrayBuffer::from_typed_array(global, buffer.buffer.value); } } + if IS_ASYNC { + return Ok((ctx, callback)); + } Ok((ctx, JSValue::UNDEFINED)) } @@ -1011,7 +996,7 @@ mod _impl { call_frame: &CallFrame, ) -> JsResult<(ThreadIsolated, JSValue)> { let (ctx, callback) = Self::from_js::(global, call_frame)?; - // SAFETY: parsed with the async flavor (`from_js::`). + // SAFETY: `from_js::` copied the buffers and thread-isolated the strings. Ok((unsafe { ThreadIsolated::new(ctx) }, callback)) } @@ -1370,11 +1355,8 @@ mod _impl { } }; - // The validators admit sizes rust-argon2 would abort on - // (`vec![Block::zero(); mem_cost]` and the output Vec allocate - // infallibly). Pre-fail the job instead, so both paths deliver - // the same catchable error node produces when OpenSSL's argon2 - // allocation fails. + // rust-argon2 ignores the synthetic allocation limit, so check it + // here. `hash_raw` reports real allocation failures. let limit = jsc::virtual_machine::synthetic_allocation_limit(); let failed = (memory as usize).saturating_mul(1024) > limit || tag_length as usize > limit; @@ -1415,8 +1397,8 @@ mod _impl { }; match rust_argon2::hash_raw(&self.message, &self.nonce, &config) { Ok(hash) => self.output = hash, - // Unreachable via `node:crypto`: `checkArgon2()` bounds are a - // superset of rust-argon2's constraints. + // `checkArgon2()` already rejected every parameter rust-argon2 + // rejects, so this is an allocation failure. Err(_) => self.failed = true, } } diff --git a/src/runtime/node/node_fs.rs b/src/runtime/node/node_fs.rs index a542eb64cf3a..579e2d086a41 100644 --- a/src/runtime/node/node_fs.rs +++ b/src/runtime/node/node_fs.rs @@ -470,6 +470,61 @@ pub(crate) const DEFAULT_PERMISSION: Mode = 0; // `&AbortSignal` inherent methods — the former `AbortSignalRefExt` shim with // per-call `unsafe { self.as_ref() }` is gone. `unref()` is handled by `Drop`. +/// Tracks subdirectories a recursive `cp` walk has seen but not yet entered. A walk +/// reuses one src/dest buffer pair for the whole tree, so each entry stores its +/// parent's lengths in those buffers plus the name to append. Entries pop in LIFO +/// order, so the parent's prefix is still in the buffers when an entry is popped. +#[derive(Default)] +struct CpPendingDirs { + names: Vec, + dirs: Vec, +} + +struct CpPendingDir { + parent_src_len: PathInt, + parent_dest_len: PathInt, + name_start: u32, +} + +impl CpPendingDirs { + /// The caller must check that both buffers have room for `name` after its parent. + fn push(&mut self, parent_src_len: PathInt, parent_dest_len: PathInt, name: &[OSPathChar]) { + self.dirs.push(CpPendingDir { + parent_src_len, + parent_dest_len, + name_start: self.names.len() as u32, + }); + self.names.extend_from_slice(name); + } + + /// Writes the next directory's NUL-terminated src/dest paths into the buffers + /// and their lengths into `src_len`/`dest_len`. Returns false if none are pending. + fn pop_into( + &mut self, + src_buf: &mut OSPathBuffer, + src_len: &mut PathInt, + dest_buf: &mut OSPathBuffer, + dest_len: &mut PathInt, + ) -> bool { + let Some(dir) = self.dirs.pop() else { + return false; + }; + let name = &self.names[dir.name_start as usize..]; + let sd = dir.parent_src_len as usize; + let dd = dir.parent_dest_len as usize; + src_buf[sd] = paths::SEP as OSPathChar; + src_buf[sd + 1..sd + 1 + name.len()].copy_from_slice(name); + src_buf[sd + 1 + name.len()] = 0; + dest_buf[dd] = paths::SEP as OSPathChar; + dest_buf[dd + 1..dd + 1 + name.len()].copy_from_slice(name); + dest_buf[dd + 1 + name.len()] = 0; + *src_len = (sd + 1 + name.len()) as PathInt; + *dest_len = (dd + 1 + name.len()) as PathInt; + self.names.truncate(dir.name_start as usize); + true + } +} + // ────────────────────────────────────────────────────────────────────────── // Async task type aliases // ────────────────────────────────────────────────────────────────────────── @@ -942,6 +997,9 @@ mod _async_tasks { // SAFETY: fn contract. The borrow ends before `destroy` below. let (result, completion, global_ref, tracker) = unsafe { let task = &mut *this; + if let Ok(result) = &task.result { + task.args.write_back(result.bytes_read()); + } ( core::mem::replace(&mut task.result, Err(sys::Error::default())), core::mem::replace( @@ -1009,6 +1067,8 @@ mod _async_tasks { fn signal(&self) -> Option<&AbortSignal> { None } + /// Runs on the JS thread before the result reaches JS. See [`PinnedArrayBuffer::write_back`]. + fn write_back(&mut self, _bytes_read: u64) {} } /// Forward [`FsArgument`] to the inherent `from_js` each `args::*` struct @@ -1052,7 +1112,6 @@ mod _async_tasks { args::FdVectorIo, args::FTruncate, args::Write<'static>, - args::Read, args::Fchown, args::FChmod, args::Fstat, @@ -1061,6 +1120,20 @@ mod _async_tasks { args::Fsync, args::Close, ); + // SAFETY: same as `impl_fs_argument!`. + unsafe impl ThreadIsolatedArg for args::Read {} + impl FsArgument for args::Read { + #[inline] + fn from_js(ctx: &JSGlobalObject, arguments: &mut ArgumentsSlice) -> JsResult { + args::Read::from_js(ctx, arguments) + } + #[inline] + fn write_back(&mut self, bytes_read: u64) { + if let args::ReadBuffer::PinnedBuffer(buffer) = &mut self.buffer { + buffer.write_back(bytes_read as usize); + } + } + } // `ReadFile`/`WriteFile` carry an `AbortSignal` field — opt them in so the // `const _ = assert!(…::HAVE_ABORT_SIGNAL)` invariants in `async_` hold and // `signal()` exposes it to `AsyncFSTask::run_from_js_thread`. @@ -1117,6 +1190,11 @@ mod _async_tasks { Self: Sized, { } + /// Returns how many bytes the job wrote into the caller's buffer. + #[inline] + fn bytes_read(&self) -> u64 { + 0 + } } impl FsReturn for JSValue { #[inline] @@ -1175,6 +1253,10 @@ mod _async_tasks { fn fs_to_js(self, global: &JSGlobalObject) -> JsResult { Ok(self.to_js(global)) } + #[inline] + fn bytes_read(&self) -> u64 { + self.bytes_read + } } impl FsReturn for ret::Write { #[inline] @@ -1384,6 +1466,9 @@ mod _async_tasks { let global_object = cx.global(); let _dispatch = js.tracker.dispatch(global_object); + if let Ok(result) = &this.result { + this.args.write_back(result.bytes_read()); + } let success = this.result.is_ok(); let completion = &js.completion; let converted = match core::mem::replace(&mut this.result, Err(sys::Error::default())) { @@ -2035,9 +2120,8 @@ mod _async_tasks { // are slices into `src_buf`/`dest_buf` and must end their borrow first. let src_len = PathInt::try_from(src.len()).expect("int cast"); let dest_len = PathInt::try_from(dest.len()).expect("int cast"); - let _ = Self::cp_async_directory( + if let Err(err) = Self::cp_async_directory( nodefs, - args.flags, // Pass the raw `*mut Self` (Box::leak provenance) so spawned // `CpSingleTask`s store a pointer that may later be promoted to // `&mut` in `on_subtask_done`. @@ -2046,19 +2130,49 @@ mod _async_tasks { src_len, &mut dest_buf, dest_len, - ); + ) { + this.finish_concurrently(Err(err)); + } } - // returns boolean `should_continue` + /// Walks the tree with a work list, because recursing once per level + /// overflows the pool thread's stack on a deep tree. fn cp_async_directory( nodefs: &mut NodeFS, - args: args::CpFlags, this: *mut Self, src_buf: &mut OSPathBuffer, - src_dir_len: PathInt, + mut src_dir_len: PathInt, dest_buf: &mut OSPathBuffer, + mut dest_dir_len: PathInt, + ) -> Maybe<()> { + let mut pending = CpPendingDirs::default(); + loop { + Self::cp_async_one_directory( + nodefs, + this, + src_buf, + src_dir_len, + dest_buf, + dest_dir_len, + &mut pending, + )?; + if !pending.pop_into(src_buf, &mut src_dir_len, dest_buf, &mut dest_dir_len) { + return Ok(()); + } + } + } + + /// Creates `dest`, dispatches a `CpSingleTask` for each non-directory entry + /// in `src`, and queues its subdirectories on `pending`. + fn cp_async_one_directory( + nodefs: &mut NodeFS, + this: *mut Self, + src_buf: &OSPathBuffer, + src_dir_len: PathInt, + dest_buf: &OSPathBuffer, dest_dir_len: PathInt, - ) -> bool { + pending: &mut CpPendingDirs, + ) -> Maybe<()> { // SAFETY: `this` is the live Box-leaked task. Shared borrow only — spawned // `CpSingleTask`s on other workpool threads may concurrently hold `&Self`. // The raw `*mut` is threaded through (instead of `&Self`) so that the @@ -2085,34 +2199,26 @@ mod _async_tasks { E::EACCES | E::ENAMETOOLONG | E::EROFS | E::EPERM | E::EINVAL => { // `errno_sys_p` // already boxed `src.as_bytes()` into `err.path`, so just forward. - this_ref.finish_concurrently(err); - return false; + return err; } // Other errors may be due to clonefile() not being supported // We'll fall back to other implementations _ => {} } } else { - return true; + return Ok(()); } } let open_flags = sys::O::DIRECTORY | sys::O::RDONLY | sys::O::NOFOLLOW; - let fd = match openat_os_path(FD::cwd(), src, open_flags, 0) { - Err(err) => { - this_ref.finish_concurrently(Err( - err.with_path(nodefs.os_path_into_sync_error_buf(src)) - )); - return false; - } - Ok(fd_) => fd_, - }; + let fd = openat_os_path(FD::cwd(), src, open_flags, 0) + .map_err(|err| err.with_path(nodefs.os_path_into_sync_error_buf(src)))?; let _close = scopeguard::guard(fd, |fd| fd.close()); #[cfg(windows)] let mut buf = bun_paths::os_path_buffer_pool::get(); #[cfg(windows)] - let normdest: &OSPathSliceZ = match sys::normalize_path_windows_opts( + let normdest: &OSPathSliceZ = sys::normalize_path_windows_opts( FD::INVALID, dest.as_slice(), &mut buf[..], @@ -2122,26 +2228,12 @@ mod _async_tasks { sys::NormalizePathWindowsOpts { add_nt_prefix: false, }, - ) { - Err(err) => { - this_ref.finish_concurrently(Err(err)); - return false; - } - Ok(n) => n, - }; + )?; #[cfg(not(windows))] let normdest: &OSPathSliceZ = dest; - let mkdir_ = nodefs.mkdir_recursive_os_path(normdest, args::Mkdir::DEFAULT_MODE, false); - match mkdir_ { - Err(err) => { - this_ref.finish_concurrently(Err(err)); - return false; - } - Ok(_) => { - this_ref.on_copy(src, normdest); - } - } + nodefs.mkdir_recursive_os_path(normdest, args::Mkdir::DEFAULT_MODE, false)?; + this_ref.on_copy(src, normdest); // On POSIX directory entries are always UTF-8, so monomorphise the // const-generic path type on `U8` and let the Windows branch (gated @@ -2150,19 +2242,13 @@ mod _async_tasks { let mut iterator = DirIterator::iterate::(fd); #[cfg(not(windows))] let mut iterator = DirIterator::iterate::(fd); - let mut entry = iterator.next(); loop { - let current = match entry { + let current = match iterator.next() { Err(err) => { - this_ref.finish_concurrently(Err( - err.with_path(nodefs.os_path_into_sync_error_buf(src)) - )); - return false; + return Err(err.with_path(nodefs.os_path_into_sync_error_buf(src))); } - Ok(ent) => match ent { - Some(e) => e, - None => break, - }, + Ok(Some(e)) => e, + Ok(None) => break, }; let cname = current.name.slice(); @@ -2172,40 +2258,19 @@ mod _async_tasks { if (src_dir_len as usize) + 1 + cname.len() >= src_buf.len() || (dest_dir_len as usize) + 1 + cname.len() >= dest_buf.len() { - this_ref.finish_concurrently(Err(sys::Error { + return Err(sys::Error { errno: E::ENAMETOOLONG as _, syscall: sys::Tag::copyfile, path: nodefs .os_path_into_sync_error_buf(&src_buf[..src_dir_len as usize]) .into(), ..Default::default() - })); - return false; + }); } match current.kind { crate::node::dirent::Kind::Directory => { - let sd = src_dir_len as usize; - let dd = dest_dir_len as usize; - src_buf[sd + 1..sd + 1 + cname.len()].copy_from_slice(cname); - src_buf[sd] = paths::SEP as OSPathChar; - src_buf[sd + 1 + cname.len()] = 0; - dest_buf[dd + 1..dd + 1 + cname.len()].copy_from_slice(cname); - dest_buf[dd] = paths::SEP as OSPathChar; - dest_buf[dd + 1 + cname.len()] = 0; - - let should_continue = Self::cp_async_directory( - nodefs, - args, - this, - src_buf, - (sd + 1 + cname.len()) as PathInt, - dest_buf, - (dd + 1 + cname.len()) as PathInt, - ); - if !should_continue { - return false; - } + pending.push(src_dir_len, dest_dir_len, cname); } _ => { this_ref.subtask_count.fetch_add(1, Ordering::Relaxed); @@ -2235,10 +2300,9 @@ mod _async_tasks { ); } } - entry = iterator.next(); } - true + Ok(()) } } @@ -3922,7 +3986,7 @@ pub(crate) mod args { let buffer_value = arguments.next_eat().ok_or_else(|| // theoretically impossible, argument has been passed already ctx.throw_invalid_arguments(format_args!("buffer is required")))?; - if buffer_value.as_array_buffer(ctx).is_none() { + if !buffer_value.js_type().is_array_buffer_like() { return Err(ctx.throw_invalid_argument_type_value2( b"buffer", b"an instance of Buffer, TypedArray, or DataView", @@ -3936,7 +4000,7 @@ pub(crate) mod args { // } else { // validateInteger(offset, 'offset', 0); // } - let offset: u64 = if offset_value.is_undefined_or_null() { + let mut offset: u64 = if offset_value.is_undefined_or_null() { 0 } else { u64::try_from(validators::validate_integer( @@ -3956,20 +4020,19 @@ pub(crate) mod args { 0.0 }; // `to_number` can run JS that detaches the buffer, so the view is read after it. - let buffer = buffer_value.as_array_buffer(ctx).ok_or_else(|| { - ctx.throw_invalid_argument_type_value2( - b"buffer", - b"an instance of Buffer, TypedArray, or DataView", - buffer_value, - ) - })?; - let buffer = if arguments.will_be_async { + let mut buffer = if arguments.will_be_async { ReadBuffer::PinnedBuffer( PinnedArrayBuffer::root(ctx, buffer_value) .ok_or_else(|| ctx.throw_out_of_memory())?, ) } else { - ReadBuffer::Buffer(buffer) + ReadBuffer::Buffer(buffer_value.as_array_buffer(ctx).ok_or_else(|| { + ctx.throw_invalid_argument_type_value2( + b"buffer", + b"an instance of Buffer, TypedArray, or DataView", + buffer_value, + ) + })?) }; // if (length === 0) { @@ -4093,6 +4156,12 @@ pub(crate) mod args { None }; + if let ReadBuffer::PinnedBuffer(pinned) = &mut buffer + && pinned.write_to_scratch_if_wasm(offset as usize, length as usize) + { + offset = 0; + } + Ok(Read { fd, buffer, @@ -8237,6 +8306,43 @@ impl NodeFS { }); } + // Recursing once per level overflows the stack on a deep tree. + let mut pending = CpPendingDirs::default(); + let (mut src_dir_len, mut dest_dir_len) = (src_dir_len, dest_dir_len); + loop { + self.cp_sync_one_directory( + src_buf, + src_dir_len, + dest_buf, + dest_dir_len, + args, + &mut pending, + )?; + if !pending.pop_into(src_buf, &mut src_dir_len, dest_buf, &mut dest_dir_len) { + return Ok(()); + } + } + } + + /// Creates `dest`, copies every non-directory entry in `src` into it, and + /// queues the subdirectories on `pending`. + fn cp_sync_one_directory( + &mut self, + src_buf: &mut OSPathBuffer, + src_dir_len: PathInt, + dest_buf: &mut OSPathBuffer, + dest_dir_len: PathInt, + args: &args::Cp, + pending: &mut CpPendingDirs, + ) -> Maybe { + let cp_flags = &args.flags; + let sd = src_dir_len as usize; + let dd = dest_dir_len as usize; + // SAFETY: the caller NUL-terminated both buffers at `sd`/`dd`. + let src = unsafe { OSPathSliceZ::from_raw(src_buf.as_ptr(), sd) }; + // SAFETY: see `src` above. + let dest = unsafe { OSPathSliceZ::from_raw(dest_buf.as_ptr(), dd) }; + #[cfg(target_os = "macos")] 'try_with_clonefile: { // CLONE_NOFOLLOW: `src` was classified as a directory via lstat, so @@ -8311,26 +8417,19 @@ impl NodeFS { }); } - src_buf[sd + 1..sd + 1 + name_slice.len()].copy_from_slice(name_slice); - src_buf[sd] = paths::SEP as OSPathChar; - src_buf[sd + 1 + name_slice.len()] = 0; - - dest_buf[dd + 1..dd + 1 + name_slice.len()].copy_from_slice(name_slice); - dest_buf[dd] = paths::SEP as OSPathChar; - dest_buf[dd + 1 + name_slice.len()] = 0; - match current.kind { sys::FileKind::Directory => { - let r = self.cp_sync_inner( - src_buf, - (sd + 1 + name_slice.len()) as PathInt, - dest_buf, - (dd + 1 + name_slice.len()) as PathInt, - args, - ); - r?; + pending.push(src_dir_len, dest_dir_len, name_slice); } _ => { + src_buf[sd + 1..sd + 1 + name_slice.len()].copy_from_slice(name_slice); + src_buf[sd] = paths::SEP as OSPathChar; + src_buf[sd + 1 + name_slice.len()] = 0; + + dest_buf[dd + 1..dd + 1 + name_slice.len()].copy_from_slice(name_slice); + dest_buf[dd] = paths::SEP as OSPathChar; + dest_buf[dd + 1 + name_slice.len()] = 0; + // NUL written at [len] above; `from_buf` debug-asserts it. let src_z = OSPathSliceZ::from_buf(&src_buf[..], sd + 1 + name_slice.len()); let dest_z = OSPathSliceZ::from_buf(&dest_buf[..], dd + 1 + name_slice.len()); diff --git a/src/runtime/node/node_fs_stat_watcher.rs b/src/runtime/node/node_fs_stat_watcher.rs index 53bb04a332a6..c7d0a5d43e8d 100644 --- a/src/runtime/node/node_fs_stat_watcher.rs +++ b/src/runtime/node/node_fs_stat_watcher.rs @@ -816,7 +816,7 @@ impl StatWatcher { // SAFETY: `FileSystem::instance()` is initialized at process start // (`FileSystem::init` runs before any JS module loads). - let top_level_dir = fs::FileSystem::get().top_level_dir; + let top_level_dir = fs::FileSystem::get().top_level_dir(); let parts: [&[u8]; 1] = [slice]; let file_path = Path::join_abs_string_buf::(top_level_dir, &mut buf[..], &parts); diff --git a/src/runtime/node/node_fs_watcher.rs b/src/runtime/node/node_fs_watcher.rs index dfc1ea04c13c..1db3aa60372a 100644 --- a/src/runtime/node/node_fs_watcher.rs +++ b/src/runtime/node/node_fs_watcher.rs @@ -1142,7 +1142,7 @@ impl FSWatcher { }; // SAFETY: `FileSystem::instance()` returns the process-global singleton // initialized at startup; never null once init has run. - let cwd = bun_resolver::fs::FileSystem::get().top_level_dir; + let cwd = bun_resolver::fs::FileSystem::get().top_level_dir(); let joined_buf_len = joined_buf.len(); let Some(joined) = Path::join_abs_string_buf_checked::( cwd, diff --git a/src/runtime/node/node_os.rs b/src/runtime/node/node_os.rs index 77622d2b9215..c34d8443ffa7 100644 --- a/src/runtime/node/node_os.rs +++ b/src/runtime/node/node_os.rs @@ -1013,7 +1013,9 @@ mod _impl { } // Does this entry already exist? - if let Some(array) = ret.get(global_this, interface_name)? { + // The OS picks the interface name, so it can be an array index like "0". + let key = bun_core::String::from_bytes(interface_name); + if let Some(array) = ret.get_own(global_this, &key)? { // Add this interface entry to the existing array let next_index: u32 = u32::try_from(array.get_length(global_this)?).expect("int cast"); @@ -1022,7 +1024,7 @@ mod _impl { // Add it as an array with this interface as an element let array = JSValue::create_empty_array(global_this, 1)?; array.put_index(global_this, 0, interface)?; - ret.put(global_this, interface_name, array); + ret.put_may_be_index(global_this, &key, array)?; } it = next; @@ -1194,7 +1196,9 @@ mod _impl { // Does this entry already exist? // SAFETY: iface.name is a NUL-terminated C string from libuv let interface_name = unsafe { bun_core::ffi::cstr(iface.name) }.to_bytes(); - if let Some(array) = ret.get(global_this, interface_name)? { + // The OS picks the interface name, so it can be an array index like "0". + let key = bun_core::String::from_bytes(interface_name); + if let Some(array) = ret.get_own(global_this, &key)? { // Add this interface entry to the existing array let next_index: u32 = u32::try_from(array.get_length(global_this)?).expect("int cast"); @@ -1203,7 +1207,7 @@ mod _impl { // Add it as an array with this interface as an element let array = JSValue::create_empty_array(global_this, 1)?; array.put_index(global_this, 0, interface)?; - ret.put(global_this, interface_name, array); + ret.put_may_be_index(global_this, &key, array)?; } } diff --git a/src/runtime/node/node_process.rs b/src/runtime/node/node_process.rs index 492ac7536ef3..219c4afbf37d 100644 --- a/src/runtime/node/node_process.rs +++ b/src/runtime/node/node_process.rs @@ -522,7 +522,7 @@ mod _impl { // path=cwd, dest=target so the // resulting Node SystemError carries `path: cwd`, `dest: target` and the // `chdir '' -> ''` message format (test-process-chdir-errormessage). - let prev_cwd: Box<[u8]> = Box::from(fs.top_level_dir); + let prev_cwd: Box<[u8]> = Box::from(fs.top_level_dir()); match vm.set_process_cwd(slice) { bun_sys::Result::Ok(()) => { vm.test_isolation_scope(|state| { @@ -536,7 +536,7 @@ mod _impl { let without_trailing_slash = strings::without_trailing_slash; bun_string_jsc::create_utf8_for_js( global_object, - without_trailing_slash(fs.top_level_dir), + without_trailing_slash(fs.top_level_dir()), ) } bun_sys::Result::Err(e) => { @@ -562,7 +562,7 @@ mod _impl { let mut buf1: Vec = vec![0u16; k.utf16_byte_length() + 1]; let mut buf2: Vec = vec![0u16; v.utf16_byte_length() + 1]; let len1: usize = if k.is_8bit() { - strings::copy_latin1_into_utf16(&mut buf1, k.latin1()).written as usize + strings::copy_latin1_into_utf16(&mut buf1, k.latin1()).written } else { buf1[0..k.length()].copy_from_slice(k.utf16()); k.length() @@ -576,7 +576,7 @@ mod _impl { break 'str_ EMPTY_W.as_ptr(); } let len2: usize = if v.is_8bit() { - strings::copy_latin1_into_utf16(&mut buf2, v.latin1()).written as usize + strings::copy_latin1_into_utf16(&mut buf2, v.latin1()).written } else { buf2[0..v.length()].copy_from_slice(v.utf16()); v.length() diff --git a/src/runtime/node/node_zlib_binding.rs b/src/runtime/node/node_zlib_binding.rs index 595408c6effa..0e716f685dad 100644 --- a/src/runtime/node/node_zlib_binding.rs +++ b/src/runtime/node/node_zlib_binding.rs @@ -120,6 +120,15 @@ pub(crate) trait CompressionContext { // R-2 (host-fn re-entrancy): every JS-exposed mixin method takes `&T`; per-field // interior mutability via `Cell` (Copy) / `JsCell` (non-Copy). Accessors return the // cell wrapper so the mixin can `.get()`/`.set()`/`.with_mut()` as needed. +/// Stores a stream's copy of an input that `can_be_freed_while_pinned`. +/// Empty when the stream has no copy. +#[derive(Default)] +pub(crate) struct InputCopy { + bytes: Vec, + /// The copied view's pointer and length. Only compared, never dereferenced. + from: (*const u8, usize), +} + pub(crate) trait CompressionStreamImpl: Sized + Taskable + bun_ptr::CellRefCounted + bun_ptr::AnyRefCounted + 'static { @@ -164,6 +173,8 @@ pub(crate) trait CompressionStreamImpl: fn task(&self) -> &JsCell; fn write_in_progress(&self) -> &Cell; fn pinned_buffers(&self) -> &Cell; + /// Returns the copy that [`CompressionStream::borrow_input_copy`] fills and reuses. + fn input_copy(&self) -> &JsCell; fn pending_close(&self) -> &Cell; fn closed(&self) -> &Cell; @@ -317,12 +328,13 @@ impl CompressionStream { ) .throw()); } - if out_buf.resizable && !out_buf.shared { + // The pool thread writes to `out` after this call returns. + if out_buf.can_be_freed_while_pinned() { return Err(global_this .err( ErrorCode::INVALID_ARG_VALUE, format_args!( - "The \"out\" argument must not be backed by a resizable ArrayBuffer" + "The \"out\" argument must not be backed by a resizable ArrayBuffer or a WebAssembly.Memory" ), ) .throw()); @@ -341,15 +353,31 @@ impl CompressionStream { }; Some(buf) }; - let in_: Option<&[u8]> = in_buf - .as_ref() - .map(|b| &b.byte_slice()[in_off as usize..in_off as usize + in_len as usize]); let Some(mut out_buf) = arguments[4].as_pinned_arraybuffer(global_this) else { if let Some(buf) = &in_buf { buf.unpin(); } return Err(global_this.throw_out_of_memory()); }; + let mut in_: Option<&[u8]> = in_buf + .as_ref() + .map(|b| &b.byte_slice()[in_off as usize..in_off as usize + in_len as usize]); + // The pool thread reads the input after this call returns, so give it + // a copy if the pin can't keep the bytes mapped. + if let Some(buf) = in_buf.as_ref().filter(|b| b.can_be_freed_while_pinned()) { + let Some(copied) = Self::borrow_input_copy( + this, + global_this, + buf.byte_slice(), + in_off as usize, + in_len as usize, + ) else { + buf.unpin(); + out_buf.unpin(); + return Err(global_this.throw_out_of_memory()); + }; + in_ = Some(copied); + } this.pinned_buffers().set( u8::from(in_buf.as_ref().is_some_and(|b| b.pinned)) | (u8::from(out_buf.pinned) << 1), ); @@ -420,6 +448,37 @@ impl CompressionStream { ticket.post(ConcurrentTask::create(Task::init(this))); } + /// Returns `view[in_off..in_off + in_len]` from the stream's copy of `view`, or + /// `None` if the copy can't be allocated. + /// + /// `processCallback` resends the same view with a larger `in_off` for each output + /// chunk, so the whole view is copied once and reused until a different view arrives. + fn borrow_input_copy<'a>( + this: &'a T, + global: &JSGlobalObject, + view: &[u8], + in_off: usize, + in_len: usize, + ) -> Option<&'a [u8]> { + this.input_copy().with_mut(|copy| { + if copy.from != (view.as_ptr(), view.len()) { + copy.from = (core::ptr::null(), 0); + copy.bytes.clear(); + if copy.bytes.try_reserve_exact(view.len()).is_err() { + return None; + } + copy.bytes.extend_from_slice(view); + copy.from = (view.as_ptr(), view.len()); + global.vm().report_extra_memory(copy.bytes.len()); + } + let chunk = copy.bytes.get(in_off..in_off + in_len)?; + // SAFETY: `write()`, `reset()` and `close()` throw or are deferred while a write + // is in progress, and the completion frees the copy only after the input is + // consumed, so the bytes outlive the job that reads them. + Some(unsafe { core::slice::from_raw_parts(chunk.as_ptr(), chunk.len()) }) + }) + } + /// Releases the pins `write()` took; the cached slots keep rooting the values either way. fn unpin_pending_buffers(this: &T, this_value: JSValue) { let pinned = this.pinned_buffers().replace(0); @@ -503,6 +562,14 @@ impl CompressionStream { this.flush_write_result(global, this_value); this_value.ensure_still_alive(); + // The copy is only read again if some input is unconsumed, because JS then resends it. + let (mut avail_in, mut avail_out) = (0, 0); + this.stream() + .with_mut(|s| s.update_write_result(&mut avail_in, &mut avail_out)); + if avail_in == 0 { + this.input_copy().set(InputCopy::default()); + } + // `init()` caches the JS write callback; a handle whose `init()` was // never called has none, so there is nothing to notify. if let Some(write_callback) = T::write_callback_get_cached(this_value) { @@ -677,6 +744,8 @@ impl CompressionStream { return; } let err = this.stream().with_mut(|s| s.reset()); + // Free the copy before `emit_error`. Its callback can call `write()`, which makes a copy the pool thread reads. + this.input_copy().set(InputCopy::default()); if err.is_error() { Self::emit_error(this, global_this, this_value, err); } @@ -700,6 +769,7 @@ impl CompressionStream { this.closed().set(true); this.this_value().with_mut(|v| v.deinit()); this.stream().with_mut(|s| s.close()); + this.input_copy().set(InputCopy::default()); } pub(crate) fn set_on_error( @@ -952,6 +1022,7 @@ macro_rules! __impl_compression_stream { #[inline] fn task(&self) -> &::bun_jsc::JsCell<::bun_jsc::WorkPoolTask> { &self.task } #[inline] fn write_in_progress(&self) -> &::core::cell::Cell { &self.write_in_progress } #[inline] fn pinned_buffers(&self) -> &::core::cell::Cell { &self.pinned_buffers } + #[inline] fn input_copy(&self) -> &::bun_jsc::JsCell<$crate::node::node_zlib_binding::InputCopy> { &self.input_copy } #[inline] fn pending_close(&self) -> &::core::cell::Cell { &self.pending_close } #[inline] fn closed(&self) -> &::core::cell::Cell { &self.closed } diff --git a/src/runtime/node/path.rs b/src/runtime/node/path.rs index 773001b1b94b..a6dbd4dd400c 100644 --- a/src/runtime/node/path.rs +++ b/src/runtime/node/path.rs @@ -463,13 +463,23 @@ unsafe extern "C" fn Bun__Node__Path_joinWTF( ) -> bun_core::String { // SAFETY: caller passes a valid slice from C++. let rhs = unsafe { bun_core::ffi::slice(rhs_ptr, rhs_len) }; - let mut buf = [0u8; path_size::()]; - let mut buf2 = [0u8; path_size::()]; let lhs = lhs.to_utf8(); + let paths: [&[u8]; 2] = [lhs.slice(), rhs]; + let buf_len = join_buf_len::(cfg!(windows), &paths); + let mut fixed = [0u8; path_size::() * 2]; + let mut spill = Vec::new(); + // Fall back to the heap when the joined path is too long for the stack buffer. + let scratch: &mut [u8] = if buf_len * 2 <= fixed.len() { + &mut fixed + } else { + spill.resize(buf_len * 2, 0); + &mut spill + }; + let (buf, buf2) = scratch.split_at_mut(buf_len); #[cfg(windows)] - let joined = join_windows_t::(&[lhs.slice(), rhs], &mut buf, &mut buf2); + let joined = join_windows_t::(&paths, buf, buf2); #[cfg(not(windows))] - let joined = join_posix_t::(&[lhs.slice(), rhs], &mut buf, &mut buf2); + let joined = join_posix_t::(&paths, buf, buf2); bun_core::String::clone_utf8(joined) } diff --git a/src/runtime/node/path_watcher.rs b/src/runtime/node/path_watcher.rs index 41bfd4ac8a5d..cc22c8a8717b 100644 --- a/src/runtime/node/path_watcher.rs +++ b/src/runtime/node/path_watcher.rs @@ -33,15 +33,15 @@ use core::sync::atomic::{AtomicBool, Ordering}; use bun_collections::HashMap; use bun_collections::{ArrayHashMap, StringArrayHashMap}; use bun_core::ZBox; -#[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] use bun_core::strings; #[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] use bun_core::{Output, zstr}; use bun_core::{ZStr, handle_oom}; use bun_paths as path; -#[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] +#[cfg(any(target_os = "linux", target_os = "android"))] use bun_paths::platform; -#[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] +#[cfg(any(target_os = "linux", target_os = "android"))] use bun_paths::resolve_path::join_z_buf_spill; #[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] use bun_sys::FdExt; @@ -565,11 +565,107 @@ pub(crate) fn watch( // Platform backends // ──────────────────────────────────────────────────────────────────────────────── -/// Shared recursive directory walk for Linux and Kqueue: open `abs_dir`, iterate, -/// and for every entry call `cb` with (abs, rel, is_file); recurse into -/// subdirectories. When `dirs_only`, non-directory entries are skipped entirely -/// (inotify delivers file events on the parent dir's wd so we only need a watch -/// per directory; kqueue needs an fd per file too). Best-effort — an unreadable +/// Scratch buffers that `walk_subtree` reuses across directories. +#[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] +#[derive(Default)] +struct WalkScratch { + /// NUL-terminated absolute path of the directory being read, or of the entry passed to `cb`. + abs: Vec, + /// Same path, relative to the watch root. + rel: Vec, + /// Unvisited entries as `[is_file][name][0]` records, innermost directory last. + pending: Vec, + /// Directories with records in `pending`, outermost first. + levels: Vec, +} + +#[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] +struct WalkLevel { + /// Length of the directory's path in `abs` (without the NUL) and in `rel`. + abs_len: usize, + rel_len: usize, + /// Offset of the directory's first record in `pending`. + start: usize, + /// Offset of the first unvisited record. + next: usize, +} + +#[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] +impl WalkScratch { + /// Truncates `abs` and `rel` to the directory's path, then appends `name`. + fn set_entry( + abs: &mut Vec, + rel: &mut Vec, + abs_len: usize, + rel_len: usize, + name: &[u8], + ) { + abs.truncate(abs_len); + // Only the root directory ends with a separator. + if abs.last() != Some(&path::SEP) { + abs.push(path::SEP); + } + abs.extend_from_slice(name); + abs.push(0); + rel.truncate(rel_len); + if rel_len != 0 { + rel.push(path::SEP); + } + rel.extend_from_slice(name); + } + + /// Reads every entry of the directory at `self.abs` and closes it before the walk descends. + #[inline(never)] + fn read_dir(&mut self, cb: &mut impl FnMut(&ZStr, &[u8], bool)) { + let abs_len = self.abs.len() - 1; + let rel_len = self.rel.len(); + let start = self.pending.len(); + let Ok(dir) = sys::open( + ZStr::from_slice_with_nul(&self.abs), + sys::O::RDONLY | sys::O::DIRECTORY | sys::O::CLOEXEC, + 0, + ) else { + return; + }; + let _close = sys::CloseOnDrop::new(dir); + let mut it = sys::dir_iterator::iterate(dir); + let mut found_dir = false; + while let Ok(Some(entry)) = it.next() { + let is_file = entry.kind != sys::EntryKind::Directory; + if DIRS_ONLY && is_file { + continue; + } + // The iterator caches the UTF-8 transcode and exposes it as `slice_u8()`. + let name = entry.name.slice_u8(); + if is_file && !found_dir { + // No subdirectory precedes this file, so it's next in walk order. + Self::set_entry(&mut self.abs, &mut self.rel, abs_len, rel_len, name); + cb(ZStr::from_slice_with_nul(&self.abs), &self.rel, true); + continue; + } + // Copy the name because this entry is visited after the directory is closed. + found_dir = true; + self.pending.push(u8::from(is_file)); + self.pending.extend_from_slice(name); + self.pending.push(0); + } + if self.pending.len() > start { + self.levels.push(WalkLevel { + abs_len, + rel_len, + start, + next: start, + }); + } + } +} + +/// Shared directory walk for Linux and Kqueue. Calls `cb` with (abs, rel, is_file) for +/// every entry below `abs_dir`, visiting a directory before its contents. When `DIRS_ONLY`, +/// non-directory entries are skipped entirely (inotify delivers file events on the +/// parent dir's wd so we only need a watch per directory; kqueue needs an fd per file +/// too). The walk is iterative and keeps one directory open at a time, so neither the +/// stack nor the open-file limit caps the tree's depth. Best-effort — an unreadable /// subdirectory just stops that branch (matches Node). #[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] fn walk_subtree( @@ -577,50 +673,38 @@ fn walk_subtree( rel_dir: &[u8], cb: &mut impl FnMut(&ZStr, &[u8], bool), ) { - let dfd = match sys::open( - abs_dir, - sys::O::RDONLY | sys::O::DIRECTORY | sys::O::CLOEXEC, - 0, - ) { - Err(_) => return, - Ok(f) => f, + let mut scratch = WalkScratch { + abs: abs_dir.as_bytes_with_nul().to_vec(), + rel: rel_dir.to_vec(), + ..Default::default() }; - let _close = sys::CloseOnDrop::new(dfd); - let mut it = sys::dir_iterator::iterate(dfd); - let mut abs_buf = path::path_buffer_pool::get(); - let mut abs_spill: Vec = Vec::new(); - let mut rel_buf = path::path_buffer_pool::get(); - let mut rel_spill: Vec = Vec::new(); - loop { - let entry = match it.next() { - Err(_) => return, - Ok(None) => return, - Ok(Some(e)) => e, - }; - let child_is_file = entry.kind != sys::EntryKind::Directory; - if DIRS_ONLY && child_is_file { + scratch.read_dir::(cb); + while let Some(level) = scratch.levels.last_mut() { + if level.next == scratch.pending.len() { + scratch.pending.truncate(level.start); + scratch.levels.pop(); continue; } - // The iterator caches the UTF-8 transcode and exposes it as `slice_u8()`. - let name = entry.name.slice_u8(); - let child_abs = join_z_buf_spill::( - abs_buf.as_mut_slice(), - &mut abs_spill, - &[abs_dir.as_bytes(), name], + let record = &scratch.pending[level.next..]; + let is_file = record[0] != 0; + let name = &record[1..]; + let name = &name[..strings::index_of_char_usize(name, 0).unwrap_or(name.len())]; + level.next += 1 + name.len() + 1; + WalkScratch::set_entry( + &mut scratch.abs, + &mut scratch.rel, + level.abs_len, + level.rel_len, + name, ); - let child_rel: &[u8] = if rel_dir.is_empty() { - name - } else { - join_z_buf_spill::( - rel_buf.as_mut_slice(), - &mut rel_spill, - &[rel_dir, name], - ) - .as_bytes() - }; - cb(child_abs, child_rel, child_is_file); - if !child_is_file { - walk_subtree::(child_abs, child_rel, cb); + cb( + ZStr::from_slice_with_nul(&scratch.abs), + &scratch.rel, + is_file, + ); + if !is_file { + // `cb` already watches this directory, so entries created during the read still get events. + scratch.read_dir::(cb); } } } diff --git a/src/runtime/node/quic/endpoint.rs b/src/runtime/node/quic/endpoint.rs index 9a9577fb313a..c4b571d3d57d 100644 --- a/src/runtime/node/quic/endpoint.rs +++ b/src/runtime/node/quic/endpoint.rs @@ -1730,6 +1730,7 @@ impl QuicEndpoint { endpoint_handle, null_mut(), true, + self.application(true), )?; let applied = self.apply_server_session_options(global, session); self.sessions.with_mut(|v| v.push(session)); @@ -1809,6 +1810,12 @@ impl QuicEndpoint { } // SAFETY: as in `on_data`. let global = unsafe { &*global_ptr }; + // lsquic promotes a server conn only after its handshake completes, and never + // calls `on_hsk_done` for it. + // SAFETY: `conn` is the live conn lsquic just created. + let Some(handshaken) = (unsafe { lsquic::Conn::from_raw(conn) }) else { + return null_mut(); + }; let endpoint_handle = self.this_value.get().get(); let peer = conn_peer_addr(conn); let provisional = self.provisional.with_mut(|v| { @@ -1820,7 +1827,7 @@ impl QuicEndpoint { if let Some(session) = provisional { if let Some(live) = self.live_session(session) { live.bind_conn(conn); - live.push_event(session::SessionEvent::HandshakeDone { ok: true }); + live.note_handshake_ok(handshaken); return session; } } @@ -1884,6 +1891,7 @@ impl QuicEndpoint { endpoint_handle, conn, true, + self.application(true), ) { Ok((session, _handle)) => { if let Err(err) = self.apply_server_session_options(global, session) { @@ -1893,7 +1901,7 @@ impl QuicEndpoint { self.pending_new_sessions.with_mut(|v| v.push(session)); self.add_stat(IDX_STATS_SERVER_SESSIONS, 1); // SAFETY: session was just created. - unsafe { (*session).push_event(session::SessionEvent::HandshakeDone { ok: true }) }; + unsafe { (*session).note_handshake_ok(handshaken) }; session } Err(e) => { @@ -1931,6 +1939,14 @@ impl QuicEndpoint { } } + fn application(&self, is_server: bool) -> session::Application { + if self.is_http(is_server) { + session::Application::Http3 + } else { + session::Application::Default + } + } + pub(super) fn drive_engines_once(&self) { if self.processing.replace(true) { return; @@ -2353,6 +2369,7 @@ impl QuicEndpoint { frame.this(), null_mut(), false, + self.application(false), )?; // `TlsConfig::from_js` defaults servername to "localhost\0" (Node parity). let sni = config.servername.as_ref(); @@ -2443,6 +2460,7 @@ impl QuicEndpoint { this_value, null_mut(), false, + session::Application::None, )?; let mut dcid = [0u8; VERNEG_PROBE_CID_LEN]; let mut scid = [0u8; VERNEG_PROBE_CID_LEN]; @@ -2646,6 +2664,26 @@ impl QuicEndpoint { Ok(JSValue::UNDEFINED) } + /// Swaps in close callbacks that are safe to run during VM teardown. + /// + /// Teardown finalizes endpoints, sessions and streams in any order, so + /// `lsquic_engine_destroy` can call back for a session or stream that's already freed. + /// `finalize` nulls a freed object's lsquic ctx, and for a live one these callbacks + /// don't dereference session or endpoint pointers. + fn detach_for_finalize(&self) { + self.sessions.with_mut(Vec::clear); + self.pending_new_sessions.with_mut(Vec::clear); + self.provisional.with_mut(Vec::clear); + self.pending_verneg.with_mut(Vec::clear); + self.vtable.with_mut(|vt| { + if let Some(vt) = vt.as_mut() { + vt.owner = null_mut(); + vt.on_stream_close = stream::QuicStream::on_close_detached; + vt.on_conn_closed = QuicSession::on_closed_detached; + } + }); + } + #[expect( clippy::boxed_local, reason = "codegen's host_fn_finalize calls this as `|b| QuicEndpoint::finalize(b)` and requires `self: Box`" @@ -2656,6 +2694,7 @@ impl QuicEndpoint { if self.event_loop_timer.get().state == EventLoopTimerState::ACTIVE { timer_all().remove(self.event_loop_timer.as_ptr()); } + self.detach_for_finalize(); self.release_native(); } } diff --git a/src/runtime/node/quic/session.rs b/src/runtime/node/quic/session.rs index 0ef2b1a94882..6ded8db04a58 100644 --- a/src/runtime/node/quic/session.rs +++ b/src/runtime/node/quic/session.rs @@ -52,6 +52,21 @@ const STREAM_ID_UNI_BIT: u64 = 0x2; const H3_NO_ERROR: u64 = 0x100; const H3_INTERNAL_ERROR: u64 = 0x102; +/// Mirrors node's `Session::Application::Type` (node/src/quic/application.h). +#[derive(Clone, Copy)] +#[repr(u8)] +pub(super) enum Application { + /// Used by version-negotiation probes, which have no engine and never handshake. + None = 0, + /// Raw QUIC (node's `DefaultApplication`). + Default = 1, + Http3 = 2, +} + +/// Mirrors node's `HeadersSupportState`. 0 means no application. +const HEADERS_SUPPORTED: u8 = 1; +const HEADERS_UNSUPPORTED: u8 = 2; + /// Node's DefaultApplication normalized option defaults /// (node/src/quic/session.cc). const DEFAULT_MAX_HEADER_PAIRS: u64 = 128; @@ -243,6 +258,7 @@ pub(crate) const SESSION_STATS_FIELDS: &[&str] = &[ "PKT_DISCARDED", ]; +/// Copied in the lsquic callback because the conn can be freed before the handshake event is dispatched. pub(super) struct HskSnapshot { sni: Option>, cipher: Option>, @@ -254,6 +270,8 @@ pub(super) struct HskSnapshot { ephemeral: Option<(&'static str, Option<&'static str>, u32)>, /// `(early_data_attempted, early_data_accepted)` (RFC 8446 §2.3). early_data: (bool, bool), + /// Backs `session.remoteTransportParams` and `state.max_datagram_size`. + peer_transport_params: Option, } pub(super) struct DeferredAbort { @@ -277,31 +295,31 @@ pub(super) enum SessionEvent { }, Closed, StreamReady { - stream: *mut super::stream::QuicStream, + stream: super::stream::Key, remote: bool, }, StreamWake { - stream: *mut super::stream::QuicStream, + stream: super::stream::Key, }, StreamDrain { - stream: *mut super::stream::QuicStream, + stream: super::stream::Key, }, StreamBlocked { - stream: *mut super::stream::QuicStream, + stream: super::stream::Key, }, StreamReset { - stream: *mut super::stream::QuicStream, + stream: super::stream::Key, code: u64, }, StreamStopSending { - stream: *mut super::stream::QuicStream, + stream: super::stream::Key, code: u64, }, StreamWantsTrailers { - stream: *mut super::stream::QuicStream, + stream: super::stream::Key, }, StreamHeaders { - stream: *mut super::stream::QuicStream, + stream: super::stream::Key, pairs: Vec>, kind: u32, }, @@ -309,7 +327,7 @@ pub(super) enum SessionEvent { Keylog(Vec), SessionResume(Vec), StreamClosed { - stream: *mut super::stream::QuicStream, + stream: super::stream::Key, }, HandshakeConfirmed, GoawayReceived, @@ -382,9 +400,10 @@ pub(crate) struct QuicSession { pending_tickets: JsCell)>>, close_after_streams: Cell, final_conn_status: JsCell)>>, - pending_local_bidi: JsCell>, - pending_local_uni: JsCell>, - streams: JsCell>, + pending_local_bidi: JsCell>, + pending_local_uni: JsCell>, + streams: JsCell>, + next_stream_key: Cell, handshake_reported: Cell, new_token_reported: Cell, close_when_bound: Cell, @@ -433,6 +452,7 @@ impl QuicSession { pending_local_bidi: JsCell::new(VecDeque::new()), pending_local_uni: JsCell::new(VecDeque::new()), streams: JsCell::new(Vec::new()), + next_stream_key: Cell::new(0), handshake_reported: Cell::new(false), new_token_reported: Cell::new(false), close_when_bound: Cell::new(false), @@ -456,6 +476,7 @@ impl QuicSession { endpoint_handle: JSValue, conn: *mut lsquic::lsquic_conn, is_server: bool, + application: Application, ) -> JsResult<(*mut QuicSession, JSValue)> { let raw = bun_core::heap::into_raw(Box::new(Self::new(global, vtable))); let handle = crate::generated_classes::js_QuicSession::to_js(raw, global); @@ -482,11 +503,22 @@ impl QuicSession { reason = "`state_ptr` is the base of a fresh JSC ArrayBuffer (byteOffset 0); JSC allocates its backing store through Gigacage/fastMalloc, which is at least 16-byte aligned" )] this.state.set(state_ptr.cast::()); + // This is the only write to the application fields, like node's `Session::SetApplication`. this.with_state(|s| { s.no_error_code = 0; s.internal_error_code = 1; s.stream_open_allowed = 1; - s.headers_supported = 0; + s.application_type = application as u8; + match application { + Application::None => {} + Application::Default => s.headers_supported = HEADERS_UNSUPPORTED, + Application::Http3 => { + s.headers_supported = HEADERS_SUPPORTED; + s.priority_supported = 1; + s.no_error_code = H3_NO_ERROR; + s.internal_error_code = H3_INTERNAL_ERROR; + } + } }); #[expect( clippy::cast_ptr_alignment, @@ -617,6 +649,11 @@ impl QuicSession { pub(super) fn push_event(&self, event: SessionEvent) { self.events.with_mut(|e| e.push(event)); } + /// Called from the lsquic callback that completes the handshake, while `conn` is still alive. + pub(super) fn note_handshake_ok(&self, conn: lsquic::Conn) { + self.capture_hsk_snapshot(conn); + self.push_event(SessionEvent::HandshakeDone { ok: true }); + } /// The `endpoint_js` Strong keeps it alive while this session holds the /// back-pointer; teardown nulls the pointer before that Strong is /// dropped, so a non-null pointer is always dereferenceable on the JS @@ -698,30 +735,37 @@ impl QuicSession { } else { &self.pending_local_bidi }; - // Skip the tombstones `remove_stream` leaves: stopping at the first + // Skip streams that `remove_stream` removed. Stopping at the first // one would strand the live request behind it until the next // MAX_STREAMS grant. - while let Some(p) = queue.with_mut(VecDeque::pop_front) { - if !p.is_null() && self.streams.get().contains(&p) { - return Some(p); + while let Some(key) = queue.with_mut(VecDeque::pop_front) { + if let Some(stream) = self.stream_ptr(key) { + return Some(stream); } } None } - pub(super) fn remove_stream(&self, stream: *mut super::stream::QuicStream) { - self.streams.with_mut(|v| v.retain(|&s| s != stream)); - // Null the slot rather than drop it: these are positional FIFOs - // lsquic fulfils in order, and a recycled allocation at the same - // address would otherwise bind to the wrong wrapper. - for queue in [&self.pending_local_bidi, &self.pending_local_uni] { - queue.with_mut(|v| { - for slot in v.iter_mut() { - if *slot == stream { - *slot = null_mut(); - } - } - }); - } + /// Creates a stream for this session and registers it. + fn create_stream( + &self, + global: &JSGlobalObject, + session_handle: JSValue, + raw: *mut lsquic::lsquic_stream, + ) -> JsResult<(super::stream::Key, *mut super::stream::QuicStream, JSValue)> { + let key = super::stream::Key(self.next_stream_key.replace(self.next_stream_key.get() + 1)); + let (qs, handle) = super::stream::QuicStream::create( + global, + self.vtable, + core::ptr::from_ref(self).cast_mut(), + key, + session_handle, + raw, + )?; + self.streams.with_mut(|v| v.push((key, qs))); + Ok((key, qs, handle)) + } + pub(super) fn remove_stream(&self, key: super::stream::Key) { + self.streams.with_mut(|v| v.retain(|&(k, _)| k != key)); } fn bump_stream_stat(&self, id: u64, local: bool) { let idx = match (id & STREAM_ID_UNI_BIT != 0, local) { @@ -752,15 +796,8 @@ impl QuicSession { } // SAFETY: sessions only exist on the JS thread of this realm. let global = unsafe { &*global_ptr }; - match super::stream::QuicStream::create( - global, - self.vtable, - core::ptr::from_ref(self).cast_mut(), - self.handle(), - raw, - ) { - Ok((qs, _handle)) => { - self.streams.with_mut(|v| v.push(qs)); + match self.create_stream(global, self.handle(), raw) { + Ok((key, qs, _handle)) => { // SAFETY: `qs` was just created. unsafe { (*qs).mark_wrote_to_lsquic() }; // SAFETY: `raw` is the live stream lsquic just created. @@ -768,12 +805,12 @@ impl QuicSession { self.bump_stream_stat(s.id(), false); } self.push_event(SessionEvent::StreamReady { - stream: qs, + stream: key, remote: true, }); // SAFETY: `qs` was just created. if let Some(code) = unsafe { (*qs).pre_reset_code() } { - self.push_event(SessionEvent::StreamReset { stream: qs, code }); + self.push_event(SessionEvent::StreamReset { stream: key, code }); } qs } @@ -813,25 +850,21 @@ impl QuicSession { } } - fn stream_is_live(&self, stream: *mut super::stream::QuicStream) -> bool { - self.streams.get().contains(&stream) + fn stream_ptr(&self, key: super::stream::Key) -> Option<*mut super::stream::QuicStream> { + let streams = self.streams.get(); + streams.iter().find_map(|&(k, s)| (k == key).then_some(s)) } - /// Teardown removes a stream from the registry before its allocation is - /// freed, so a registered pointer is live on the JS thread. - fn live_stream(&self, p: *mut super::stream::QuicStream) -> Option<&super::stream::QuicStream> { - // SAFETY: see doc comment. - self.stream_is_live(p).then(|| unsafe { &*p }) + fn live_stream(&self, key: super::stream::Key) -> Option<&super::stream::QuicStream> { + // SAFETY: teardown unregisters a stream before freeing it, so a + // registered pointer is live on the JS thread. + self.stream_ptr(key).map(|stream| unsafe { &*stream }) } pub(super) fn apply_peer_datagram_budget(&self) { let Some(tp) = self.conn().and_then(|c| c.peer_transport_params()) else { return; }; - let sz = tp - .max_datagram_frame_size - .saturating_sub(DATAGRAM_FRAME_OVERHEAD) - .min(DATAGRAM_PAYLOAD_BUDGET) as u16; - self.with_state(|s| s.max_datagram_size = sz); + self.with_state(|s| s.max_datagram_size = datagram_budget(&tp)); } fn deliver_pending_tickets(&self, global: &JSGlobalObject) { @@ -934,7 +967,6 @@ impl QuicSession { match event { SessionEvent::HandshakeDone { ok } => { if ok { - self.capture_hsk_snapshot(); if self.is_server.get() || self.peer_verification_refused() { // Node's server reports at handshake COMPLETION // (session.cc: server completion == confirmation @@ -1278,9 +1310,10 @@ impl QuicSession { 1 } }); - let streams: Vec<*mut super::stream::QuicStream> = self.streams.get().clone(); - for sp in streams { - if let Some(stream) = self.live_stream(sp) { + let keys: Vec = + self.streams.get().iter().map(|&(key, _)| key).collect(); + for key in keys { + if let Some(stream) = self.live_stream(key) { stream.cancel_early_rejected(code); } } @@ -1449,8 +1482,8 @@ impl QuicSession { }; stream.apply_peer_stop_sending(code); } - SessionEvent::StreamClosed { stream: stream_ptr } => { - let Some(stream) = self.live_stream(stream_ptr) else { + SessionEvent::StreamClosed { stream: key } => { + let Some(stream) = self.live_stream(key) else { return Ok(()); }; if stream.mark_close_reported() { @@ -1460,7 +1493,7 @@ impl QuicSession { // no onStreamClose -- but it still has to leave `streams` // and drop its self-root, or it lives until teardown. if stream.is_announce_suppressed() { - self.streams.with_mut(|v| v.retain(|&s| s != stream_ptr)); + self.remove_stream(key); stream.release_close_root(); return Ok(()); } @@ -1468,7 +1501,7 @@ impl QuicSession { // this stream and let GC free it. Re-acquire before any // further use; `mark_close_reported` above already fired. stream.end_read_side(global); - let Some(stream) = self.live_stream(stream_ptr) else { + let Some(stream) = self.live_stream(key) else { return Ok(()); }; let handle = stream.handle(); @@ -1484,10 +1517,10 @@ impl QuicSession { } // onStreamClose is user JS too: re-acquire before the // `release_close_root` below, as above. - let Some(stream) = self.live_stream(stream_ptr) else { + let Some(stream) = self.live_stream(key) else { return Ok(()); }; - self.streams.with_mut(|v| v.retain(|&s| s != stream_ptr)); + self.remove_stream(key); // Nothing else reaches this stream now, so drop the // self-root; `stream` stays valid because the retain above // only dropped a pointer. @@ -1501,9 +1534,6 @@ impl QuicSession { if self.handshake_reported.replace(true) || self.destroyed.get() { return; } - if ok { - self.capture_hsk_snapshot(); - } let cert_ok = { if let Some(endpoint) = self.endpoint_ref().filter(|_| ok && self.is_server.get()) { let verify_client = endpoint.server_verify_client.get(); @@ -1521,22 +1551,18 @@ impl QuicSession { self.peer_cert_rejected.set(true); } let open_allowed = ok && !self.peer_cert_rejected.get(); - let peer_frame_size = if !ok || self.conn.get().is_null() { - 0 - } else { - // SAFETY: `conn` is live (handshake just reported on it). - unsafe { lsquic::Conn::from_raw(self.conn.get()) } - .and_then(|c| c.peer_transport_params()) - .map(|tp| tp.max_datagram_frame_size) - .unwrap_or(0) - }; + let max_datagram_size = self + .hsk_snapshot + .get() + .as_ref() + .filter(|_| ok) + .and_then(|s| s.peer_transport_params.as_ref()) + .map_or(0, datagram_budget); self.with_state(|s| { s.handshake_completed = ok as u8; s.handshake_confirmed = ok as u8; s.stream_open_allowed = open_allowed as u8; - s.max_datagram_size = peer_frame_size - .saturating_sub(DATAGRAM_FRAME_OVERHEAD) - .min(DATAGRAM_PAYLOAD_BUDGET) as u16; + s.max_datagram_size = max_datagram_size; }); if !ok { return; @@ -1562,28 +1588,6 @@ impl QuicSession { }; let sni = opt_bytes_to_js(global, snap_sni.as_deref()); let cipher = opt_bytes_to_js(global, snap_cipher.as_deref()); - // HTTP/3 application bits: when the engine runs in `LSENG_HTTP` mode - // (and ALPN confirms it), enable headers/priority and switch the - // close-error codes to RFC 9114's H3_NO_ERROR / H3_INTERNAL_ERROR. - let is_http = self - .endpoint_ref() - .map(|ep| ep.is_http(self.is_server.get())) - .unwrap_or(false) - && alpn_bytes - .as_deref() - .map(|a| a == b"h3" || a.starts_with(b"h3-")) - .unwrap_or(false); - if is_http { - self.with_state(|s| { - s.headers_supported = 1; - s.application_type = 1; - s.priority_supported = 1; - s.no_error_code = H3_NO_ERROR; - s.internal_error_code = H3_INTERNAL_ERROR; - }); - } else { - self.with_state(|s| s.headers_supported = 2); - } let alpn = alpn_bytes .map(|b| bun_string_jsc::create_utf8_for_js(global, &b).or_report()) .unwrap_or(JSValue::UNDEFINED); @@ -1685,13 +1689,7 @@ impl QuicSession { .is_some_and(|s| s.validation.is_some()) } - fn capture_hsk_snapshot(&self) { - if self.hsk_snapshot.get().is_some() { - return; - } - let Some(conn) = self.conn() else { - return; - }; + fn capture_hsk_snapshot(&self, conn: lsquic::Conn) { let sni = conn.sni().map(|s| s.to_bytes().to_vec()); let cipher = conn.cipher().map(|s| s.to_bytes().to_vec()); let ssl = conn.ssl().cast(); @@ -1704,6 +1702,7 @@ impl QuicSession { let local_cert_der = tls::local_certificate_der(ssl); let ephemeral = tls::ephemeral_key_info(ssl); let early_data = tls::early_data_info(ssl); + let peer_transport_params = conn.peer_transport_params(); self.hsk_snapshot.with_mut(|s| { *s = Some(HskSnapshot { sni, @@ -1714,6 +1713,7 @@ impl QuicSession { local_cert_der, ephemeral, early_data, + peer_transport_params, }); }); } @@ -1828,13 +1828,13 @@ impl QuicSession { // root every wrapper up front. let _roots: Vec = streams .iter() - .map(|&qs| { + .map(|&(_, qs)| { // SAFETY: entries are live here -- a stream is only downgraded // after `remove_stream` drops it from `self.streams`. Strong::create(unsafe { (*qs).handle() }, _global) }) .collect(); - for qs in streams { + for (_, qs) in streams { // SAFETY: rooted by `_roots` above; teardown is idempotent. unsafe { (*qs).teardown(_global) }; } @@ -1911,8 +1911,9 @@ impl QuicSession { .endpoint_ref() .map(|ep| ep.is_http(self.is_server.get())) .unwrap_or(false); - if is_http && !app && code == 0 && !self.streams.get().is_empty() { - // RFC 9114 §5.2. + if is_http && !app && code == 0 && self.has_opened_stream() { + // RFC 9114 §5.2. lsquic can't send a GOAWAY until the handshake creates the + // control stream. An opened stream proves the handshake finished. if let Some(c) = self.conn() { c.going_away(); } @@ -1959,11 +1960,19 @@ impl QuicSession { Ok(()) } - fn any_stream_undelivered(&self) -> bool { - self.streams.get().iter().any(|&s| { + /// True if lsquic has opened a stream. Like Node, pending streams don't delay a graceful close. + fn has_opened_stream(&self) -> bool { + self.streams.get().iter().any(|&(_, s)| { // SAFETY: pointers in `streams` are unregistered before their // owner is destroyed (registry invariant). - unsafe { (*s).has_undelivered_outbound() } + unsafe { !(*s).is_pending() } + }) + } + + fn any_stream_undelivered(&self) -> bool { + self.streams.get().iter().any(|&(_, s)| { + // SAFETY: as in `has_opened_stream`. + unsafe { !(*s).is_pending() && (*s).has_undelivered_outbound() } }) } @@ -1974,7 +1983,7 @@ impl QuicSession { if self.any_stream_undelivered() { return; } - if self.close_after_streams.get() && !self.streams.get().is_empty() { + if self.close_after_streams.get() && self.has_opened_stream() { return; } if let Some((app, code, reason)) = self.deferred_close.with_mut(Option::take) { @@ -2067,19 +2076,12 @@ impl QuicSession { } let [direction, body] = frame.arguments_as_array::<2>(); let unidirectional = direction.is_number() && direction.as_number() == 1.0; - let (qs, handle) = super::stream::QuicStream::create( - global, - self.vtable, - core::ptr::from_ref(self).cast_mut(), - frame.this(), - null_mut(), - )?; + let (key, qs, handle) = self.create_stream(global, frame.this(), null_mut())?; if unidirectional { - self.pending_local_uni.with_mut(|q| q.push_back(qs)); + self.pending_local_uni.with_mut(|q| q.push_back(key)); } else { - self.pending_local_bidi.with_mut(|q| q.push_back(qs)); + self.pending_local_bidi.with_mut(|q| q.push_back(key)); } - self.streams.with_mut(|v| v.push(qs)); self.bump_stream_stat(if unidirectional { STREAM_ID_UNI_BIT } else { 0 }, true); if let Some(buf) = body.as_array_buffer(global) { // SAFETY: `qs` was just created. @@ -2355,6 +2357,13 @@ impl QuicSession { } } +/// Returns the largest DATAGRAM payload the peer's transport parameters allow. +fn datagram_budget(tp: &lsquic::NqTransportParams) -> u16 { + tp.max_datagram_frame_size + .saturating_sub(DATAGRAM_FRAME_OVERHEAD) + .min(DATAGRAM_PAYLOAD_BUDGET) as u16 +} + fn opt_bytes_to_js(global: &JSGlobalObject, bytes: Option<&[u8]>) -> JSValue { match bytes { Some(b) => bun_string_jsc::create_utf8_for_js(global, b).or_report(), @@ -2392,17 +2401,39 @@ impl QuicSession { global: &JSGlobalObject, _f: &CallFrame, ) -> JsResult { + if let Some(tp) = self.conn().and_then(|c| c.peer_transport_params()) { + return Self::transport_params_to_js(global, &tp); + } + match self + .hsk_snapshot + .get() + .as_ref() + .and_then(|s| s.peer_transport_params.as_ref()) + { + Some(tp) => Self::transport_params_to_js(global, tp), + None => Ok(JSValue::UNDEFINED), + } + } + + /// Replaces `on_conn_closed` after `QuicEndpoint::detach_for_finalize`. + pub(super) unsafe extern "C" fn on_closed_detached(ctx: *mut c_void) { + // SAFETY: `finalize` nulls the lsquic ctx, so a non-null ctx is live. + // `endpoint` and `streams` may be freed, so only touch `conn`. + if let Some(session) = unsafe { super::ffi::ctx_ref::(ctx) } { + session.conn.set(null_mut()); + } + } + + #[expect( + clippy::boxed_local, + reason = "codegen's host_fn_finalize calls this as `|b| QuicSession::finalize(b)` and requires `self: Box`" + )] + pub(crate) fn finalize(self: Box) { let conn = self.conn.get(); - if conn.is_null() { - return Ok(JSValue::UNDEFINED); + if !conn.is_null() { + // SAFETY: `on_conn_closed` and `teardown` null `conn`, so a non-null `conn` is live. + unsafe { lsquic::lsquic_conn_set_ctx(conn, null_mut()) }; } - // SAFETY: `conn` is live until on_conn_closed clears it. - let Some(tp) = - (unsafe { lsquic::Conn::from_raw(conn) }).and_then(|c| c.peer_transport_params()) - else { - return Ok(JSValue::UNDEFINED); - }; - Self::transport_params_to_js(global, &tp) } } @@ -2427,27 +2458,26 @@ lsquic_callback! { pub(super) fn on_hsk_done(session: &QuicSession, status: c_int) { let ok = status == lsquic::LSQ_HSK_OK || status == lsquic::LSQ_HSK_RESUMED_OK; - if ok && !session.is_server.get() && session.reject_unverified_peer.get() { - session.capture_hsk_snapshot(); - if session.peer_verification_refused() { - session.peer_cert_rejected.set(true); - session.self_close.with_mut(|s| { - *s = Some(( - false, - CRYPTO_ERROR_BAD_CERTIFICATE, - b"peer certificate verification failed".to_vec(), - )); - }); - if let Some(c) = session.conn() { - c.abort_error( - false, - CRYPTO_ERROR_BAD_CERTIFICATE as c_uint, - c"peer certificate verification failed", - ); - } - } + let Some(conn) = session.conn().filter(|_| ok) else { + session.push_event(SessionEvent::HandshakeDone { ok: false }); + return; + }; + session.note_handshake_ok(conn); + if session.peer_verification_refused() { + session.peer_cert_rejected.set(true); + session.self_close.with_mut(|s| { + *s = Some(( + false, + CRYPTO_ERROR_BAD_CERTIFICATE, + b"peer certificate verification failed".to_vec(), + )); + }); + conn.abort_error( + false, + CRYPTO_ERROR_BAD_CERTIFICATE as c_uint, + c"peer certificate verification failed", + ); } - session.push_event(SessionEvent::HandshakeDone { ok }); } } diff --git a/src/runtime/node/quic/stream.rs b/src/runtime/node/quic/stream.rs index d26532702eca..7a39ed96372c 100644 --- a/src/runtime/node/quic/stream.rs +++ b/src/runtime/node/quic/stream.rs @@ -80,6 +80,11 @@ const PULL_STATUS_DATA: f64 = 1.0; const PULL_STATUS_BLOCKED: f64 = 2.0; const PULL_STATUS_ERROR: f64 = -1.0; +/// Identifies a stream within its session. Keys are never reused, so unlike a pointer, +/// a stale key can't match a newer stream. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) struct Key(pub(super) u64); + #[derive(Clone, Copy, Default, PartialEq, Eq)] pub(super) enum PendingEnd { #[default] @@ -111,6 +116,7 @@ pub(crate) struct QuicStream { raw: Cell<*mut lsquic::lsquic_stream>, /// Owning session (raw pointer; the JS-side Strong keeps it alive). session: Cell<*mut QuicSession>, + key: Key, session_js: JsCell>, this_value: JsCell, state: Cell<*mut StreamState>, @@ -136,13 +142,16 @@ impl QuicStream { global: &JSGlobalObject, vtable: *const lsquic::NqVtable, session: *mut QuicSession, + key: Key, session_handle: JSValue, raw: *mut lsquic::lsquic_stream, ) -> JsResult<(*mut QuicStream, JSValue)> { + // `bind_raw` sets `raw` after the allocations below succeed. let stream = QuicStream { vtable, - raw: Cell::new(raw), + raw: Cell::new(null_mut()), session: Cell::new(session), + key, session_js: JsCell::new(Some(Strong::create(session_handle, global))), this_value: JsCell::new(JsRef::empty()), state: Cell::new(null_mut()), @@ -363,6 +372,10 @@ impl QuicStream { } self.ls().is_some_and(|s| s.has_unacked_data()) } + /// True until lsquic opens the stream. + pub(super) fn is_pending(&self) -> bool { + self.with_state(|s| s.pending != 0) + } pub(super) fn stream_id(&self) -> i64 { self.with_state(|s| s.id) @@ -470,9 +483,7 @@ impl QuicStream { if !session.is_null() { // SAFETY: the session outlives its streams. unsafe { - (*session).push_event(SessionEvent::StreamBlocked { - stream: core::ptr::from_ref(self).cast_mut(), - }); + (*session).push_event(SessionEvent::StreamBlocked { stream: self.key }); } } } @@ -520,9 +531,7 @@ impl QuicStream { } } else if end == PendingEnd::Trailers && !self.trailers_requested.replace(true) { if let Some(session) = self.session_ref() { - session.push_event(SessionEvent::StreamWantsTrailers { - stream: core::ptr::from_ref(self).cast_mut(), - }); + session.push_event(SessionEvent::StreamWantsTrailers { stream: self.key }); } } s.want_write(false); @@ -536,9 +545,7 @@ impl QuicStream { }); if was_full && pending < hwm { if let Some(session) = self.session_ref() { - session.push_event(SessionEvent::StreamDrain { - stream: core::ptr::from_ref(self).cast_mut(), - }); + session.push_event(SessionEvent::StreamDrain { stream: self.key }); } } } @@ -587,7 +594,7 @@ impl QuicStream { // The session's `streams` Vec is the only other holder; remove // it before downgrading so process_events can't iterate a freed // stream. SAFETY: as above. - unsafe { (*session).remove_stream(core::ptr::from_ref(self).cast_mut()) }; + unsafe { (*session).remove_stream(self.key) }; } self.outbound.with_mut(|o| o.data.clear()); self.inbound.with_mut(|i| { @@ -610,6 +617,29 @@ impl QuicStream { self.this_value.with_mut(|r| r.downgrade()); } + /// Replaces `on_stream_close` after `QuicEndpoint::detach_for_finalize`. + pub(super) unsafe extern "C" fn on_close_detached( + ctx: *mut c_void, + _s: *mut lsquic::lsquic_stream, + ) { + // SAFETY: `finalize` nulls the lsquic ctx, so a non-null ctx is live. + // `session` may be freed, so only touch `raw`. + if let Some(qs) = unsafe { super::ffi::ctx_ref::(ctx) } { + qs.raw.set(null_mut()); + } + } + + #[expect( + clippy::boxed_local, + reason = "codegen's host_fn_finalize calls this as `|b| QuicStream::finalize(b)` and requires `self: Box`" + )] + pub(crate) fn finalize(self: Box) { + if let Some(s) = self.ls() { + // SAFETY: a non-null `raw` is live (see `ls()`). + unsafe { s.set_ctx(null_mut()) }; + } + } + pub(crate) fn get_reader(&self, _g: &JSGlobalObject, frame: &CallFrame) -> JsResult { self.with_state(|s| s.has_reader = 1); Ok(frame.this()) @@ -1015,9 +1045,10 @@ pub(super) unsafe extern "C" fn on_new_stream( if is_local { if let Some(qs) = session.take_pending_local_stream(id & STREAM_ID_UNI_BIT as u64 != 0) { // SAFETY: pending streams are kept alive by their wrapper Strong. - unsafe { (*qs).bind_raw(s) }; + let pending = unsafe { &*qs }; + pending.bind_raw(s); session.push_event(SessionEvent::StreamReady { - stream: qs, + stream: pending.key, remote: false, }); return qs.cast(); @@ -1082,7 +1113,7 @@ pub(super) unsafe extern "C" fn on_stream_read(ctx: *mut c_void, s: *mut lsquic: }; if let Some(session) = qs.session_ref() { session.push_event(SessionEvent::StreamHeaders { - stream: ctx.cast(), + stream: qs.key, pairs, kind, }); @@ -1128,7 +1159,7 @@ pub(super) unsafe extern "C" fn on_stream_read(ctx: *mut c_void, s: *mut lsquic: } if got_any { if let Some(session) = qs.session_ref() { - session.push_event(SessionEvent::StreamWake { stream: ctx.cast() }); + session.push_event(SessionEvent::StreamWake { stream: qs.key }); } } } @@ -1138,18 +1169,15 @@ lsquic_callback! { qs.drain_outbound(); } - pub(super) fn on_stream_close( - ctx: *mut c_void as qs: &QuicStream, - _s: *mut lsquic::lsquic_stream, - ) { + pub(super) fn on_stream_close(qs: &QuicStream, _s: *mut lsquic::lsquic_stream) { // The lsquic_stream is freed immediately after this callback returns. qs.raw.set(null_mut()); if let Some(session) = qs.session_ref() { - session.push_event(SessionEvent::StreamClosed { stream: ctx.cast() }); + session.push_event(SessionEvent::StreamClosed { stream: qs.key }); } } - pub(super) fn on_stream_reset(ctx: *mut c_void as qs: &QuicStream, how: c_int, code: u64) { + pub(super) fn on_stream_reset(qs: &QuicStream, how: c_int, code: u64) { if how == 0 || how == 2 { qs.mark_reset(code); if let Some(s) = qs.ls() { @@ -1164,10 +1192,10 @@ lsquic_callback! { } if let Some(session) = qs.session_ref() { session.push_event(SessionEvent::StreamReset { - stream: ctx.cast(), + stream: qs.key, code, }); - session.push_event(SessionEvent::StreamWake { stream: ctx.cast() }); + session.push_event(SessionEvent::StreamWake { stream: qs.key }); } } if how == 1 || how == 2 { @@ -1176,10 +1204,10 @@ lsquic_callback! { if let Some(session) = qs.session_ref() { // Node's `onstream` still observes a live writer. session.push_event(SessionEvent::StreamStopSending { - stream: ctx.cast(), + stream: qs.key, code, }); - session.push_event(SessionEvent::StreamWake { stream: ctx.cast() }); + session.push_event(SessionEvent::StreamWake { stream: qs.key }); } else { qs.apply_peer_stop_sending(code); } diff --git a/src/runtime/node/quic/tls.rs b/src/runtime/node/quic/tls.rs index 910e8ed94a60..b85c327c6254 100644 --- a/src/runtime/node/quic/tls.rs +++ b/src/runtime/node/quic/tls.rs @@ -654,8 +654,10 @@ pub(super) fn local_certificate_der(ssl: *mut ssl::SSL) -> Option> { if ssl.is_null() { return None; } - // SAFETY: returns a borrowed X509 owned by the SSL. - let cert = unsafe { ssl::SSL_get_certificate(ssl) }; + // `load_cert_chain` only installs certificates on the context. The context parses its + // leaf once, but `SSL_get_certificate` parses a copy for each SSL. + // SAFETY: returns a borrowed X509 owned by `ssl`'s context. + let cert = unsafe { ssl::SSL_CTX_get0_certificate(ssl::SSL_get_SSL_CTX(ssl)) }; if cert.is_null() { return None; } diff --git a/src/runtime/node/types.rs b/src/runtime/node/types.rs index bf382d18714f..aa01b4a507f8 100644 --- a/src/runtime/node/types.rs +++ b/src/runtime/node/types.rs @@ -289,8 +289,33 @@ impl StringOrBuffer<'static> { unsafe { ThreadIsolated::new(Self::owned(bytes)) } } + /// Copies a `Sync`-parsed buffer's current bytes, or isolates a string, for a work-pool job. + pub(crate) fn make_thread_isolated_copy(&mut self, global: &JSGlobalObject) -> JsResult<()> { + match self { + Self::Buffer(buffer) => { + buffer.buffer = jsc::ArrayBuffer::from_typed_array(global, buffer.buffer.value); + let mut bytes = Vec::new(); + if bytes.try_reserve_exact(buffer.slice().len()).is_err() { + return Err(global.throw_out_of_memory()); + } + bytes.extend_from_slice(buffer.slice()); + global.vm().report_extra_memory(bytes.len()); + *self = Self::owned(bytes); + } + Self::String(str) => { + str.make_thread_isolated(); + *self = Self::ThreadIsolatedString(core::mem::take(str)); + } + Self::ThreadIsolatedString(_) | Self::Utf8(_) => {} + Self::PinnedBuffer(_) => { + unreachable!("make_thread_isolated_copy on a value parsed with Flavor::Async") + } + } + Ok(()) + } + /// `value` is ArrayBuffer-like (the caller checked): borrowed for `Sync`, - /// pinned and GC-rooted for `Async`. + /// [`PinnedArrayBuffer::root_read_only`] for `Async`. pub(crate) fn buffer_from_js( global: &JSGlobalObject, value: JSValue, @@ -299,7 +324,7 @@ impl StringOrBuffer<'static> { if flavor == Flavor::Sync { return Ok(Self::Buffer(Buffer::from_array_buffer(global, value))); } - match PinnedArrayBuffer::root(global, value) { + match PinnedArrayBuffer::root_read_only(global, value) { Some(buffer) => Ok(Self::PinnedBuffer(buffer)), None => Err(global.throw_out_of_memory()), } @@ -402,19 +427,33 @@ impl StringOrBuffer<'static> { Self::from_js_maybe_async(global, value, Flavor::Sync, StringObjects::Allow) } - /// [`from_js`](Self::from_js) for a work-pool job that reads the bytes itself: strings thread-isolated, buffers pinned and GC-rooted, a resizable buffer copied ([`PinnedArrayBuffer::copy_if_resizable`]). + /// Like [`from_js`](Self::from_js), but copies a buffer that + /// [`can_change_under_borrow`](jsc::ArrayBuffer::can_change_under_borrow). + /// Use it for parsers that read a byte more than once. + pub(crate) fn from_js_stable( + global: &JSGlobalObject, + value: JSValue, + ) -> JsResult> { + let parsed = Self::from_js(global, value)?; + if let Some(Self::Buffer(buffer)) = &parsed + && buffer.buffer.can_change_under_borrow() + { + return match buffer.buffer.try_copy_bytes() { + Some(copy) => Ok(Some(Self::owned(copy))), + None => Err(global.throw_out_of_memory()), + }; + } + Ok(parsed) + } + + /// Like [`from_js`](Self::from_js), but for a work-pool job that reads the bytes. Strings are + /// thread-isolated, and buffers are pinned and GC-rooted (see [`buffer_from_js`](Self::buffer_from_js)). #[inline] pub(crate) fn from_js_async( global: &JSGlobalObject, value: JSValue, ) -> JsResult>> { - let mut parsed = - Self::from_js_maybe_async(global, value, Flavor::Async, StringObjects::Allow)?; - if let Some(Self::PinnedBuffer(buffer)) = &mut parsed - && !buffer.copy_if_resizable(global) - { - return Err(global.throw_out_of_memory()); - } + let parsed = Self::from_js_maybe_async(global, value, Flavor::Async, StringObjects::Allow)?; // SAFETY: parsed with `Flavor::Async`. Ok(parsed.map(|v| unsafe { ThreadIsolated::new(v) })) } @@ -1060,8 +1099,9 @@ impl PathLikeExt for PathLike<'_> { PinnedArrayBuffer::pin(ctx, arg) } .ok_or_else(|| ctx.throw_out_of_memory())?; - // Read after this call (pool thread, a later argument's getter, a `Blob` store): a shrink in between unmaps the pages. - if !buffer.copy_if_resizable(ctx) { + // The path is read after this call returns. By then a later argument's getter can + // shrink the buffer or grow its `WebAssembly.Memory`, which frees the pages. + if !buffer.copy_if_freeable_while_pinned(ctx) { return Err(ctx.throw_out_of_memory()); } Valid::path_buffer(buffer.slice(), ctx)?; @@ -1225,18 +1265,27 @@ impl Valid { } pub(crate) fn path_null_bytes(slice: &[u8], global: &JSGlobalObject) -> JsResult<()> { - if strings::index_of_char(slice, 0).is_some() { - return Err(global - .err( - jsc::ErrorCode::INVALID_ARG_VALUE, - format_args!( - "The argument 'path' must be a string, Uint8Array, or URL without null bytes. Received {}", - bun_fmt::quote(slice) - ), - ) - .throw()); + Self::no_null_bytes(slice, "path", "a string, Uint8Array, or URL", global) + } + + pub(crate) fn no_null_bytes( + slice: &[u8], + name: &str, + accepts: &str, + global: &JSGlobalObject, + ) -> JsResult<()> { + if strings::index_of_char(slice, 0).is_none() { + return Ok(()); } - Ok(()) + Err(global + .err( + jsc::ErrorCode::INVALID_ARG_VALUE, + format_args!( + "The argument '{name}' must be {accepts} without null bytes. Received {}", + bun_fmt::quote(slice) + ), + ) + .throw()) } } diff --git a/src/runtime/node/util/parse_args.rs b/src/runtime/node/util/parse_args.rs index ab4e96982495..227b2a918319 100644 --- a/src/runtime/node/util/parse_args.rs +++ b/src/runtime/node/util/parse_args.rs @@ -189,6 +189,7 @@ fn get_default_args(global: &JSGlobalObject) -> JsResult { let exec_argv = super::process::get_exec_argv(global)?; let argv = super::process::get_argv(global)?; if argv.is_array() && exec_argv.is_array() { + let end = u32::try_from(argv.get_length(global)?).expect("int cast"); let mut iter = exec_argv.array_iterator(global)?; while let Some(item) = iter.next()? { if item.is_string() { @@ -200,16 +201,17 @@ fn get_default_args(global: &JSGlobalObject) -> JsResult { { return Ok(ArgsSlice { array: argv, - start: 1, - end: u32::try_from(argv.get_length(global)?).expect("int cast"), + start: end.min(1), + end, }); } } } + // `process.argv` can have fewer than 2 items. Clamp the start like `ArrayPrototypeSlice` does. return Ok(ArgsSlice { array: argv, - start: 2, - end: u32::try_from(argv.get_length(global)?).expect("int cast"), + start: end.min(2), + end, }); } diff --git a/src/runtime/node/zlib/NativeBrotli.rs b/src/runtime/node/zlib/NativeBrotli.rs index 64e962f3530b..fa71a78d09f1 100644 --- a/src/runtime/node/zlib/NativeBrotli.rs +++ b/src/runtime/node/zlib/NativeBrotli.rs @@ -89,6 +89,8 @@ mod _impl { pub write_in_progress: Cell, /// bit 0: the pending input's ArrayBuffer is pinned; bit 1: the pending output's. A held bufferless view sets neither. pub pinned_buffers: Cell, + /// Copy of the pending `write()`'s input, if a pin can't keep the input mapped. + pub input_copy: JsCell, pub pending_close: Cell, pub closed: Cell, pub task: JsCell, @@ -152,6 +154,7 @@ mod _impl { this_value: JsCell::new(StrongOptional::empty()), write_in_progress: Cell::new(false), pinned_buffers: Cell::new(0), + input_copy: JsCell::new(Default::default()), pending_close: Cell::new(false), closed: Cell::new(false), // .callback = undefined — overwritten before WorkPool::schedule() diff --git a/src/runtime/node/zlib/NativeZlib.rs b/src/runtime/node/zlib/NativeZlib.rs index ee92c0dffa5f..64256a5682b6 100644 --- a/src/runtime/node/zlib/NativeZlib.rs +++ b/src/runtime/node/zlib/NativeZlib.rs @@ -46,6 +46,8 @@ mod _impl { pub write_in_progress: Cell, /// bit 0: the pending input's ArrayBuffer is pinned; bit 1: the pending output's. A held bufferless view sets neither. pub pinned_buffers: Cell, + /// Copy of the pending `write()`'s input, if a pin can't keep the input mapped. + pub input_copy: JsCell, pub pending_close: Cell, pub closed: Cell, pub task: JsCell, @@ -101,6 +103,7 @@ mod _impl { this_value: JsCell::new(StrongOptional::empty()), write_in_progress: Cell::new(false), pinned_buffers: Cell::new(0), + input_copy: JsCell::new(Default::default()), pending_close: Cell::new(false), closed: Cell::new(false), task: JsCell::new(WorkPoolTask { diff --git a/src/runtime/node/zlib/NativeZstd.rs b/src/runtime/node/zlib/NativeZstd.rs index 8a56248734c2..1ca259a25fb4 100644 --- a/src/runtime/node/zlib/NativeZstd.rs +++ b/src/runtime/node/zlib/NativeZstd.rs @@ -49,6 +49,8 @@ mod _impl { pub write_in_progress: Cell, /// bit 0: the pending input's ArrayBuffer is pinned; bit 1: the pending output's. A held bufferless view sets neither. pub pinned_buffers: Cell, + /// Copy of the pending `write()`'s input, if a pin can't keep the input mapped. + pub input_copy: JsCell, pub pending_close: Cell, pub closed: Cell, pub task: JsCell, @@ -115,6 +117,7 @@ mod _impl { this_value: JsCell::new(StrongOptional::empty()), write_in_progress: Cell::new(false), pinned_buffers: Cell::new(0), + input_copy: JsCell::new(Default::default()), pending_close: Cell::new(false), closed: Cell::new(false), // WorkPoolTask { callback: undefined } — callback is overwritten by @@ -644,29 +647,10 @@ mod _impl { pub(crate) fn close(&mut self) { // Idempotent: a handle that was never (successfully) initialized, - // or that was already closed, has no CCtx/DCtx to reset or free. - if self.state.is_none() { - self.mode = NodeMode::NONE; - return; + // or that was already closed, has no CCtx/DCtx to free. + if self.state.is_some() { + self.deinit_state(); } - let _ = match self.mode { - // SAFETY: state is a valid CCtx/DCtx for this mode. - NodeMode::ZSTD_COMPRESS => unsafe { - c::ZSTD_CCtx_reset( - self.state_ptr().cast(), - c::ZSTD_reset_session_and_parameters, - ) - }, - // SAFETY: state is a valid DCtx set by init() for this mode. - NodeMode::ZSTD_DECOMPRESS => unsafe { - c::ZSTD_DCtx_reset( - self.state_ptr().cast(), - c::ZSTD_reset_session_and_parameters, - ) - }, - _ => unreachable!(), - }; - self.deinit_state(); self.mode = NodeMode::NONE; } diff --git a/src/runtime/server/AnyRequestContext.rs b/src/runtime/server/AnyRequestContext.rs index 26d8098a9f8f..b174f575bbef 100644 --- a/src/runtime/server/AnyRequestContext.rs +++ b/src/runtime/server/AnyRequestContext.rs @@ -235,6 +235,10 @@ impl AnyRequestContext { dispatch!(self, (), |_T, ctx| ctx.deref()) } + pub(crate) fn end_with_internal_server_error(self) { + dispatch!(self, (), |_T, ctx| ctx.end_with_internal_server_error()) + } + pub fn on_request_body_stream_drained(self) { dispatch!( self, diff --git a/src/runtime/server/FileResponseStream.rs b/src/runtime/server/FileResponseStream.rs index 6c092fcfb2c0..75b679af7d9d 100644 --- a/src/runtime/server/FileResponseStream.rs +++ b/src/runtime/server/FileResponseStream.rs @@ -573,6 +573,10 @@ impl FileResponseStream { "finish (already={})", self.state.get().contains(State::FINISHED) ); + // If the request aborts before the reader gets its first byte, no reader callback will release this ref. + // On Windows, a pending uv read needs the stream alive and releases the ref itself. + #[cfg(unix)] + drop(self.take_read_ref()); if self.state.get().contains(State::FINISHED) { return; } @@ -582,12 +586,10 @@ impl FileResponseStream { self.insert_state(State::RESPONSE_DONE); self.detach_resp(); let resp = self.resp.get(); - resp.end_without_body(resp.should_close_connection()); + // `end` writes a chunked body's final chunk, or `Content-Length: 0` if no + // length was declared, and runs the uWS close check. + resp.end(b"", resp.should_close_connection()); self.deliver(resp, StreamEnd::Complete); - // This end runs uncorked (reader callbacks), so no cork or parser - // gate will run the close check; do it here, after `on_complete` - // like `end_sendfile`, so the callbacks see a live socket. - resp.close_if_done_and_marked(); } // Release the owner ref from `heap::into_raw` in `start()`. Every entry @@ -644,9 +646,9 @@ bun_io::impl_buffered_reader_parent! { impl Drop for FileResponseStream { fn drop(&mut self) { bun_output::scoped_log!(FileResponseStream, "deinit"); - // `self.reader` (BufferedReader) is torn down by its own `Drop` as a - // field — closes the poll handle. `bun.destroy(this)` is owned by - // `heap::take` in `deref`, not here. + // `start()` cleared CLOSE_HANDLE, so the reader's `Drop` doesn't unregister the poll. + #[cfg(unix)] + self.reader_mut().handle.close_without_closing_fd(); if self.auto_close.get() { #[cfg(windows)] Closer::close(self.fd.get(), bun_sys::windows::libuv::Loop::get()); diff --git a/src/runtime/server/FileRoute.rs b/src/runtime/server/FileRoute.rs index 09dad42332e9..e4278582cef0 100644 --- a/src/runtime/server/FileRoute.rs +++ b/src/runtime/server/FileRoute.rs @@ -50,8 +50,12 @@ pub(crate) struct InitOptions<'a> { use crate::webcore::headers_ref::blob_content_type; #[inline] -fn headers_from(fetch_headers: Option<&FetchHeaders>, blob: &Blob) -> Headers { - bun_http_jsc::headers_jsc::from_fetch_headers(fetch_headers, blob_content_type(blob)) +fn headers_from( + global: &JSGlobalObject, + fetch_headers: Option<&FetchHeaders>, + blob: &Blob, +) -> JsResult { + bun_http_jsc::headers_jsc::from_fetch_headers(global, fetch_headers, blob_content_type(blob)) } #[inline] @@ -124,9 +128,18 @@ impl FileRoute { } } - pub(crate) fn init_from_blob(blob: Blob, opts: &InitOptions<'_>) -> RefPtr { - let headers = headers_from(opts.headers, &blob); - RefPtr::new(FileRoute::new(blob, headers, opts.server, opts.status_code)) + pub(crate) fn init_from_blob( + global: &JSGlobalObject, + blob: Blob, + opts: &InitOptions<'_>, + ) -> JsResult> { + let headers = headers_from(global, opts.headers, &blob)?; + Ok(RefPtr::new(FileRoute::new( + blob, + headers, + opts.server, + opts.status_code, + ))) } pub(crate) fn from_js( @@ -165,7 +178,7 @@ impl FileRoute { "expected blob not to be heap-allocated" ); *body_value = BodyValue::Blob(blob.dupe()); - let headers = headers_from(response.get_init_headers(), &blob); + let headers = headers_from(global, response.get_init_headers(), &blob)?; let status_code = response.status_code(); return Ok(Some(RefPtr::new(FileRoute::new( @@ -184,7 +197,7 @@ impl FileRoute { !b.is_heap_allocated(), "expected blob not to be heap-allocated" ); - let headers = headers_from(None, &b); + let headers = headers_from(global, None, &b)?; return Ok(Some(RefPtr::new(FileRoute::new(b, headers, None, 200)))); } } diff --git a/src/runtime/server/NodeHTTPResponse.rs b/src/runtime/server/NodeHTTPResponse.rs index 88f212f60932..0f9246fc6cb7 100644 --- a/src/runtime/server/NodeHTTPResponse.rs +++ b/src/runtime/server/NodeHTTPResponse.rs @@ -2133,11 +2133,10 @@ impl NodeHTTPResponse { bytes_len ); // For buffers, pin so `transfer()` copies instead of detaching. - // Resizable (non-shared) buffers are spilled: `resize()` mprotect()s - // trimmed pages PROT_NONE and `pin()` doesn't prevent it. + // If a pin can't keep the bytes mapped, copy them into the backpressure buffer instead. let pinned_value = if is_buffer && input_value.is_cell() { match input_value.as_pinned_arraybuffer(global_object) { - Some(ab) if ab.resizable && !ab.shared => { + Some(ab) if ab.can_be_freed_while_pinned() => { ab.unpin(); None } diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 8c5568b2a6c0..235047897953 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -277,7 +277,7 @@ use crate::node::types::PathLikeExt as _; use crate::server::jsc::CallFrame; use crate::server::{AnyRequestContext, FileResponseStream, HTTPStatusText, file_response_stream}; use crate::webcore::blob::BlobExt as _; -use crate::webcore::{Blob, ReadableStream, body as Body, s3 as S3}; +use crate::webcore::{Blob, ReadableStream, body as Body}; use bun_jsc::SysErrorJsc as _; /// RAII: releases one intrusive ref on a [`RequestContext`] at scope exit. @@ -810,6 +810,8 @@ where fn render_missing_invalid_response(&self, value: JSValue) { let class_name = value.get_class_info_name().unwrap_or(b""); + // Inspecting `value` can run user code (`[inspect.custom]`, `toString`), which can throw. + let mut inspect_error: Option = None; if let Some(server) = self.server.get() { // server is a BACKREF — valid while this RequestContext is alive @@ -825,10 +827,17 @@ where } else if !value.is_empty() && !global_this.has_exception() { let mut formatter = jsc::ConsoleObject::Formatter::new(global_this); formatter.quote_strings = true; - bun_core::err_generic!( - "Expected a Response object, but received '{}'", - jsc::console_object::formatter::ZigFormatter::new(&mut formatter, value), - ); + let mut received: Vec = Vec::new(); + match formatter.format_value::(value, &mut received) { + Ok(()) => bun_core::err_generic!( + "Expected a Response object, but received '{}'", + bstr::BStr::new(&received), + ), + Err(err) => { + bun_core::err_generic!("Expected a Response object"); + inspect_error = Some(global_this.take_error(err)); + } + } // `formatter` drops here. } else { bun_core::err_generic!("Expected a Response object"); @@ -849,6 +858,11 @@ where if self.is_aborted_or_ended() { return; } + if let Some(error) = inspect_error { + // Report it as if the handler threw it. + self.run_error_handler(error); + return; + } self.render_missing(); } @@ -1278,7 +1292,7 @@ where let bb = DevErrorPage { message, - cwd: bun_resolver::fs::FileSystem::get().top_level_dir, + cwd: bun_resolver::fs::FileSystem::get().top_level_dir(), exceptions, log: Some(log), } @@ -1411,6 +1425,18 @@ where } } + /// Answers 500 with no body, for a request that was saved to run later and now never will. + /// A request that got an answer while it waited (a 413) has detached its response, so + /// nothing more is written to it. + pub(crate) fn end_with_internal_server_error(&self) { + if let Some(resp) = self.resp.get() { + self.flags.set_has_written_status(true); + resp.write_status(b"500 Internal Server Error"); + resp.write_header_int(b"Content-Length", 0); + } + self.end_without_body(true); + } + pub(crate) fn force_close(&self) { if let Some(resp) = self.resp.get() { self.detach_response(); @@ -2560,49 +2586,6 @@ where || self.server().terminated() } - fn do_render_head_response_after_s3_size_resolved( - pair: *mut HeaderResponseSizePair<'_, ThisServer, SSL_ENABLED, DEBUG_MODE, MUX>, - ) { - // SAFETY: `pair` is the live stack-local threaded through the - // synchronous cork call. - let pair = unsafe { &*pair }; - let this = pair.this; - this.render_metadata(); - - if let Some(resp) = this.resp.get() { - // SAFETY: FFI handle - resp.write_header_int(b"content-length", pair.size as u64); - } - this.end_without_body(this.should_close_connection()); - // `end_without_body` released the base ref; the caller - // (`on_s3_size_resolved`) releases the ref taken for the S3 stat. - } - - /// `S3::client::stat` callback shape: `fn(S3StatResult, *mut c_void) -> JsResult<()>`. - fn on_s3_size_resolved_thunk( - result: S3::simple_request::S3StatResult<'_>, - this: *mut c_void, - ) -> JsResult<()> { - let stat_ref = RequestContextRef::adopt(this.cast::()); - stat_ref.ctx().on_s3_size_resolved(result); - Ok(()) - } - - pub(crate) fn on_s3_size_resolved(&self, result: S3::simple_request::S3StatResult<'_>) { - if let Some(resp) = self.resp.get() { - let size = match result { - S3::simple_request::S3StatResult::Failure(_) - | S3::simple_request::S3StatResult::NotFound(_) => 0, - S3::simple_request::S3StatResult::Success(stat) => stat.size, - }; - let mut pair = HeaderResponseSizePair { this: self, size }; - resp.run_corked_with_type( - |p| Self::do_render_head_response_after_s3_size_resolved(p), - &raw mut pair, - ); - } - } - fn do_render_head_response( pair: *mut HeaderResponsePair<'_, ThisServer, SSL_ENABLED, DEBUG_MODE, MUX>, ) { @@ -2708,36 +2691,17 @@ where } Body::Value::Blob(blob) => { - if shim::blob_is_s3(blob) { - // we need to read the size asynchronously - // in this case should always be a redirect so should not hit this path, but in case we change it in the future lets handle it - // Ref for the S3 stat; adopted and released by - // `on_s3_size_resolved_thunk`. - this.ref_(); - - let crate::webcore::blob::store::Data::S3(s3) = - &blob.store.get().as_ref().unwrap().data - else { - unreachable!() - }; - let credentials = s3.get_credentials(); - let path = s3.path(); - let _ = S3::client::stat( - credentials, - this.script_context(), - path, - Self::on_s3_size_resolved_thunk, - this.as_ctx_ptr().cast::(), - s3.request_payer, - ); // TODO: properly propagate exception upwards - return; - } // Size the blob *before* `render_metadata()`: it re-fetches the // Response from `response_weakref`, so no borrow of the Response // (here, `blob`) may still be live across it. Nothing is written // to the socket in between, so the wire output is unchanged. - blob.resolve_size(); - let blob_size = blob.size.get(); + let blob_size = if shim::blob_is_s3(blob) { + // Match the body GET sends (`render_bytes`). `resolve_size()` would set the blob's size to 0. + blob.shared_view().len() as BlobSizeType + } else { + blob.resolve_size(); + blob.size.get() + }; this.render_metadata(); if blob_size == crate::webcore::blob::MAX_SIZE { @@ -3140,7 +3104,7 @@ where let bb = DevErrorPage { message: b"Stream error during server-side rendering", - cwd: bun_resolver::fs::FileSystem::get().top_level_dir, + cwd: bun_resolver::fs::FileSystem::get().top_level_dir(), exceptions: &exception_list, log: None, } @@ -3905,13 +3869,19 @@ where } if let Some(mut cookies) = self.cookies.replace(None) { - let global_this = self.server().global_this(); + let server = self.server(); + let global_this = server.global_this(); let resp = self.resp.get().expect("infallible: resp bound"); - let r = cookies.write(global_this, uws::ResponseKind::of(resp), resp.as_ptr()); + // The status line is already written, so report the error and send the response + // without that Set-Cookie header. + if let Err(err) = cookies.write(global_this, uws::ResponseKind::of(resp), resp.as_ptr()) + { + server + .vm() + .as_mut() + .run_error_handler(global_this.take_exception(err), None); + } // `cookies` drops here, releasing the ref taken in `set_cookies`. - if r.is_err() { - return; - } // TODO: properly propagate exception upwards } let blob = self.blob.get(); @@ -4675,11 +4645,6 @@ struct StreamPair<'a, ThisServer, const SSL: bool, const DBG: bool, const MUX: b pub stream: WebCore::ReadableStream, } -struct HeaderResponseSizePair<'a, ThisServer, const SSL: bool, const DBG: bool, const MUX: bool> { - pub this: &'a RequestContext, - pub(crate) size: usize, -} - struct HeaderResponsePair<'a, ThisServer, const SSL: bool, const DBG: bool, const MUX: bool> { pub this: &'a RequestContext, /// The JS wrapper's cell pointer, not a `&mut Response`: the receiving diff --git a/src/runtime/server/ServerWebSocket.rs b/src/runtime/server/ServerWebSocket.rs index 0afb96f12f0c..6f7ce2c1c9a7 100644 --- a/src/runtime/server/ServerWebSocket.rs +++ b/src/runtime/server/ServerWebSocket.rs @@ -739,9 +739,33 @@ impl ServerWebSocket { Opcode::Binary => self.binary_to_js(global_object, message), _ => unreachable!(), }; - // Converting the payload threw (or the VM is terminating): there is - // no message to deliver; the handler's landing frame folds it. - let data = data?; + let data = match data { + Ok(data) => data, + Err(e) => { + // Copying the message for the handler failed. Close the connection instead of + // silently dropping a message. + let err_value = global_object.take_error(e); + if err_value.is_termination_exception() { + return Err(e); + } + // Without an `error` handler, only the 1011 close reports the failure. An uncaught + // exception would exit the process. + let reported = if on_error.is_empty_or_undefined_or_null() { + Ok(()) + } else { + self.handler().run_error_callback( + on_error, + global_object, + self.js_value().unwrap_or(JSValue::UNDEFINED), + err_value, + ) + }; + if !self.is_closed() { + ws.fail_out_of_memory(); + } + return reported; + } + }; let arguments = [self.js_value().unwrap_or(JSValue::UNDEFINED), data]; let result = match call_corked( @@ -824,7 +848,12 @@ impl ServerWebSocket { ArrayBuffer::create::<{ JSType::ArrayBuffer }>(global_this, data) } BinaryType::Blob => { - let blob = Blob::new(Blob::init(data.to_vec(), global_this)); + let mut bytes = Vec::new(); + if bytes.try_reserve_exact(data.len()).is_err() { + return Err(bun_jsc::JsError::OutOfMemory); + } + bytes.extend_from_slice(data); + let blob = Blob::new(Blob::init(bytes, global_this)); // SAFETY: `blob` is the live allocation `Blob::new` just made. `BlobExt::to_js` // reports the payload size to the GC, which the by-value `JsClass::to_js` skips. Ok(unsafe { BlobExt::to_js(&*blob, global_this) }) @@ -1549,15 +1578,25 @@ impl WebSocketHandler for WsPackageSocket { unsafe { &*this }.0.attach(ws); } #[inline(always)] - unsafe fn on_message(this: *mut Self, _ws: AnyWebSocket, message: &[u8], opcode: Opcode) { + unsafe fn on_message(this: *mut Self, ws: AnyWebSocket, message: &[u8], opcode: Opcode) { // SAFETY: per trait contract. unsafe { Self::dispatch(this, |this| { - this.call(this.0.handler().on_message, |global| { - Ok([ - ArrayBuffer::create_buffer(global, message)?, - JSValue::js_boolean(opcode == Opcode::Binary), - ]) + let global = this.0.handler().global_object(); + let buffer = match ArrayBuffer::create_buffer(global, message) { + Ok(buffer) => buffer, + Err(e) => { + // Same as `ServerWebSocket::on_message`. `ws.ts` turns the close into the + // socket's 'error' event. + if global.take_error(e).is_termination_exception() { + return Err(e); + } + ws.fail_out_of_memory(); + return Ok(()); + } + }; + this.call(this.0.handler().on_message, |_| { + Ok([buffer, JSValue::js_boolean(opcode == Opcode::Binary)]) }) }) }; diff --git a/src/runtime/server/StaticRoute.rs b/src/runtime/server/StaticRoute.rs index cae0a042df00..70fcc0a2b8bf 100644 --- a/src/runtime/server/StaticRoute.rs +++ b/src/runtime/server/StaticRoute.rs @@ -43,7 +43,6 @@ pub(crate) struct InitFromBytesOptions<'a> { pub(crate) server: Option, pub(crate) mime_type: Option<&'a MimeType>, pub(crate) status_code: u16, - pub(crate) headers: Option<&'a FetchHeaders>, } impl<'a> Default for InitFromBytesOptions<'a> { @@ -52,7 +51,6 @@ impl<'a> Default for InitFromBytesOptions<'a> { server: None, mime_type: None, status_code: 200, - headers: None, } } } @@ -82,16 +80,11 @@ impl StaticRoute { blob: AnyBlob, options: InitFromBytesOptions<'_>, ) -> RefPtr { - let mut headers = bun_http_jsc::headers_jsc::from_fetch_headers( - options.headers, - any_blob_content_type(&blob), - ); - if headers.get_content_type().is_none() { - if let Some(mime_type) = options.mime_type { - headers.append(b"Content-Type", &mime_type.value); - } else if blob.has_content_type_from_user() { - headers.append(b"Content-Type", blob.content_type()); - } + let mut headers = Headers::default(); + if let Some(content_type) = any_blob_content_type(&blob) { + headers.append(b"Content-Type", content_type); + } else if let Some(mime_type) = options.mime_type { + headers.append(b"Content-Type", &mime_type.value); } // Generate ETag if not already present @@ -109,6 +102,32 @@ impl StaticRoute { )) } + /// Ownership of `blob` is transferred to this function. + pub(crate) fn init_from_any_blob_with_headers( + global_this: &JSGlobalObject, + blob: AnyBlob, + fetch_headers: Option<&FetchHeaders>, + status_code: u16, + ) -> JsResult> { + let mut headers: Headers = bun_http_jsc::headers_jsc::from_fetch_headers( + global_this, + fetch_headers, + any_blob_content_type(&blob), + )?; + + // Generate ETag if not already present + if headers.get(b"etag").is_none() { + if !blob.slice().is_empty() { + append_etag(blob.slice(), &mut headers); + } + } + + let mut route = StaticRoute::new(blob, headers, None, status_code); + // Match the body `on_get` sends. `blob.size()` returns the unknown-size sentinel for an S3 store. + route.cached_blob_size = route.blob.slice().len() as u64; + Ok(RefPtr::new(route)) + } + /// Create a static route to be used on a single response, freeing the bytes once sent. pub(crate) fn send_blob_then_deinit( resp: AnyResponse, @@ -223,24 +242,12 @@ impl StaticRoute { )?; } - let mut headers: Headers = bun_http_jsc::headers_jsc::from_fetch_headers( - response.get_init_headers(), - any_blob_content_type(&blob), - ); - - // Generate ETag if not already present - if headers.get(b"etag").is_none() { - if !blob.slice().is_empty() { - append_etag(blob.slice(), &mut headers); - } - } - - return Ok(Some(RefPtr::new(StaticRoute::new( + return Ok(Some(Self::init_from_any_blob_with_headers( + global_this, blob, - headers, - None, + response.get_init_headers(), response.status_code(), - )))); + )?)); } Ok(None) diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index fe7b6674bcd0..90c6f0f3b2f5 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -307,8 +307,7 @@ pub(crate) struct NewServer { pub(crate) dev_server: Option>, - /// Route → index in RouteList.cpp. User routes may be applied multiple - /// times due to SNI, so we have to store them. + /// Route → index in RouteList.cpp. uWS route handlers keep pointers into this Vec. pub(crate) user_routes: Vec>, /// Raw shadow of the wrapper's `m_onClientError` WriteBarrier slot. @@ -337,6 +336,12 @@ impl Drop for NewServer { fn drop(&mut self) { // Before `config`, which owns the arena the dev server's transpilers and views live in. drop(self.dev_server.take()); + if let Some(plugins) = &self.plugins { + // SAFETY: the intrusive refcount permits mutation through any owner. Each server has its + // own `ServePlugins`, so no other `&mut` to it is live while the server is being freed. + unsafe { &mut *plugins.as_ptr() } + .forget_server(AnyServer::from(std::ptr::from_ref::(self))); + } // The remaining owned fields (config, base_url, h3_alt_svc, // user_routes, all_closed_promise) drop automatically. } @@ -2833,8 +2838,7 @@ impl NewServer { // reshaped for borrowck (PORTING.md §Forbidden — aliased // `&mut`). No long-lived `&mut Self` is held across re-derives from // `this`; each use site reborrows fresh and the borrow ends before the - // next derive. The serverName / SNI loop extracts raw `(ptr, len)` so - // no `&self.config` outlives the per-domain `set_routes()` call. + // next derive. // // SAFETY (applies to every `&mut *this` below): `this` was produced by // `init()` and is live for this call; only one reference derived from @@ -2932,20 +2936,13 @@ impl NewServer { route_list_value = unsafe { (*this).set_routes() }; // add serverName to the SSL context using the default ssl options - // extract raw (ptr, len) so no `&self.config` borrow - // outlives the `set_routes()` call below. set_routes() does not - // touch `config.ssl_config`, so the bytes remain valid. - let server_name_raw = this_ref + let server_name = this_ref .config .ssl_config .as_ref() .and_then(|c| c.server_name_cstr()) - .filter(|n| !n.to_bytes().is_empty()) - .map(|n| (n.as_ptr(), n.to_bytes().len())); - if let Some((name_ptr, name_len)) = server_name_raw { - // SAFETY: name_ptr/name_len were just extracted from the live - // `config.ssl_config.server_name` CString; valid + NUL-terminated. - let server_name = unsafe { bun_core::ffi::cstr(name_ptr) }; + .filter(|n| !n.to_bytes().is_empty()); + if let Some(server_name) = server_name { // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. if bun_opaque::opaque_deref_mut(app) .add_server_name_with_options(server_name, &ssl_options, false) @@ -2966,27 +2963,9 @@ impl NewServer { Self::deinit(this); return JSValue::ZERO; } - - // SAFETY: server_name is a CStr; ZStr::from_raw upholds the NUL invariant. - let z = unsafe { bun_core::ZStr::from_raw(name_ptr.cast(), name_len) }; - // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. - bun_opaque::opaque_deref_mut(app).domain(z); - if throw_ssl_error_if_necessary(global) { - // SAFETY: caller contract — `this` is the live boxed server from `init()`. - Self::deinit(this); - return JSValue::ZERO; - } - - // Ensure routes are set for that domain name. - // SAFETY: `this` is the live boxed server from `init()`; no - // other borrow is live — `&mut` scoped to this call. - let _ = unsafe { (*this).set_routes() }; } // SNI: per-hostname contexts - // iterate by index and reborrow `&*this` per iteration so - // the `set_routes()` `&mut` at the bottom of the loop body never - // overlaps an outstanding `&self.config.sni` borrow. let sni_len = this_ref.config.sni.as_ref().map_or(0, |s| s.slice().len()); for i in 0..sni_len { let (name_ptr, name_len, sni_opts) = { @@ -3004,8 +2983,7 @@ impl NewServer { sni_ssl_config.as_usockets(), ) }; - // SAFETY: name_ptr/name_len point into config.sni[i].server_name; - // set_routes() does not mutate config.sni so the bytes are valid. + // SAFETY: name_ptr/name_len point into config.sni[i].server_name. let sni_name = unsafe { bun_core::ffi::cstr(name_ptr) }; // SAFETY: sni_name is a CStr; NUL invariant holds for ZStr. let z = unsafe { bun_core::ZStr::from_raw(name_ptr.cast(), name_len) }; @@ -3044,16 +3022,11 @@ impl NewServer { Self::deinit(this); return JSValue::ZERO; } - // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. - bun_opaque::opaque_deref_mut(app).domain(z); if throw_ssl_error_if_necessary(global) { // SAFETY: caller contract — `this` is the live boxed server from `init()`. Self::deinit(this); return JSValue::ZERO; } - // SAFETY: `this` is the live boxed server from `init()`; no - // other borrow is live — `&mut` scoped to this call. - let _ = unsafe { (*this).set_routes() }; } } else { app = match uws_sys::NewApp::::create(&uws_sys::BunSocketContextOptions::default()) @@ -4224,7 +4197,7 @@ impl AnyServer { /// - `Pending` if `callback` was stored. It will call `on_plugins_resolved` or `on_plugins_rejected` later. pub(crate) fn get_or_load_plugins( &self, - callback: ServePluginsCallback<'_>, + callback: ServePluginsCallback, ) -> GetOrStartLoadResult<'_> { any_server_dispatch_mut!(self, |s| s.get_or_load_plugins(callback)) } diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index f62914cfe935..81a6905cdffb 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -5,7 +5,6 @@ use std::io::Write as _; use crate::api::js_bundler::PluginJscExt as _; use crate::api::{SocketAddress, js_bundler as JSBundler}; -use crate::bake::dev_server::DevServer; use crate::bake::router as FrameworkRouter; use crate::bake::{self as bake}; use crate::node::types::PathLikeExt as _; @@ -609,7 +608,7 @@ impl AnyRoute { let cwd: &[u8] = if StandaloneModuleGraph::is_bun_standalone_file_path(path_slice) { StandaloneModuleGraph::BASE_PUBLIC_PATH_WITH_DEFAULT_SUFFIX.as_bytes() } else { - FileSystem::instance().top_level_dir + FileSystem::instance().top_level_dir() }; let abs_path = FileSystem::instance().abs(&[path_slice]); @@ -731,23 +730,24 @@ impl AnyRoute { } return Ok(AnyRoute::File(FileRoute::init_from_blob( + global, blob, &super::file_route::InitOptions { server: None, status_code: 200, headers, }, - ))); + )?)); } - Ok(AnyRoute::Static(StaticRoute::init_from_any_blob( - AnyBlob::Blob(blob), - super::static_route::InitFromBytesOptions { - server: None, + Ok(AnyRoute::Static( + StaticRoute::init_from_any_blob_with_headers( + global, + AnyBlob::Blob(blob), headers, - ..Default::default() - }, - ))) + 200, + )?, + )) } pub(crate) fn html_route_from_js( @@ -905,13 +905,8 @@ pub(crate) enum ServePluginsState { promise: jsc::JSPromiseStrong, /// Each holds a ref, released once the route is told the outcome. html_bundle_routes: Vec>, - // LIFETIMES.tsv classifies this BORROW_PARAM (`Option<&'a DevServer>`), - // but `ServePlugins` is a refcounted heap object handed across FFI as - // a raw promise-context pointer with dynamic lifetime, so a borrowed - // `&'a DevServer` cannot be expressed here. Back-reference invariant: - // the DevServer outlives the pending plugin load (see the SAFETY - // comments at the deref sites in `on_plugins_resolved`/`_rejected`). - dev_server: Option>, + /// Server whose DevServer is waiting for the plugins to load. + dev_server: Option, }, Loaded(Box), /// Error information is not stored as it is already reported. @@ -926,9 +921,10 @@ pub(crate) enum GetOrStartLoadResult<'a> { } #[derive(Clone, Copy)] -pub(crate) enum ServePluginsCallback<'a> { +pub(crate) enum ServePluginsCallback { HtmlBundleRoute(bun_ptr::ThisPtr), - DevServer(&'a DevServer), + /// The calling server's DevServer. + DevServer, } impl ServePlugins { @@ -942,7 +938,8 @@ impl ServePlugins { pub(crate) fn get_or_start_load( &mut self, global: &JSGlobalObject, - cb: ServePluginsCallback<'_>, + cb: ServePluginsCallback, + server: super::AnyServer, ) -> JsResult> { loop { match &mut self.state { @@ -960,13 +957,10 @@ impl ServePlugins { ServePluginsCallback::HtmlBundleRoute(route) => { html_bundle_routes.push(RefPtr::from_this(route)); } - ServePluginsCallback::DevServer(server) => { - debug_assert!( - dev_server.is_none() - || dev_server.map(|p| p.as_ptr().cast_const()) - == Some(std::ptr::from_ref(server)) - ); // one dev server per server - *dev_server = Some(NonNull::from(server)); + ServePluginsCallback::DevServer => { + // one dev server per server + debug_assert!(dev_server.is_none_or(|s| s == server)); + *dev_server = Some(server); } } return Ok(GetOrStartLoadResult::Pending); @@ -993,7 +987,7 @@ impl ServePlugins { let bunfig_path: &[u8] = &global.bun_vm().transpiler.options.bunfig_path; // With no bunfig.toml, only `bake.config.ts` has plugins: their root is the project. let bunfig_folder: &[u8] = if bunfig_path.is_empty() { - FileSystem::instance().top_level_dir + FileSystem::instance().top_level_dir() } else { bun_paths::resolve_path::dirname::(bunfig_path) }; @@ -1114,13 +1108,12 @@ impl ServePlugins { Some(NonNull::from(plugin_ref)), )); } - if let Some(mut server) = dev_server { - // SAFETY: dev_server outlives plugin load (stored as a back-reference - // by `get_or_start_load`; the owning Box is held by the - // server instance, which itself holds a counted ref on `self`). - bun_core::handle_oom(unsafe { server.as_mut() }.on_plugins_resolved(Some( - std::ptr::from_ref::(plugin_ref).cast_mut(), - ))); + if let Some(server) = dev_server { + if let Some(dev) = server.dev_server_mut() { + bun_core::handle_oom(dev.on_plugins_resolved(Some( + std::ptr::from_ref::(plugin_ref).cast_mut(), + ))); + } } } @@ -1141,14 +1134,24 @@ impl ServePlugins { for route in html_bundle_routes { bun_core::handle_oom(route.on_plugins_rejected()); } - if let Some(mut server) = dev_server { - // SAFETY: dev_server outlives plugin load - bun_core::handle_oom(unsafe { server.as_mut() }.on_plugins_rejected()); + if let Some(server) = dev_server { + if let Some(dev) = server.dev_server_mut() { + bun_core::handle_oom(dev.on_plugins_rejected()); + } } Output::err_generic("Failed to load plugins for Bun.serve:", ()); global.bun_vm().as_mut().run_error_handler(err, None); } + + /// Clears the pending `server` before it's freed, so a load that settles later can't call into it. + pub(crate) fn forget_server(&mut self, server: super::AnyServer) { + if let ServePluginsState::Pending { dev_server, .. } = &mut self.state { + if *dev_server == Some(server) { + *dev_server = None; + } + } + } } impl Drop for ServePlugins { @@ -1420,16 +1423,17 @@ where /// - .pending if `callback` was stored. It will call `onPluginsResolved` or `onPluginsRejected` later. pub(crate) fn get_or_load_plugins( &mut self, - callback: ServePluginsCallback<'_>, + callback: ServePluginsCallback, ) -> GetOrStartLoadResult<'_> { if let Some(p) = &self.plugins { // Keep `*p` alive across re-entrant JS in `load_and_resolve_plugins`. let p = p.clone(); let global = self.global(); + let server = self.as_any_server(); // SAFETY: intrusive refcount permits mutation through any owner. No // other `&mut ServePlugins` is live on this (single-threaded) JS // thread for the call's duration. - return match unsafe { &mut *p.as_ptr() }.get_or_start_load(&global, callback) { + return match unsafe { &mut *p.as_ptr() }.get_or_start_load(&global, callback, server) { Ok(r) => r, Err(JsError::Thrown | JsError::Terminated) => { panic!("unhandled exception from ServePlugins.getStartOrLoad") @@ -2127,22 +2131,14 @@ where if let Some(headers_) = opts.fast_get(ctx, jsc::BuiltinName::Headers)? { if let Some(headers__) = FetchHeaders::cast_(headers_, ctx.vm()) { - // NOTE: `cast_` returns the `FetchHeaders*` held by the - // JS `Headers` wrapper (`JSFetchHeaders`'s internal - // `Ref`) without bumping the refcount — - // the FFI surface has `WebCore__FetchHeaders__deref` but - // no `ref()`, so a +1 cannot be taken here. Adopting - // hands that wrapper-held ref to the constructed - // `Request` (via `Request::init2` below): the eventual - // single deref happens when the Request's finalizer - // drops its `headers` field (`HeadersRef::Drop`, - // Response.rs), pairing with the wrapper's +1. - // SAFETY: `headers__` is live (rooted by `headers_`), - // and ownership of one ref transfers as described above. - headers = Some(unsafe { HeadersRef::adopt(headers__) }); - } else if let Some(headers__) = FetchHeaders::create_from_js(ctx, headers_)? { - // SAFETY: create_from_js returns a +1 ref. - headers = Some(unsafe { HeadersRef::adopt(headers__) }); + // Copy the headers like `new Request(url, { headers })` does. + // S008: `FetchHeaders` is an opaque ZST FFI handle — safe deref. + headers = bun_opaque::opaque_deref_mut(headers__.as_ptr()) + .clone_this(ctx)? + // SAFETY: `clone_this` returns a +1 ref. + .map(|p| unsafe { HeadersRef::adopt(p) }); + } else { + headers = HeadersRef::create_from_js(ctx, headers_)?; } } diff --git a/src/runtime/shell/Builtin.rs b/src/runtime/shell/Builtin.rs index 24e6dfb90ca4..c9bc0b24df28 100644 --- a/src/runtime/shell/Builtin.rs +++ b/src/runtime/shell/Builtin.rs @@ -350,11 +350,10 @@ impl BuiltinIO { Ok(buf.len()) } BuiltinIO::ArrayBuf { buf: arraybuf, i } => { - // `len = buf.len` stays usize so `i + len > byte_len` is - // computed at usize width and cannot overflow; only the - // stored cursor is u32. + // JS can resize the buffer between chunks. + let dst_all = arraybuf.live_slice_mut(); let idx = *i as usize; - let total = arraybuf.byte_len; + let total = dst_all.len(); if idx >= total { return Err(bun_sys::Error::from_code( bun_sys::E::ENOSPC, @@ -362,7 +361,7 @@ impl BuiltinIO { )); } let write_len = (total - idx).min(buf.len()); - let dst = &mut arraybuf.slice_mut()[idx..idx + write_len]; + let dst = &mut dst_all[idx..idx + write_len]; dst.copy_from_slice(&buf[..write_len]); *i = i.saturating_add(write_len as u32); Ok(write_len) @@ -603,34 +602,7 @@ impl Builtin { ), )); } - Ok(f) => { - #[cfg(windows)] - { - use bun_sys::FdExt as _; - match f.make_lib_uv_owned_for_syscall( - bun_sys::Tag::open, - bun_sys::ErrorCase::CloseOnFail, - ) { - Err(e) => { - let sys = e.to_shell_system_error(); - return Some(Self::cmd_write_failing_error( - interp, - cmd, - format_args!( - "bun: {}: {}", - bstr::BStr::new(sys.message.byte_slice()), - bstr::BStr::new(path.as_bytes()), - ), - )); - } - Ok(f2) => f2, - } - } - #[cfg(not(windows))] - { - f - } - } + Ok(f) => f, } }; diff --git a/src/runtime/shell/IOReader.rs b/src/runtime/shell/IOReader.rs index 2ce9560ad8eb..b0fd24df6bd0 100644 --- a/src/runtime/shell/IOReader.rs +++ b/src/runtime/shell/IOReader.rs @@ -42,14 +42,14 @@ pub(crate) type ReaderImpl = bun_io::BufferedReader; struct State { fd: Fd, buf: Vec, + /// Listeners for the in-flight read (see `take_readers`). readers: Readers, - /// The raw `sys::Error`. `SystemError` is not `Clone` - /// in the Rust port yet, so we keep the source error to re-derive a fresh - /// `SystemError` per callee in `on_reader_done_cb`. - raw_err: Option, evtloop: EventLoopHandle, #[cfg(windows)] is_reading: bool, + /// True after EOF. The Windows reader closes its source and the fd at EOF, so later listeners can't read. + #[cfg(windows)] + reached_eof: bool, /// Weak self-ref so `keepalive()` can bump the strong count from `&self` /// without unsafe Arc-pointer reconstruction. Set via `Arc::new_cyclic` in /// `init()` (the sole constructor). @@ -90,12 +90,11 @@ impl IOReader { // held by the bun_io read loop never overlaps a `&mut State` derived in a // vtable callback (see struct doc comment). // - // MUST NOT be invoked from within a `BufferedReaderParent` vtable - // callback (`on_read_chunk_cb`/`on_reader_done_cb`/`on_reader_error`): - // the read loop already holds a live `&mut ReaderImpl` on its stack - // while the callback runs (PipeReader.rs aliasing contract), so - // re-deriving here would create two simultaneous `&mut` to the same - // BufferedReader = Stacked-Borrows UB. + // Don't call this from the callback bodies below. `WindowsBufferedReader` + // (and `PosixBufferedReader::start()` when registration fails synchronously) + // invokes them while a `&mut ReaderImpl` is live. The POSIX poll dispatch + // doesn't borrow the reader (raw pointer, copied vtable), so a command + // started from a done/error notification can `start()` a new read. unsafe { &mut *self.reader.get() } } @@ -137,10 +136,11 @@ impl IOReader { fd, buf: Vec::new(), readers: Readers::new(), - raw_err: None, evtloop, #[cfg(windows)] is_reading: false, + #[cfg(windows)] + reached_eof: false, self_weak: std::sync::Weak::clone(w), read_guards: Vec::new(), interp: None, @@ -208,12 +208,9 @@ impl IOReader { #[cfg(not(windows))] { let r = self.reader(); - let need_start = match &r.handle { - bun_io::pipes::PollOrFd::Closed => true, - bun_io::pipes::PollOrFd::Poll(p) => !p.is_registered(), - bun_io::pipes::PollOrFd::Fd(_) => true, - }; - if need_start { + // After a read finishes, the one-shot poll has fired but is still registered, + // so checking `is_registered()` would skip the read a later listener needs. + if !r.has_pending_read() { let fd = self.state().fd; if let Err(e) = r.start(fd, true) { self.on_reader_error(&e); @@ -224,6 +221,9 @@ impl IOReader { #[cfg(windows)] { let s = self.state(); + if s.reached_eof { + return self.finish_listeners_at_eof(); + } if s.is_reading { return Yield::suspended(); } @@ -280,21 +280,9 @@ impl IOReader { let should_continue = has_more != bun_io::ReadState::Eof; if should_continue && !self.state().readers.is_empty() { self.set_reading(true); - // NOTE: no explicit re-arm (`registerPoll()` on posix / - // `startWithCurrentPipe()` on windows) here: that would re-derive - // a second `&mut ReaderImpl` while the bun_io read loop still - // holds one on its stack (PipeReader.rs aliasing contract) — - // Stacked-Borrows UB. - // On posix the re-arm is redundant: the read loop re-registers - // itself after the callback returns based on the `bool` we return - // (PipeReader.rs:731/755/846/920/986). On Windows the re-arm is - // also handled by the caller (`on_file_read`'s defer block / - // `uv_read_start` for streams) — but `startWithCurrentPipe()` had - // a SECOND load-bearing side effect: `buffer().clearRetainingCapacity()`, - // which keeps `WindowsBufferedReader._buffer` bounded between - // chunks. That clear is now performed by - // `WindowsBufferedReader::on_read` after the streaming chunk is - // consumed, so we still do nothing here. + // Don't re-arm here (Windows doesn't allow it, see `reader()`). The + // caller re-arms after we return (on posix, based on the `bool` below), + // and `WindowsBufferedReader::on_read` clears the chunk buffer. } should_continue } @@ -304,12 +292,8 @@ impl IOReader { // alive across the loop. let _keepalive = self.keepalive(); self.set_reading(false); - let s = self.state(); - s.raw_err = Some(err.clone()); - // NOTE: reshaped for borrowck — copy out before dispatching. - let readers: Vec = s.readers.clone(); - let interp = s.interp; - for r in readers { + let interp = self.state().interp; + for r in self.take_readers() { // Re-derive a fresh SystemError per callee (see // IOWriter.on_error note). let ee = err.to_shell_system_error(); @@ -324,19 +308,40 @@ impl IOReader { // Hold a strong ref across the body. let _keepalive = self.keepalive(); self.set_reading(false); - let s = self.state(); - let readers: Vec = s.readers.clone(); - let interp = s.interp; - // `SystemError` isn't `Clone` yet, so we keep the source `sys::Error` - // (which IS `Clone`) and re-derive a fresh `SystemError` per callee — - // same approach as `on_reader_error`. - let raw_err = s.raw_err.clone(); + // Set this before notifying listeners, because a notification can start the next `cat`. + #[cfg(windows)] + { + self.state().reached_eof = true; + } + let interp = self.state().interp; + for r in self.take_readers() { + self.run_yield(dispatch_reader_done(r, None, interp)); + } + } + + /// Reports EOF to listeners that registered after it. Returns the last continuation + /// instead of running it, so consecutive `cat`s don't nest `Yield::run` calls. + #[cfg(windows)] + fn finish_listeners_at_eof(&self) -> Yield { + let _keepalive = self.keepalive(); + let interp = self.state().interp; + let mut readers = self.take_readers(); + let last = readers.pop(); for r in readers { - let ee = raw_err.as_ref().map(|e| e.to_shell_system_error()); - self.run_yield(dispatch_reader_done(r, ee, interp)); + self.run_yield(dispatch_reader_done(r, None, interp)); + } + match last { + Some(r) => dispatch_reader_done(r, None, interp), + None => Yield::suspended(), } } + /// Removes and returns the finished read's listeners. The next read would notify a + /// stale entry again, and by then its `NodeId` may be recycled. + fn take_readers(&self) -> Readers { + core::mem::take(&mut self.state().readers) + } + fn run_yield(&self, y: Yield) { let Some(interp) = self.state().interp else { debug_assert!( diff --git a/src/runtime/shell/ParsedShellScript.rs b/src/runtime/shell/ParsedShellScript.rs index 14f6dbcdefda..bc37150b5739 100644 --- a/src/runtime/shell/ParsedShellScript.rs +++ b/src/runtime/shell/ParsedShellScript.rs @@ -107,6 +107,7 @@ impl ParsedShellScript { return Err(global.throw(format_args!("$`...`.cwd(): expected a string argument"))); }; let str = BunString::from_js(str_js, global)?; + throw_if_nul(global, "cwd", &str.to_utf8())?; self.cwd.set(Some(str)); Ok(JSValue::UNDEFINED) } @@ -155,11 +156,11 @@ impl ParsedShellScript { continue; } - let keyslice = key.to_owned_slice(); + let keyslice = env_bytes(global, "env key", &key)?; // errdefer free(keyslice) — Drop on early-return handles this. - let slice = value.to_bun_string(global)?.to_owned_slice(); - let keyref = EnvStr::init_ref_counted(keyslice.into_boxed_slice()); - let valueref = EnvStr::init_ref_counted(slice.into_boxed_slice()); + let slice = env_bytes(global, "env value", &value.to_bun_string(global)?)?; + let keyref = EnvStr::init_ref_counted(keyslice); + let valueref = EnvStr::init_ref_counted(slice); env.insert(keyref, valueref); keyref.deref(); @@ -171,6 +172,37 @@ impl ParsedShellScript { } } +/// Throws if `bytes` contains a NUL. The OS reads the cwd and env as C strings, which end at the first NUL. +fn throw_if_nul(global: &JSGlobalObject, name: &str, bytes: &[u8]) -> JsResult<()> { + if bun_core::strings::contains_char(bytes, 0) { + return Err(global + .err( + bun_jsc::ErrorCode::INVALID_ARG_VALUE, + format_args!( + "The shell {name} must be a string without null bytes. Received {}", + bun_core::fmt::quote(bytes) + ), + ) + .throw()); + } + Ok(()) +} + +/// Returns an env name's or value's UTF-8 bytes. Throws like [`throw_if_nul`]. +fn env_bytes(global: &JSGlobalObject, name: &str, str: &BunString) -> JsResult> { + // Fast path, because this runs for every variable of every command. The scan stops at + // any byte below 0x20 or above 0x7F, so `None` proves the bytes are ASCII without a NUL. + if !str.is_utf16() { + let bytes = str.latin1(); + if bun_core::strings::index_of_newline_or_non_ascii(bytes, 0).is_none() { + return Ok(Box::from(bytes)); + } + } + let bytes = str.to_owned_slice(); + throw_if_nul(global, name, &bytes)?; + Ok(bytes.into_boxed_slice()) +} + /// `jsc.MarkedArgumentBuffer.wrap` generates a host-fn shim that allocates a /// `MarkedArgumentBuffer` on the C++ stack and forwards to the impl. pub(crate) const CREATE_PARSED_SHELL_SCRIPT: bun_jsc::JSHostFnZig = diff --git a/src/runtime/shell/builtin/cp.rs b/src/runtime/shell/builtin/cp.rs index 2c2c8b25644e..0b54d5b2a248 100644 --- a/src/runtime/shell/builtin/cp.rs +++ b/src/runtime/shell/builtin/cp.rs @@ -4,7 +4,8 @@ use crate::node::PathLike; use crate::shell::builtin::{Builtin, BuiltinState, IoKind, Kind}; use crate::shell::interpreter::{ EventLoopHandle, FlagParser, Interpreter, NodeId, OutputSrc, OutputTask, OutputTaskVTable, - ParseFlagResult, ShellTask, parse_flags, unsupported_flag, + ParseFlagResult, ShellTask, parse_flags, shell_join_path, shell_resolve_operand, + unsupported_flag, }; use crate::shell::io_writer::{ChildPtr, WriterTag}; use crate::shell::yield_::Yield; @@ -597,6 +598,98 @@ impl ShellCpTask { } } + fn same_inode(a: &bun_sys::Stat, b: &bun_sys::Stat) -> bool { + // A filesystem without inode numbers reports `st_ino == 0`. + a.st_ino != 0 && a.st_ino == b.st_ino && a.st_dev == b.st_dev + } + + /// Runs the src/dest checks `fs.cp` does up front (node's `checkPaths` and + /// `checkParentPaths`), so copying a directory into itself fails before + /// anything is created. `appended` is the source basename if it was joined + /// onto the target operand. + fn check_paths( + &self, + src: &bun_core::ZStr, + tgt: &bun_core::ZStr, + appended: Option<&[u8]>, + ) -> Option { + use bun_sys::S; + use resolve_path::Platform; + + let custom = |msg: String| Some(ShellErr::Custom(msg.into_bytes().into_boxed_slice())); + let src_shown = bstr::BStr::new(&self.src); + let tgt_shown = || -> Vec { + let mut shown = self.tgt.clone(); + if let Some(basename) = appended { + if !Self::has_trailing_sep(&shown) { + shown.push(bun_paths::SEP); + } + shown.extend_from_slice(basename); + } + shown + }; + + // If lstat fails, let the copy report the error. + let Ok(src_stat) = bun_sys::lstat(src) else { + return None; + }; + // A symlink to a directory is copied as a link, so don't treat it as a directory. + let src_is_dir = S::ISDIR(src_stat.st_mode as _); + if let Ok(tgt_stat) = bun_sys::lstat(tgt) { + if Self::same_inode(&src_stat, &tgt_stat) || src.as_bytes() == tgt.as_bytes() { + return custom(format!( + "{src_shown} and {} are identical (not copied)", + bstr::BStr::new(&tgt_shown()), + )); + } + let tgt_is_dir = S::ISDIR(tgt_stat.st_mode as _); + if src_is_dir && !tgt_is_dir { + return custom(format!( + "cannot overwrite non-directory {} with directory {src_shown}", + bstr::BStr::new(&tgt_shown()), + )); + } + if !src_is_dir && tgt_is_dir { + return custom(format!( + "cannot overwrite directory {} with non-directory {src_shown}", + bstr::BStr::new(&tgt_shown()), + )); + } + } + + if !src_is_dir { + return None; + } + let into_itself = || { + custom(format!( + "cannot copy a directory, '{src_shown}', into itself, '{}'", + bstr::BStr::new(&tgt_shown()), + )) + }; + let (s, t) = (src.as_bytes(), tgt.as_bytes()); + if t.len() > s.len() && t.starts_with(s) && Platform::AUTO.is_separator(t[s.len()]) { + return into_itself(); + } + // A symlink can alias `src`, so also compare each of `tgt`'s ancestors by inode. + let src_parent = bun_paths::dirname(s); + let mut path = bun_paths::path_buffer_pool::get(); + let mut ancestor = t; + while let Some(parent) = bun_paths::dirname(ancestor) { + if Some(parent) == src_parent { + break; + } + path[..parent.len()].copy_from_slice(parent); + path[parent.len()] = 0; + if let Ok(stat) = bun_sys::stat(bun_core::ZStr::from_buf(&path[..], parent.len())) { + if Self::same_inode(&src_stat, &stat) { + return into_itself(); + } + } + ancestor = parent; + } + None + } + /// Resolves src/tgt to absolute paths, classifies them per the three /// POSIX `cp` synopses /// (), then hands off to @@ -605,26 +698,16 @@ impl ShellCpTask { &mut self, poster: &bun_jsc::ConcurrentPoster, ) -> Option { - use resolve_path::{Platform, platform}; - - let mut buf2 = bun_paths::path_buffer_pool::get(); - let mut buf3 = bun_paths::path_buffer_pool::get(); // We have to give an absolute path to our cp implementation for it to // work with cwd. - let src: &bun_core::ZStr = if Platform::AUTO.is_absolute(&self.src) { - // `self.src` is the bare argv bytes (no NUL); re-terminate via - // the thread-local join buffer. - resolve_path::join_z::(&[&self.src]) - } else { - resolve_path::join_z::(&[&self.cwd_path, &self.src]) + let src = match shell_resolve_operand(bun_sys::Tag::copyfile, &self.cwd_path, &self.src) { + Ok(path) => path, + Err(e) => return Some(ShellErr::new_sys(&e)), }; - let mut tgt: &bun_core::ZStr = if Platform::AUTO.is_absolute(&self.tgt) { - resolve_path::join_z_buf::(buf2.as_mut_slice(), &[&self.tgt]) - } else { - resolve_path::join_z_buf::( - buf2.as_mut_slice(), - &[&self.cwd_path, &self.tgt], - ) + let mut tgt = match shell_resolve_operand(bun_sys::Tag::copyfile, &self.cwd_path, &self.tgt) + { + Ok(path) => path, + Err(e) => return Some(ShellErr::new_sys(&e)), }; // Cases: @@ -635,7 +718,7 @@ impl ShellCpTask { // folder -> folder // We need to check dest to see what it is; if it doesn't exist we // need to create it. - let src_is_dir = match Self::is_dir(src) { + let src_is_dir = match Self::is_dir(&src) { Ok(x) => x, Err(e) => return Some(ShellErr::new_sys(&e)), }; @@ -649,18 +732,7 @@ impl ShellCpTask { )); } - if !src_is_dir && src.as_bytes() == tgt.as_bytes() { - return Some(ShellErr::Custom( - format!( - "{0} and {0} are identical (not copied)", - bstr::BStr::new(&self.src) - ) - .into_bytes() - .into_boxed_slice(), - )); - } - - let (tgt_is_dir, tgt_exists) = match Self::is_dir(tgt) { + let (tgt_is_dir, tgt_exists) = match Self::is_dir(&tgt) { Ok(is_dir) => (is_dir, true), Err(e) if e.get_errno() == bun_sys::E::ENOENT => { // If it has a trailing directory separator, it's a directory. @@ -670,6 +742,8 @@ impl ShellCpTask { }; let mut _copying_many = false; + // Set to the source basename when it's joined onto `tgt`. + let mut appended: Option<&[u8]> = None; // The following logic is based on the POSIX spec. if !src_is_dir && !tgt_is_dir && self.operands == 2 { @@ -678,10 +752,11 @@ impl ShellCpTask { // 2nd synopsis: -R source_files... -> target. if tgt_exists { let basename = resolve_path::basename(src.as_bytes()); - tgt = resolve_path::join_z_buf::( - buf3.as_mut_slice(), - &[tgt.as_bytes(), basename], - ); + tgt = match shell_join_path(bun_sys::Tag::copyfile, &[tgt.as_bytes(), basename]) { + Ok(path) => path, + Err(e) => return Some(ShellErr::new_sys(&e)), + }; + appended = Some(basename); } else if self.operands == 2 { // source_dir -> new_target_dir. } else { @@ -709,13 +784,18 @@ impl ShellCpTask { )); } let basename = resolve_path::basename(src.as_bytes()); - tgt = resolve_path::join_z_buf::( - buf3.as_mut_slice(), - &[tgt.as_bytes(), basename], - ); + tgt = match shell_join_path(bun_sys::Tag::copyfile, &[tgt.as_bytes(), basename]) { + Ok(path) => path, + Err(e) => return Some(ShellErr::new_sys(&e)), + }; + appended = Some(basename); _copying_many = true; } + if let Some(err) = self.check_paths(&src, &tgt, appended) { + return Some(err); + } + let args = crate::node::fs::args::Cp::owned( src.as_bytes().to_vec(), tgt.as_bytes().to_vec(), diff --git a/src/runtime/shell/builtin/mkdir.rs b/src/runtime/shell/builtin/mkdir.rs index a50d8b9aca49..1645c8cb492c 100644 --- a/src/runtime/shell/builtin/mkdir.rs +++ b/src/runtime/shell/builtin/mkdir.rs @@ -29,8 +29,8 @@ pub(crate) struct Exec { pub(crate) started: bool, pub(crate) tasks_count: usize, pub(crate) tasks_done: usize, - pub(crate) output_waiting: u16, - pub(crate) output_done: u16, + pub(crate) output_waiting: usize, + pub(crate) output_done: usize, /// Index into `Builtin::args` where filepath args start (storing the /// index keeps the lifetime tied to the Cmd's argv without a /// self-reference). diff --git a/src/runtime/shell/builtin/mv.rs b/src/runtime/shell/builtin/mv.rs index 2d143b92a882..26f082688a1a 100644 --- a/src/runtime/shell/builtin/mv.rs +++ b/src/runtime/shell/builtin/mv.rs @@ -6,7 +6,9 @@ use bun_ptr::BackRef; use crate::shell::ExitCode; use crate::shell::builtin::{Builtin, BuiltinState, IoKind, Kind}; -use crate::shell::interpreter::{Interpreter, NodeId, ShellTask, closefd, shell_openat}; +use crate::shell::interpreter::{ + Interpreter, NodeId, ShellTask, closefd, shell_openat, shell_statat, +}; use crate::shell::io_writer::{ChildPtr, WriterTag}; use crate::shell::yield_::Yield; @@ -486,7 +488,7 @@ impl ShellMvBatchedTask { ) -> Result<(), bun_sys::Error> { match bun_sys::renameat(src_dir, src, dst_dir, dst) { Err(e) if e.get_errno() == bun_sys::E::EXDEV => { - Self::move_across_devices(src_dir, src, dst_dir, dst).map_err(|e| { + Self::move_across_devices(src_dir, src, dst_dir, dst, None).map_err(|e| { if e.path.is_empty() { e.with_path(src.as_bytes()) } else { @@ -499,17 +501,33 @@ impl ShellMvBatchedTask { } /// EXDEV fallback: copy `src` to `dst`, then (only on success) remove `src`. + /// + /// `output` is `None` for `mv`'s operand. Below the operand, it's the `(st_dev, st_ino)` of the + /// directory being copied into. The walk must not enter it, or it would copy its own output. fn move_across_devices( src_dir: bun_sys::Fd, src: &ZStr, dst_dir: bun_sys::Fd, dst: &ZStr, + output: Option<(u64, u64)>, ) -> Result<(), bun_sys::Error> { use bun_sys::{Dir, E, File, O, S, Tag}; let st = bun_sys::lstatat(src_dir, src)?; let mode = st.st_mode as bun_core::Mode; + if let Some((dev, ino)) = output { + // A filesystem without inode numbers reports `st_ino == 0`. + if S::ISDIR(mode) + && st.st_ino != 0 + && st.st_dev as u64 == dev + && st.st_ino as u64 == ino + { + // `rename(2)` fails with EINVAL when a directory is moved into itself on the same mount. + return Err(bun_sys::Error::from_code(E::EINVAL, Tag::rename)); + } + } + // Bind mounts can alias one inode through two vfsmounts (renameat() // still returns EXDEV); treat same-inode as the POSIX rename() no-op. if let Ok(d) = bun_sys::lstatat(dst_dir, dst) { @@ -534,6 +552,10 @@ impl ShellMvBatchedTask { let src_nofollow = if cfg!(windows) { 0 } else { O::NOFOLLOW }; if S::ISDIR(mode) { + // This function recurses once per directory level. + if !bun_core::StackCheck::init().is_safe_to_recurse() { + return Err(bun_sys::Error::from_code(E::ENAMETOOLONG, Tag::rename)); + } let sd = Dir::from_fd(shell_openat( src_dir, src, @@ -546,6 +568,10 @@ impl ShellMvBatchedTask { } let st = sst; let mode = st.st_mode as bun_core::Mode; + let output = match output { + Some(id) => id, + None => Self::output_directory(dst_dir, dst, &st)?, + }; // `| 0o700` so children can be written even when the source mode is read-only; restored via `fchmod` below. if let Err(e) = bun_sys::mkdirat(dst_dir, dst, (mode & 0o7777) | 0o700) { if e.get_errno() != E::EEXIST { @@ -572,7 +598,7 @@ impl ShellMvBatchedTask { nbuf[..name.len()].copy_from_slice(name); nbuf[name.len()] = 0; let name_z = ZStr::from_buf(&nbuf[..], name.len()); - Self::move_across_devices(sd.fd(), name_z, dd.fd(), name_z)?; + Self::move_across_devices(sd.fd(), name_z, dd.fd(), name_z, Some(output))?; } #[cfg(unix)] let _ = bun_sys::fchown(dd.fd(), st.st_uid as _, st.st_gid as _); @@ -622,6 +648,62 @@ impl ShellMvBatchedTask { bun_sys::unlinkat(src_dir, src) } + /// Returns the `(st_dev, st_ino)` of the directory `dst` is created in. Fails with `EINVAL`, + /// like `rename(2)` on the same mount, if that directory is `src` or is inside `src`. + fn output_directory( + dst_dir: bun_sys::Fd, + dst: &ZStr, + src: &bun_sys::Stat, + ) -> Result<(u64, u64), bun_sys::Error> { + use bun_sys::{E, Tag}; + + let is_src = |dir: &bun_sys::Stat| { + src.st_ino != 0 && dir.st_dev == src.st_dev && dir.st_ino == src.st_ino + }; + + let mut path = bun_paths::path_buffer_pool::get(); + let mut len = 0; + let made_in = match bun_paths::dirname(dst.as_bytes()) { + None => bun_sys::fstat(dst_dir)?, + Some(dir) => { + if dir.len() >= bun_paths::MAX_PATH_BYTES { + return Err(bun_sys::Error::from_code(E::ENAMETOOLONG, Tag::rename)); + } + path[..dir.len()].copy_from_slice(dir); + len = dir.len(); + path[len] = 0; + shell_statat(dst_dir, ZStr::from_buf(&path[..], len))? + } + }; + if is_src(&made_in) { + return Err(bun_sys::Error::from_code(E::EINVAL, Tag::rename)); + } + + // Check each parent by appending `..` until the root, where `..` resolves to itself. + let mut below = (made_in.st_dev, made_in.st_ino); + loop { + let up: &[u8] = if len == 0 { b".." } else { b"/.." }; + if len + up.len() >= bun_paths::MAX_PATH_BYTES { + break; + } + path[len..len + up.len()].copy_from_slice(up); + len += up.len(); + path[len] = 0; + // Stop at a parent we can't stat. `move_across_devices` still checks every directory it enters. + let Ok(parent) = shell_statat(dst_dir, ZStr::from_buf(&path[..], len)) else { + break; + }; + if (parent.st_dev, parent.st_ino) == below { + break; + } + if is_src(&parent) { + return Err(bun_sys::Error::from_code(E::EINVAL, Tag::rename)); + } + below = (parent.st_dev, parent.st_ino); + } + Ok((made_in.st_dev as u64, made_in.st_ino as u64)) + } + /// `renameat(cwd, src, target_fd, basename(src))`. A free fn over the /// fields it touches so `src` can borrow `self.sources[_]` while `self.err` /// is written by the caller. @@ -633,20 +715,24 @@ impl ShellMvBatchedTask { buf: &mut PathBuffer, ) -> Result<(), bun_sys::Error> { let base = resolve_path::basename(src.as_bytes()); - let len = - resolve_path::normalize_buf::(base, &mut buf[..]).len(); - if len + 1 >= bun_paths::MAX_PATH_BYTES { - return Err(bun_sys::Error::from_code( + // `normalize_buf_z` doesn't bounds-check `buf`. It can add one byte + // (`""` becomes `"."`), and the NUL needs one more. + let result = if base.len() + 1 >= bun_paths::MAX_PATH_BYTES { + Err(bun_sys::Error::from_code( bun_sys::E::ENAMETOOLONG, bun_sys::Tag::rename, - )); - } - buf[len] = 0; - let path_in_dir = ZStr::from_buf(buf.as_slice(), len); - Self::do_rename(cwd, src, target_fd, path_in_dir).map_err(|e| { + )) + } else { + let path_in_dir = + resolve_path::normalize_buf_z::(base, &mut buf[..]); + Self::do_rename(cwd, src, target_fd, path_in_dir) + }; + result.map_err(|e| { // Surface `target/basename(src)` as the failing path. - let joined = resolve_path::join_z::(&[target, base]); - e.with_path(joined.as_bytes()) + let mut spill = Vec::new(); + let joined = + resolve_path::join_spill::(&mut spill, &[target, base]); + e.with_path(joined) }) } diff --git a/src/runtime/shell/interpreter.rs b/src/runtime/shell/interpreter.rs index bb711e6feed7..04cefc50f128 100644 --- a/src/runtime/shell/interpreter.rs +++ b/src/runtime/shell/interpreter.rs @@ -2342,29 +2342,63 @@ pub(crate) fn shell_dup(fd: Fd) -> bun_sys::Result { } } +/// Joins `parts` into an owned, normalized path. Fails with `ENAMETOOLONG` if the result is +/// too long for the `MAX_PATH_BYTES` buffers that the fs layers copy paths into. +pub(crate) fn shell_join_path( + syscall: bun_sys::Tag, + parts: &[&[u8]], +) -> bun_sys::Result { + let mut spill = Vec::new(); + let joined = + bun_paths::resolve_path::join_spill::(&mut spill, parts); + if joined.len() >= bun_paths::MAX_PATH_BYTES { + return Err(bun_sys::Error::from_code(bun_sys::E::ENAMETOOLONG, syscall).with_path(joined)); + } + Ok(bun_core::ZBox::from_bytes(joined)) +} + +/// Resolves a builtin's path `operand` against the shell's `cwd`. +pub(crate) fn shell_resolve_operand( + syscall: bun_sys::Tag, + cwd: &[u8], + operand: &[u8], +) -> bun_sys::Result { + if bun_paths::Platform::AUTO.is_absolute(operand) { + shell_join_path(syscall, &[operand]) + } else { + shell_join_path(syscall, &[cwd, operand]) + } +} + /// Windows-only: rewrite shell paths so POSIX-absolute `/foo` resolves onto /// `dirfd`'s drive root, `/dev/null` maps to `NUL`, and relative paths are /// joined against `dirfd`'s real path. Returns a NUL-terminated slice that -/// either borrows `buf` or is `to` itself. +/// either borrows `buf` or is `to` itself. Fails with `ENAMETOOLONG` (tagged +/// with `syscall` and `to`) if the rewritten path is too long for `buf`. #[cfg(windows)] fn shell_get_path<'a>( dirfd: Fd, to: &'a bun_core::ZStr, buf: &'a mut bun_paths::PathBuffer, + syscall: bun_sys::Tag, ) -> bun_sys::Result<&'a bun_core::ZStr> { if to.as_bytes() == b"/dev/null" { return Ok(crate::shell::shell_body::WINDOWS_DEV_NULL); } + let name_too_long = + || bun_sys::Error::from_code(bun_sys::E::ENAMETOOLONG, syscall).with_path(to.as_bytes()); if bun_paths::Platform::Posix.is_absolute(to.as_bytes()) { let source_root_len = { let dirpath = bun_sys::get_fd_path(dirfd, buf).map_err(|e| e.with_fd(dirfd))?; bun_paths::resolve_path::windows_filesystem_root(dirpath).len() }; - // `dirpath` already - // occupies `buf[0..]` and the root is its prefix, so no copy is - // needed. Splice `to[1..]` after the root. + // `dirpath` already occupies `buf[0..]` and the root is its prefix, so + // no copy is needed. Splice `to[1..]` after the root. let to_tail = &to.as_bytes()[1..]; let end = source_root_len + to_tail.len(); + if end >= buf.len() { + return Err(name_too_long()); + } buf[source_root_len..end].copy_from_slice(to_tail); buf[end] = 0; return Ok(bun_core::ZStr::from_buf(buf.as_slice(), end)); @@ -2372,16 +2406,23 @@ fn shell_get_path<'a>( if bun_paths::Platform::Windows.is_absolute(to.as_bytes()) { return Ok(to); } - // Relative: resolve dirfd → path, then join. - // Note: a single-buffer join would read `dirpath` (a slice of `buf`) - // while writing `buf`; copy `dirpath` - // out first so the mutable borrow on `buf` is exclusive. - let dirpath = bun_sys::get_fd_path(dirfd, buf) - .map_err(|e| e.with_fd(dirfd))? - .to_vec(); - Ok(bun_paths::resolve_path::join_z_buf::< - bun_paths::platform::Auto, - >(&mut buf[..], &[&dirpath, to.as_bytes()])) + // Relative path. Join outside `buf` because `join_z_buf` doesn't bounds-check, + // then copy the normalized result into `buf` if there's room. + let mut spill = Vec::new(); + let joined = { + let dirpath = bun_sys::get_fd_path(dirfd, buf).map_err(|e| e.with_fd(dirfd))?; + bun_paths::resolve_path::join_spill::( + &mut spill, + &[dirpath, to.as_bytes()], + ) + }; + let len = joined.len(); + if len >= buf.len() { + return Err(name_too_long()); + } + buf[..len].copy_from_slice(joined); + buf[len] = 0; + Ok(bun_core::ZStr::from_buf(buf.as_slice(), len)) } /// Windows: rewrite the path via `shell_get_path` then `bun_sys::stat`, tagging @@ -2392,7 +2433,7 @@ pub(crate) fn shell_statat(dir: Fd, path_: &bun_core::ZStr) -> bun_sys::Result bun_sys::Result< #[cfg(windows)] { let mut buf = bun_paths::path_buffer_pool::get(); - let p = shell_get_path(dir, path_, &mut buf)?; + let p = shell_get_path(dir, path_, &mut buf, bun_sys::Tag::fstatat)?; return bun_sys::lstat(p).map_err(|e| e.with_path(path_.as_bytes())); } #[cfg(not(windows))] @@ -2432,7 +2473,7 @@ pub(crate) fn shell_openat( if flags & bun_sys::O::DIRECTORY != 0 { if bun_paths::Platform::Posix.is_absolute(path.as_bytes()) { let mut buf = bun_paths::path_buffer_pool::get(); - let p = shell_get_path(dir, path, &mut buf)?; + let p = shell_get_path(dir, path, &mut buf, bun_sys::Tag::open)?; return bun_sys::open_dir_at_windows_a( dir, p.as_bytes(), @@ -2461,7 +2502,7 @@ pub(crate) fn shell_openat( .make_lib_uv_owned_for_syscall(bun_sys::Tag::open, bun_sys::ErrorCase::CloseOnFail); } let mut buf = bun_paths::path_buffer_pool::get(); - let p = shell_get_path(dir, path, &mut buf)?; + let p = shell_get_path(dir, path, &mut buf, bun_sys::Tag::open)?; // No `makeLibUVOwnedForSyscall` here: `bun_sys::open` on Windows // routes through `sys_uv` and already yields a uv-owned fd. return bun_sys::open(p, flags, perm); diff --git a/src/runtime/shell/shell_body.rs b/src/runtime/shell/shell_body.rs index eec562d638c5..c94e039bc462 100644 --- a/src/runtime/shell/shell_body.rs +++ b/src/runtime/shell/shell_body.rs @@ -386,22 +386,10 @@ pub(crate) fn handle_template_value( return Ok(()); } - if let Some(_rstream) = crate::webcore::ReadableStream::from_js(template_value, global)? { - let idx = out_jsobjs.len(); - marked_argument_buffer.append(template_value); - out_jsobjs.push(template_value); - let mut cursor = std::io::Cursor::new(&mut jsobjref_buf[..]); - write!( - cursor, - "{}{}{}", - bstr::BStr::new(LEX_JS_OBJREF_PREFIX), - idx, - LEX_JS_REF_TERMINATOR as char - ) - .map_err(|_| global.throw_out_of_memory())?; - let n = cursor.position() as usize; - out_script.extend_from_slice(&jsobjref_buf[..n]); - return Ok(()); + if crate::webcore::ReadableStream::from_js(template_value, global)?.is_some() { + return Err(global.throw(format_args!( + "ReadableStream is not supported as a shell redirect yet. Buffer it first, e.g. `< ${{await new Response(stream).blob()}}`, or use Bun.spawn({{ stdin: stream }})" + ))); } if let Some(_req) = template_value.as_::() { diff --git a/src/runtime/shell/states/Cmd.rs b/src/runtime/shell/states/Cmd.rs index aaf8a1f47af4..921facf0a3a5 100644 --- a/src/runtime/shell/states/Cmd.rs +++ b/src/runtime/shell/states/Cmd.rs @@ -779,8 +779,6 @@ impl Cmd { STDERR_NO as i32, )?; } - } else if crate::webcore::ReadableStream::from_js(jsval, global)?.is_some() { - panic!("TODO SHELL READABLE STREAM"); } else if let Some(req) = jsval.as_::() { // SAFETY: `as_` returns a live JSC-owned `*mut Response`; // `get_body_value` is `&self`. @@ -1029,7 +1027,7 @@ impl Cmd { debug_assert!(matches!(self.exec, Exec::Subproc(_))); log!("cmd close buffered stdout"); if let Some(e) = err { - self.exit_code = Some(e.errno.unsigned_abs() as ExitCode); + self.exit_code = Some(e.get_errno() as ExitCode); } let redirect = self.ast_node().redirect; let Exec::Subproc(sub) = &mut self.exec else { @@ -1065,7 +1063,7 @@ impl Cmd { debug_assert!(matches!(self.exec, Exec::Subproc(_))); log!("cmd close buffered stderr"); if let Some(e) = err { - self.exit_code = Some(e.errno.unsigned_abs() as ExitCode); + self.exit_code = Some(e.get_errno() as ExitCode); } let redirect = self.ast_node().redirect; let Exec::Subproc(sub) = &mut self.exec else { diff --git a/src/runtime/shell/states/Expansion.rs b/src/runtime/shell/states/Expansion.rs index 2d066ce88c3c..12b615773815 100644 --- a/src/runtime/shell/states/Expansion.rs +++ b/src/runtime/shell/states/Expansion.rs @@ -327,14 +327,16 @@ impl Expansion { &mut expanded[..], lexer_output.contains_nested, ) { - if matches!(e, braces::ParserError::TooManyBraces) { - let msg = "too many braces in brace expansion".to_string(); - me.state = - ExpansionState::Err(Box::new(ShellErr::Custom(msg.into_bytes().into()))); - return; - } - // An unexpected token from brace expansion is a parser bug. - panic!("unexpected error from Braces.expand: {e:?}"); + let msg = match e { + braces::ParserError::TooManyBraces => "too many braces in brace expansion", + // The word has more than `u16::MAX` brace tokens. + braces::ParserError::UnexpectedToken => { + "unexpected token while expanding braces" + } + braces::ParserError::OutOfMemory => bun_core::out_of_memory(), + }; + me.state = ExpansionState::Err(Box::new(ShellErr::Custom(msg.as_bytes().into()))); + return; } drop(arena); expanded @@ -621,7 +623,7 @@ impl Expansion { } // SAFETY: single trampoline frame; the child script's env (and its // parent buffer in the `Borrowed` case) has no other live borrow. - let stdout = unsafe { + let mut stdout = unsafe { interp .as_script_mut(child) .base @@ -629,6 +631,11 @@ impl Expansion { .buffered_stdout_mut() } .clone(); + // Like bash and dash, drop NUL bytes before trimming and field splitting. + let had_nul = bun_core::strings::contains_char(&stdout, 0); + if had_nul { + stdout.retain(|&b| b != 0); + } // Propagate the exit code if the *whole* atom was a single `$(...)` // (so `$(false)` as argv0 fails the command). @@ -652,6 +659,10 @@ impl Expansion { hi -= 1; } me.current_out.extend_from_slice(&stdout[..hi]); + // A quoted substitution that printed only NUL bytes expands to one empty word. + if had_nul && me.cmd_subst_quoted { + me.has_quoted_empty = true; + } } else { Self::post_subshell_expansion(me, stdout); } diff --git a/src/runtime/shell/subproc.rs b/src/runtime/shell/subproc.rs index fa99bc8fe0db..bccbee819c03 100644 --- a/src/runtime/shell/subproc.rs +++ b/src/runtime/shell/subproc.rs @@ -404,7 +404,7 @@ impl ShellSubprocess { } } - /// Tear down a subprocess whose stdio start() failed. Ends the child, marks pending pipe + /// Tear down a subprocess whose stdin writer start() failed. Ends the child, marks pending pipe /// readers as errored so PipeReader.deinit's done-assert passes, drops the exit handler so /// a later onProcessExit doesn't touch the freed Subprocess, then deinits. /// @@ -894,31 +894,23 @@ impl ShellSubprocess { // SAFETY: `subprocess` is live; the slot is passed raw because the // reader can complete synchronously and overwrite it via `on_close_io`. - if let Err(err) = unsafe { + unsafe { Readable::start_pipe_reader( &raw mut (*subprocess).stdout, subprocess, event_loop, !spawn_args.lazy, - ) - } { - let sys_err = err.to_shell_system_error(); - Self::abort_after_failed_start(subprocess); - return Err(ShellErr::Sys(sys_err)); + ); } // SAFETY: as for stdout above. - if let Err(err) = unsafe { + unsafe { Readable::start_pipe_reader( &raw mut (*subprocess).stderr, subprocess, event_loop, !spawn_args.lazy, - ) - } { - let sys_err = err.to_shell_system_error(); - Self::abort_after_failed_start(subprocess); - return Err(ShellErr::Sys(sys_err)); + ); } log!("returning"); @@ -1200,24 +1192,32 @@ impl Readable { process: *mut ShellSubprocess, event_loop: EventLoopHandle, eager: bool, - ) -> bun_sys::Result<()> { + ) { // The reader must outlive the re-entrant calls below even if they // drop this slot's `Arc`; clone it as a keepalive. // SAFETY: caller contract; borrow scoped to the clone. let keepalive = match unsafe { &*slot } { Readable::Pipe(pipe) => Arc::clone(pipe), - _ => return Ok(()), + _ => return, }; let p = arc_as_mut_ptr(&keepalive); // SAFETY: see `arc_as_mut_ptr` — single-threaded shell; the // re-entrant reader callbacks only hold raw `*mut PipeReader`, and // each `&mut` below is scoped to its own call. - unsafe { (*p).start(process, event_loop) }?; + if let Err(err) = unsafe { (*p).start(process, event_loop) } { + // Only Windows returns an error here. Close the pipe and report it as + // a read error, so the `Cmd` is still alive when the child exits. + // SAFETY: as above. + unsafe { + (*p).reader.deinit(); + PipeReader::on_reader_error(p, &err); + } + return; + } if eager { // SAFETY: as above. unsafe { (*p).read_all() }; } - Ok(()) } pub(crate) fn r#ref(&mut self) { @@ -1536,10 +1536,14 @@ impl BufferedOutput { } } + /// Returns the bytes the command has written so far. With `2>&1 ${buf}`, the tee reads them from the `ArrayBuffer`. pub(crate) fn slice(&self) -> &[u8] { match self { BufferedOutput::Bytelist(b) => b.slice(), - BufferedOutput::ArrayBuffer { buf, .. } => buf.slice(), + BufferedOutput::ArrayBuffer { buf, i } => { + let live = buf.live_slice(); + &live[..(*i as usize).min(live.len())] + } } } @@ -1549,7 +1553,8 @@ impl BufferedOutput { let _ = b.append_slice(bytes); // OOM/capacity: fire-and-forget } BufferedOutput::ArrayBuffer { buf, i } => { - let array_buf_slice = buf.slice_mut(); + // JS can resize the buffer between chunks. + let array_buf_slice = buf.live_slice_mut(); let idx = *i as usize; // TODO: We should probably throw error here? if idx >= array_buf_slice.len() { diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index 82aae1ae5551..d4e345f386eb 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -849,11 +849,7 @@ impl Listener { // The C SNI tree SSL_CTX_up_ref()s; ours drops here. // S008: `ListenSocket` is an `opaque_ffi!` ZST — safe deref. - bun_opaque::opaque_deref_mut(ls).add_server_name( - server_name, - sni_ctx.as_ptr(), - core::ptr::null_mut(), - ); + bun_opaque::opaque_deref_mut(ls).add_server_name(server_name, sni_ctx.as_ptr()); Ok(()) } @@ -1520,9 +1516,9 @@ impl Listener { Ok(b) => b, Err(_) => return Ok(JSValue::UNDEFINED), }; - let family_js = match address_bytes.len() { - 4 => global.common_strings().ipv4(), - 16 => global.common_strings().ipv6(), + let is_ipv6 = match address_bytes.len() { + 4 => false, + 16 => true, _ => return Ok(JSValue::UNDEFINED), }; // Format with `SocketAddrV{4,6}` so `format_ip`'s strip logic sees the @@ -1554,9 +1550,7 @@ impl Listener { None => JSValue::UNDEFINED, }; - out.put(global, b"family", family_js); - out.put(global, b"address", address_js); - out.put(global, b"port", port_js); + bun_jsc::cpp::JSSocketAddressDTO__assign(global, out, address_js, port_js, is_ipv6)?; Ok(JSValue::UNDEFINED) } } diff --git a/src/runtime/socket/SocketAddress.rs b/src/runtime/socket/SocketAddress.rs index c24337cd8e89..b799978756f2 100644 --- a/src/runtime/socket/SocketAddress.rs +++ b/src/runtime/socket/SocketAddress.rs @@ -210,7 +210,6 @@ impl SocketAddress { } }; debug_assert!(host.tag() != bun_core::Tag::Dead); - debug_assert!(host.length() >= 2); // NOTE: parsed host cannot be used as presentation string. e.g. // - "[::1]" -> "::1" @@ -221,7 +220,7 @@ impl SocketAddress { // `ares_inet_pton` does not accept it, so we strip and parse it here. // (WHATWG URL host parsing rejects zone identifiers, so in practice // `URL::host_()` should not yield one — handled defensively.) - let addr = if paddr[0] == b'[' && paddr[paddr.len() - 1] == b']' { + let addr = if paddr.len() >= 2 && paddr[0] == b'[' && paddr[paddr.len() - 1] == b']' { let mut inner = &paddr[1..paddr.len() - 1]; let mut scope_id: u32 = 0; if let Some(pct) = strings::index_of_char_usize(inner, b'%') { diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 878351da0ca0..d0c3f0ddfe95 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -3400,7 +3400,10 @@ impl NewSocket { .get(global, "socket")? .ok_or_else(|| global.throw(format_args!("Expected \"socket\" option")))?; - let handlers = this.get_handlers(); + // The "socket" getter can close this socket, which drops its handlers. + let Some(handlers) = this.handlers_opt() else { + return Ok(JSValue::UNDEFINED); + }; // Parse and validate first: the option getters run user JS that can // close this socket and repoint its `Handlers`. let reloaded = Handlers::prepare_reload(global, socket_obj)?; @@ -3469,11 +3472,11 @@ impl NewSocket { // adopt; the old `isDetached()/isNamedPipe()` guard let those // through and the `.connected` payload read below would then be // illegal-union-access on a `.connecting` socket. - let uws::InternalSocket::Connected(raw_socket) = this.socket.get().socket else { + if !matches!(this.socket.get().socket, uws::InternalSocket::Connected(_)) { return Err(global.throw_invalid_arguments(format_args!( "upgradeTLS requires an established socket" ))); - }; + } if opts.is_empty_or_undefined_or_null() || opts.is_boolean() || !opts.is_object() { return Err(global.throw(format_args!("Expected options object"))); } @@ -3609,6 +3612,13 @@ impl NewSocket { default_data.ensure_still_alive(); } + // An option getter can close this socket, or re-enter upgradeTLS and adopt its fd. + let uws::InternalSocket::Connected(raw_socket) = this.socket.get().socket else { + return Err(global.throw_invalid_arguments(format_args!( + "upgradeTLS requires an established socket" + ))); + }; + let vm = handlers.vm; let cfg = ssl_opts.as_ref(); diff --git a/src/runtime/socket/udp_socket.rs b/src/runtime/socket/udp_socket.rs index 896a9a1e6a7f..7339ae52aceb 100644 --- a/src/runtime/socket/udp_socket.rs +++ b/src/runtime/socket/udp_socket.rs @@ -1860,7 +1860,7 @@ impl UDPSocket { BinaryType::Buffer => global_this.common_strings().buffer(), BinaryType::Uint8Array => global_this.common_strings().uint8array(), BinaryType::ArrayBuffer => global_this.common_strings().arraybuffer(), - _ => panic!("Invalid binary type"), + other => BunString::static_(<&'static str>::from(other)).to_js(global_this)?, }) } diff --git a/src/runtime/test_runner/Order.rs b/src/runtime/test_runner/Order.rs index 75fa2a0cc442..60cc88777839 100644 --- a/src/runtime/test_runner/Order.rs +++ b/src/runtime/test_runner/Order.rs @@ -29,16 +29,6 @@ impl Order { } // `deinit` only freed `groups` / `sequences` — handled by Drop on Vec; no impl Drop needed. - pub(crate) fn generate_order_sub(&mut self, current: &mut TestScheduleEntry) -> JsResult<()> { - match current { - TestScheduleEntry::Describe(describe) => self.generate_order_describe(describe)?, - TestScheduleEntry::TestCallback(test_callback) => { - self.generate_order_test(NonNull::from(&mut **test_callback))? - } - } - Ok(()) - } - pub(crate) fn generate_all_order(&mut self, entries: &[Box]) -> JsResult { let start = self.groups.len(); for entry_box in entries.iter() { @@ -78,9 +68,42 @@ impl Order { Ok(AllOrderResult { start, end }) } - pub(crate) fn generate_order_describe(&mut self, current: &mut DescribeScope) -> JsResult<()> { + /// Uses an explicit stack instead of recursion, because `describe()` can nest arbitrarily deep. + pub(crate) fn generate_order_describe(&mut self, root: &mut DescribeScope) -> JsResult<()> { + let mut open: Vec> = Vec::new(); + if let Some(frame) = self.enter_describe(root)? { + open.push(frame); + } + while let Some(frame) = open.last_mut() { + let scope_only = frame.only; + let Some(entry) = frame.children.next() else { + let frame = open.pop().expect("non-empty"); + self.exit_describe(&frame)?; + continue; + }; + if scope_only == Only::Contains && entry.base().only == Only::No { + continue; + } + match entry { + TestScheduleEntry::Describe(describe) => { + if let Some(child) = self.enter_describe(describe)? { + open.push(child); + } + } + TestScheduleEntry::TestCallback(test_callback) => { + self.generate_order_test(NonNull::from(&mut **test_callback))? + } + } + } + Ok(()) + } + + fn enter_describe<'a>( + &mut self, + current: &'a mut DescribeScope, + ) -> JsResult>> { if current.failed { - return Ok(()); // do not schedule any tests in a failed describe scope + return Ok(None); // do not schedule any tests in a failed describe scope } let use_hooks = self.cfg.always_use_hooks || current.base.has_callback; @@ -96,22 +119,22 @@ impl Order { shuffle_with_index(random, &mut current.entries); } - // gather children - // reshaped for borrowck — iterate by index since generate_order_sub borrows &mut self. - let scope_only = current.base.only; - for i in 0..current.entries.len() { - if scope_only == Only::Contains && current.entries[i].base().only == Only::No { - continue; - } - self.generate_order_sub(&mut current.entries[i])?; - } + Ok(Some(OpenDescribe { + children: current.entries.iter_mut(), + only: current.base.only, + use_hooks, + beforeall_order, + after_all: ¤t.after_all, + })) + } + fn exit_describe(&mut self, frame: &OpenDescribe<'_>) -> JsResult<()> { // update skip_to values for beforeAll to skip to the first afterAll - beforeall_order.set_failure_skip_to(self); + frame.beforeall_order.set_failure_skip_to(self); // gather afterAll - let afterall_order: AllOrderResult = if use_hooks { - self.generate_all_order(¤t.after_all)? + let afterall_order: AllOrderResult = if frame.use_hooks { + self.generate_all_order(frame.after_all)? } else { AllOrderResult::EMPTY }; @@ -242,6 +265,15 @@ impl Order { } } +/// Tracks a describe scope whose children `generate_order_describe` is still scheduling. +struct OpenDescribe<'a> { + children: core::slice::IterMut<'a, TestScheduleEntry>, + only: Only, + use_hooks: bool, + beforeall_order: AllOrderResult, + after_all: &'a [Box], +} + pub(crate) struct AllOrderResult { pub(crate) start: usize, pub(crate) end: usize, diff --git a/src/runtime/test_runner/bun_test.rs b/src/runtime/test_runner/bun_test.rs index b67a04b69cfc..71ef8b561e26 100644 --- a/src/runtime/test_runner/bun_test.rs +++ b/src/runtime/test_runner/bun_test.rs @@ -1328,7 +1328,7 @@ impl BunTest { Some(crate::cli::test_command::TestFailure::record_cb); vm.on_print_error_zig_exception_ctx = failure_ctx; } - vm.run_error_handler(exception, None); + print_error(vm, global_this, exception, None); if !failure_ctx.is_null() { vm.on_print_error_zig_exception = None; vm.on_print_error_zig_exception_ctx = core::ptr::null_mut(); @@ -1346,6 +1346,29 @@ impl BunTest { } } +/// Prints `error`, then prints any exception the error's getters or `toString` threw during the print. +pub(crate) fn print_error( + vm: &mut VirtualMachine, + global_this: &JSGlobalObject, + error: JSValue, + exception_list: Option<&mut bun_jsc::virtual_machine::ExceptionList>, +) { + vm.run_error_handler(error, exception_list); + // `BunTest::run` and `run_test_callback` clear termination exceptions. + if global_this.has_pending_termination_exception() { + return; + } + let Some(left) = global_this.try_take_exception() else { + return; + }; + vm.run_error_handler(left, None); + // Printing this one could throw again, so just print a note. + if !global_this.has_pending_termination_exception() && global_this.try_take_exception().is_some() { + bun_core::pretty_errorln!("note: printing the error above threw another error"); + Output::flush(); + } +} + impl Drop for BunTest { fn drop(&mut self) { let _g = group_begin!(); @@ -1743,6 +1766,19 @@ pub(crate) struct DescribeScope { pub(crate) failed: bool, } +impl Drop for DescribeScope { + /// Drops nested scopes iteratively. The default drop recurses once per nested `describe()`. + fn drop(&mut self) { + let mut pending: Vec = core::mem::take(&mut self.entries); + while let Some(entry) = pending.pop() { + if let TestScheduleEntry::Describe(mut scope) = entry { + pending.append(&mut scope.entries); + // `scope.entries` is empty, so dropping `scope` here doesn't recurse. + } + } + } +} + impl DescribeScope { pub(crate) fn create(base: BaseScope) -> Box { Box::new(DescribeScope { @@ -1755,7 +1791,6 @@ impl DescribeScope { failed: false, }) } - // destroy → Drop on Box; all fields own their contents. fn mark_contains_only(&mut self) { let mut target: Option<*mut DescribeScope> = Some(std::ptr::from_mut(self)); diff --git a/src/runtime/test_runner/expect.rs b/src/runtime/test_runner/expect.rs index e4241ea6de79..9e7ca7a93d9f 100644 --- a/src/runtime/test_runner/expect.rs +++ b/src/runtime/test_runner/expect.rs @@ -1479,7 +1479,7 @@ impl Expect { js_promise::Status::Rejected => { // TODO: rewrite this code to use .then() instead of blocking the event loop // SAFETY: per-use reborrow of the thread-local VM (see VirtualMachine::get docs). - VirtualMachine::get().as_mut().run_error_handler(result, None); + bun_test::print_error(VirtualMachine::get().as_mut(), global_this, result, None); return Err(global_this.throw(format_args!( "Matcher `{}` returned a promise that rejected", matcher_name, diff --git a/src/runtime/test_runner/expect/toHaveReturnedWith.rs b/src/runtime/test_runner/expect/toHaveReturnedWith.rs index 8d6731245079..e4b4a8319501 100644 --- a/src/runtime/test_runner/expect/toHaveReturnedWith.rs +++ b/src/runtime/test_runner/expect/toHaveReturnedWith.rs @@ -1,4 +1,4 @@ -use bun_jsc::{CallFrame, JSGlobalObject, JSValue, JsResult}; +use bun_jsc::{CallFrame, JSGlobalObject, JSValue, JsResult, MarkedArgumentBuffer}; use super::DiffFormatter; use super::mock; @@ -9,6 +9,17 @@ pub(crate) fn to_have_returned_with( this: &Expect, global: &JSGlobalObject, frame: &CallFrame, +) -> JsResult { + MarkedArgumentBuffer::new(|return_roots| { + to_have_returned_with_impl(this, global, frame, return_roots) + }) +} + +fn to_have_returned_with_impl( + this: &Expect, + global: &JSGlobalObject, + frame: &CallFrame, + return_roots: &mut MarkedArgumentBuffer, ) -> JsResult { let expected = frame.arguments_as_array::<1>()[0]; let (this, returns, _value) = this.mock_prologue( @@ -22,9 +33,8 @@ pub(crate) fn to_have_returned_with( let calls_count = u32::try_from(returns.get_length(global)?).unwrap(); let mut pass = false; - // A heap-backed Vec is not stack-scanned by JSC's conservative GC; - // however every value pushed here is also reachable via the `returns` JSArray (kept live on the - // stack), so a plain Vec is safe. SuccessfulReturnsFormatter expects &Vec. + // `jest_deep_equals` can run user code that clears `mock.results`, so `return_roots` + // keeps these values alive. let mut successful_returns: Vec = Vec::new(); let mut has_errors = false; @@ -40,6 +50,7 @@ pub(crate) fn to_have_returned_with( if type_str.eq_ascii(b"return") { let result_value = result.get(global, "value")?.unwrap_or(JSValue::UNDEFINED); + return_roots.append(result_value); successful_returns.push(result_value); // Check for pass condition only if not already passed diff --git a/src/runtime/test_runner/expect/toThrow.rs b/src/runtime/test_runner/expect/toThrow.rs index ea60faa91d16..72fd7f16abff 100644 --- a/src/runtime/test_runner/expect/toThrow.rs +++ b/src/runtime/test_runner/expect/toThrow.rs @@ -189,16 +189,29 @@ pub(crate) fn to_throw( } let expected_class = expected_value.get_class_name(global)?; - let received_message: JSValue = result - .fast_get(global, bun_jsc::BuiltinName::Message)? - .unwrap_or(JSValue::UNDEFINED); + // `Symbol.hasInstance` can accept a thrown value that isn't an object. + let received_message = if result.is_object() { + result.fast_get(global, bun_jsc::BuiltinName::Message)? + } else { + None + }; + if let Some(received_message) = received_message { + return throw!( + this, + global, + signature, + "\n\nExpected constructor: not {}\n\nReceived message: {}\n", + expected_class, + received_message.to_fmt(&mut formatter), + ); + } return throw!( this, global, signature, - "\n\nExpected constructor: not {}\n\nReceived message: {}\n", + "\n\nExpected constructor: not {}\n\nReceived value: {}\n", expected_class, - received_message.to_fmt(&mut formatter), + result.to_fmt(&mut formatter), ); } diff --git a/src/runtime/test_runner/jest.rs b/src/runtime/test_runner/jest.rs index dc9851daec00..703b9186eb7d 100644 --- a/src/runtime/test_runner/jest.rs +++ b/src/runtime/test_runner/jest.rs @@ -641,7 +641,7 @@ pub(crate) mod on_unhandled_rejection { let exception_list = jsc_vm .on_unhandled_rejection_exception_list .map(|p| unsafe { &mut *p.as_ptr() }); - jsc_vm.run_error_handler(rejection, exception_list); + bun_test::print_error(jsc_vm, global_object, rejection, exception_list); } } diff --git a/src/runtime/test_runner/pretty_format.rs b/src/runtime/test_runner/pretty_format.rs index 9ba2de15ff60..ae9e0b18805d 100644 --- a/src/runtime/test_runner/pretty_format.rs +++ b/src/runtime/test_runner/pretty_format.rs @@ -423,7 +423,7 @@ impl Tag { #[inline] pub(crate) const fn can_have_circular_references(self) -> bool { - matches!(self, Tag::Array | Tag::Object | Tag::Map | Tag::Set) + matches!(self, Tag::Array | Tag::Object | Tag::Map | Tag::Set | Tag::JSX) } } @@ -1121,8 +1121,13 @@ impl<'a> Formatter<'a> { self.reset_line(); self.write_indent(writer.ctx).expect("unreachable"); let length = str.len; - for (i, c) in str.slice().iter().enumerate() { - writer.print(format_args!("\"{}\": \"{}\",\n", i, *c as char)); + for i in 0..length { + let unit = [str.char_at(i)]; + writer.print(format_args!( + "\"{}\": \"{}\",\n", + i, + bun_fmt::utf16(&unit) + )); if i != length - 1 { self.write_indent(writer.ctx).expect("unreachable"); } @@ -1600,12 +1605,12 @@ impl<'a> Formatter<'a> { } else if let Some(timer) = value.as_class_ref::() { self.add_for_new_line( b"Timeout(# ) ".len() - + bun_fmt::digit_count(timer.internals.id.max(0)), + + bun_fmt::digit_count(timer.internals.id), ); if timer.internals.flags.get().kind() == crate::timer::Kind::SetInterval { self.add_for_new_line( b"repeats ".len() - + bun_fmt::digit_count(timer.internals.id.max(0)), + + bun_fmt::digit_count(timer.internals.id), ); writer.print(format_args!( "{}Timeout{} {}(#{}{}{}{}, repeats){}", @@ -1638,7 +1643,7 @@ impl<'a> Formatter<'a> { { self.add_for_new_line( b"Immediate(# ) ".len() - + bun_fmt::digit_count(immediate.internals.id.max(0)), + + bun_fmt::digit_count(immediate.internals.id), ); writer.print(format_args!( "{}Immediate{} {}(#{}{}{}{}){}", diff --git a/src/runtime/test_runner/snapshot.rs b/src/runtime/test_runner/snapshot.rs index 89e5c5fb3340..03487a7481f8 100644 --- a/src/runtime/test_runner/snapshot.rs +++ b/src/runtime/test_runner/snapshot.rs @@ -1,10 +1,10 @@ use core::ffi::c_ulong; use std::io::Write as _; +use crate::Error; use bun_collections::{HashMap, StringHashMap}; use bun_core::output as bun_output; use bun_core::printer as js_printer; -use crate::Error; use bun_core::{ZStr, strings}; use bun_js_parser::{self as js_parser, lexer as js_lexer}; use bun_jsc::virtual_machine::VirtualMachine; @@ -116,7 +116,11 @@ impl Snapshots { } } - pub(crate) fn add_count(&mut self, expect: &Expect, hint: &[u8]) -> Result<(Vec, usize), Error> { + pub(crate) fn add_count( + &mut self, + expect: &Expect, + hint: &[u8], + ) -> Result<(Vec, usize), Error> { self.total += 1; let snapshot_name = expect.get_snapshot_name(hint)?; // bun_collections::StringHashMap::get_or_put can't hand out `key_ptr`, so return the @@ -140,9 +144,7 @@ impl Snapshots { target_value: &[u8], hint: &[u8], ) -> Result, Error> { - let buntest_strong = expect - .bun_test() - .ok_or(crate::Error::SnapshotFailed)?; + let buntest_strong = expect.bun_test().ok_or(crate::Error::SnapshotFailed)?; let bun_test = buntest_strong.get(); match self.get_snapshot_file(bun_test.file_id)? { bun_sys::Result::Ok(()) => {} @@ -228,10 +230,8 @@ impl Snapshots { // SAFETY: VM is thread-local singleton installed before any test runs; lives for the // duration of the runner. Per `VirtualMachine::get` doc, callers form a short-lived borrow. let vm = VirtualMachine::get().as_mut(); - let opts = js_parser::ParserOptions::init( - vm.transpiler.options.jsx.clone(), - bun_ast::Loader::Js, - ); + let opts = + js_parser::ParserOptions::init(vm.transpiler.options.jsx.clone(), bun_ast::Loader::Js); // Thread a per-call arena — js_parser is bump-allocated. let arena = bun_alloc::Arena::new(); let mut temp_log = bun_ast::Log::init(); @@ -252,19 +252,18 @@ impl Snapshots { let dir_path = name.dir_with_trailing_slash(); let mut snapshot_file_path_buf = bun_paths::path_buffer_pool::get(); - let buf = snapshot_file_path_buf.0.as_mut_slice(); - let mut pos = 0usize; - buf[pos..pos + dir_path.len()].copy_from_slice(dir_path); - pos += dir_path.len(); - buf[pos..pos + Self::SNAPSHOTS_DIR_NAME.len()].copy_from_slice(Self::SNAPSHOTS_DIR_NAME); - pos += Self::SNAPSHOTS_DIR_NAME.len(); - buf[pos..pos + test_filename.len()].copy_from_slice(test_filename); - pos += test_filename.len(); - buf[pos..pos + b".snap".len()].copy_from_slice(b".snap"); - pos += b".snap".len(); - buf[pos] = 0; - // SAFETY: buf[pos] == 0 written above - let snapshot_file_path = ZStr::from_buf(&buf[..], pos); + let snapshot_file_path = ZStr::from_slice_with_nul( + strings::concat_buf_t::( + &mut snapshot_file_path_buf[..], + &[ + dir_path, + Self::SNAPSHOTS_DIR_NAME, + test_filename, + b".snap\0", + ], + ) + .map_err(|_| crate::Error::FailedToOpenSnapshotFile)?, + ); let source = bun_ast::Source::init_path_string( snapshot_file_path.as_bytes(), @@ -364,10 +363,7 @@ impl Snapshots { file_id: FileId, value: InlineSnapshotToWrite, ) -> Result<(), Error> { - let list = self - .inline_snapshots_to_write - .entry(file_id) - .or_default(); + let list = self.inline_snapshots_to_write.entry(file_id).or_default(); list.push(value); Ok(()) } @@ -845,18 +841,13 @@ impl Snapshots { let mut snapshot_file_path_buf = bun_paths::path_buffer_pool::get(); let buf = snapshot_file_path_buf.0.as_mut_slice(); - let mut pos = 0usize; - buf[pos..pos + dir_path.len()].copy_from_slice(dir_path); - pos += dir_path.len(); - buf[pos..pos + Self::SNAPSHOTS_DIR_NAME.len()] - .copy_from_slice(Self::SNAPSHOTS_DIR_NAME); - pos += Self::SNAPSHOTS_DIR_NAME.len(); let cached_dir = self.snapshot_dir_path; if cached_dir.is_none() || !strings::eql_long(dir_path, cached_dir.unwrap(), true) { - buf[pos] = 0; - // SAFETY: buf[pos] == 0 written above - let snapshot_dir_path = ZStr::from_buf(&buf[..], pos); + let snapshot_dir_path = ZStr::from_slice_with_nul( + strings::concat_buf_t::(buf, &[dir_path, Self::SNAPSHOTS_DIR_NAME, b"\0"]) + .map_err(|_| crate::Error::FailedToOpenSnapshotFile)?, + ); match bun_sys::mkdir(snapshot_dir_path, 0o777) { bun_sys::Result::Ok(()) => { self.snapshot_dir_path = Some(dir_path); @@ -870,13 +861,18 @@ impl Snapshots { } } - buf[pos..pos + test_filename.len()].copy_from_slice(test_filename); - pos += test_filename.len(); - buf[pos..pos + b".snap".len()].copy_from_slice(b".snap"); - pos += b".snap".len(); - buf[pos] = 0; - // SAFETY: buf[pos] == 0 written above - let snapshot_file_path = ZStr::from_buf(&buf[..], pos); + let snapshot_file_path = ZStr::from_slice_with_nul( + strings::concat_buf_t::( + buf, + &[ + dir_path, + Self::SNAPSHOTS_DIR_NAME, + test_filename, + b".snap\0", + ], + ) + .map_err(|_| crate::Error::FailedToOpenSnapshotFile)?, + ); let mut flags: i32 = bun_sys::O::CREAT | bun_sys::O::RDWR; if self.update_snapshots { diff --git a/src/runtime/test_runner/timers/FakeTimers.rs b/src/runtime/test_runner/timers/FakeTimers.rs index d20385e78cfb..a030156e68dd 100644 --- a/src/runtime/test_runner/timers/FakeTimers.rs +++ b/src/runtime/test_runner/timers/FakeTimers.rs @@ -1,8 +1,3 @@ -use std::sync::atomic::{AtomicU64, Ordering}; - -use bun_threading::RwLock; - -use bun_core::Environment; use bun_core::Timespec; use bun_jsc::{CallFrame, JSFunction, JSGlobalObject, JSHostFn, JSValue, JsResult}; use crate::api::cron::CronJob; @@ -20,7 +15,10 @@ unsafe extern "C" { #[derive(Default)] pub(crate) struct FakeTimers { - active: bool, + /// Fake monotonic clock. `useFakeTimers()` starts it at 0. `None` means the real clock is in use. + now: Option, + /// `Date.now()` minus `now.ms()`. + date_now_offset: f64, /// Depth of [`FakeTimers::fire`] calls on the stack; each covers the callback and its microtask drain. firing: u32, /// The sorted fake timers. TimerHeap is not optimal here because we need these operations: @@ -30,65 +28,34 @@ pub(crate) struct FakeTimers { pub(crate) timers: TimerHeap, } -// `date_now_offset` is stored as `AtomicU64` (f64 bits) so the static is `Sync` -// without `static mut`. -pub(crate) struct CurrentTime { - /// starts at 0. offset in milliseconds. - offset_raw: RwLock, - date_now_offset: AtomicU64, -} - -const MIN_TIMESPEC: Timespec = Timespec { sec: i64::MIN, nsec: i64::MIN }; - -static CURRENT_TIME: CurrentTime = CurrentTime { - offset_raw: RwLock::new(MIN_TIMESPEC), - date_now_offset: AtomicU64::new(0f64.to_bits()), -}; - -impl CurrentTime { - pub(crate) fn get_timespec_now(&self) -> Option { - let value = *self.offset_raw.read(); - if value.eql(&MIN_TIMESPEC) { - return None; - } - Some(value) - } - - pub(crate) fn set(&self, global: &JSGlobalObject, offset: &Timespec, js: Option) { +impl FakeTimers { + fn set_now(&mut self, global: &JSGlobalObject, now: &Timespec, js: Option) { let vm = global.bun_vm().as_mut(); - { - *self.offset_raw.write() = *offset; - } + self.now = Some(*now); // Mirror into T0 storage so `Timespec::now(AllowMockedTime)` sees // the fake clock. - bun_core::mock_time::set(offset.ns() as i64); - let timespec_ms: f64 = offset.ms() as f64; - let mut date_now_offset = f64::from_bits(self.date_now_offset.load(Ordering::Relaxed)); + bun_core::mock_time::set(now.ns() as i64); + let timespec_ms: f64 = now.ms() as f64; if let Some(js) = js { - date_now_offset = js.floor() - timespec_ms; - self.date_now_offset.store(date_now_offset.to_bits(), Ordering::Relaxed); + self.date_now_offset = js.floor() - timespec_ms; } - let date_now = date_now_offset + timespec_ms; - // SAFETY: FFI call into C++ JSMock; global is a valid &JSGlobalObject + let date_now = self.date_now_offset + timespec_ms; JSMock__setOverridenDateNow(global, date_now); bun_core::mock_time::set_wall_ms(date_now); - vm.overridden_performance_now = Some(offset.ns()); + vm.overridden_performance_now = Some(now.ns()); // `Date.now() == date_now_offset + performance.now()`, so the offset is // the fake clock's `performance.timeOrigin`. - vm.overridden_time_origin = Some(date_now_offset); + vm.overridden_time_origin = Some(self.date_now_offset); } - pub(crate) fn clear(&self, global: &JSGlobalObject) { + fn clear_now(&mut self, global: &JSGlobalObject) { let vm = global.bun_vm().as_mut(); - { - *self.offset_raw.write() = MIN_TIMESPEC; - } + self.now = None; bun_core::mock_time::clear(); bun_core::mock_time::clear_wall(); // NaN is JSGlobalObject::overridenDateNow's "no override" sentinel; a // real -1 would pin Date.now() at 1969-12-31T23:59:59.999Z. - // SAFETY: FFI call into C++ JSMock; global is a valid &JSGlobalObject JSMock__setOverridenDateNow(global, f64::NAN); vm.overridden_performance_now = None; vm.overridden_time_origin = None; @@ -105,7 +72,9 @@ impl CurrentTime { /// and `performance.timeOrigin` go back to real as well. #[unsafe(no_mangle)] extern "C" fn Bun__FakeTimers__setSystemTime(global: &JSGlobalObject, ms: f64) { - let Some(current) = CURRENT_TIME.get_timespec_now() else { + // SAFETY: `timer::All` is per-thread and lives as long as the VM. Nothing here re-enters it. + let fake_timers = unsafe { &mut (*timer_all()).fake_timers }; + let Some(current) = fake_timers.now else { return; }; let vm = global.bun_vm().as_mut(); @@ -114,12 +83,9 @@ extern "C" fn Bun__FakeTimers__setSystemTime(global: &JSGlobalObject, ms: f64) { vm.overridden_time_origin = None; return; } - let date_now_offset = ms - current.ms() as f64; - CURRENT_TIME - .date_now_offset - .store(date_now_offset.to_bits(), Ordering::Relaxed); + fake_timers.date_now_offset = ms - current.ms() as f64; bun_core::mock_time::set_wall_ms(ms); - vm.overridden_time_origin = Some(date_now_offset); + vm.overridden_time_origin = Some(fake_timers.date_now_offset); } use crate::jsc_hooks::timer_all; @@ -173,23 +139,21 @@ impl ClearedTimers { impl FakeTimers { pub(crate) fn is_active(&self) -> bool { - self.active + self.now.is_some() } /// 1 while a fake timer's callback runs (sinon's `duringTick` rule): a zero-delay re-arm is due again in the drain that runs it. pub(crate) fn min_delay_ms(&self) -> u32 { - if self.active && self.firing > 0 { 1 } else { 0 } + if self.is_active() && self.firing > 0 { 1 } else { 0 } } fn activate(&mut self, js_now: f64, global: &JSGlobalObject) { - self.active = true; - CURRENT_TIME.set(global, &Timespec::EPOCH, Some(js_now)); + self.set_now(global, &Timespec::EPOCH, Some(js_now)); } fn deactivate(&mut self, global: &JSGlobalObject) -> ClearedTimers { let cleared = self.clear(); - CURRENT_TIME.clear(global); - self.active = false; + self.clear_now(global); cleared } @@ -199,8 +163,7 @@ impl FakeTimers { /// JS has stopped) can walk the still-populated fake heap and release /// `TimeoutObject` pins and discard `AbortSignalTimeout` timers. pub(crate) fn reset_for_isolation(&mut self, global: &JSGlobalObject) { - CURRENT_TIME.clear(global); - self.active = false; + self.clear_now(global); } /// Pop every fake timer. Popping only unlinks the nodes; the owners that @@ -260,21 +223,26 @@ impl FakeTimers { let _vm = global.bun_vm(); // SAFETY: `next` was just popped from our heap; live until callback completes. - let now_el = unsafe { (*next).next }; - let now = from_el_timespec(&now_el); - if Environment::CI_ASSERT { - let prev = CURRENT_TIME.get_timespec_now(); - debug_assert!(prev.is_some()); - debug_assert!(now.eql(&prev.unwrap()) || now.greater(&prev.unwrap())); - } - CURRENT_TIME.set(global, &now, None); + let deadline = from_el_timespec(unsafe { &(*next).next }); let all = timer_all(); - // SAFETY: `all` is the live per-thread `All`; the borrow ends at this - // statement, before `EventLoopTimer::fire` re-enters `All::insert`. - unsafe { (*all).fake_timers.firing += 1 }; + let now = { + // SAFETY: `all` is the live per-thread `All`. The borrow ends with + // this block, before `EventLoopTimer::fire` re-enters `All::insert`. + let this = unsafe { &mut (*all).fake_timers }; + debug_assert!(this.is_active()); + // An earlier callback in this drain can advance the clock past `deadline` with a + // `jest` timer call. The timer then fires late, like an overdue real timer. + let now = match this.now { + Some(current) if current.greater(&deadline) => current, + _ => deadline, + }; + this.set_now(global, &now, None); + this.firing += 1; + now + }; // SAFETY: `next` is live; `fire` takes `*mut Self` (noalias re-entrancy) // and an erased `*mut ()` for the VM. - let fired = unsafe { EventLoopTimer::fire(next, &now_el, bun_jsc::virtual_machine::VirtualMachine::get_mut_ptr().cast()) }; + let fired = unsafe { EventLoopTimer::fire(next, &now, bun_jsc::virtual_machine::VirtualMachine::get_mut_ptr().cast()) }; // SAFETY: as above; the callback has returned. unsafe { (*all).fake_timers.firing -= 1 }; match fired { @@ -356,7 +324,7 @@ fn set_fake_timer_marker(global: &JSGlobalObject, enabled: bool) -> JsResult<()> // testing-library/react checks Object.hasOwnProperty.call(setTimeout, 'clock') // to detect if fake timers are enabled. if enabled { - set_timeout_fn.put(global, "clock", JSValue::TRUE); + bun_jsc::cpp::JSMock__defineFakeTimersMarker(global, set_timeout_fn)?; } else { set_timeout_fn.delete_property(global, "clock")?; } @@ -397,14 +365,15 @@ fn use_fake_timers(global: &JSGlobalObject, frame: &CallFrame) -> JsResult JsResult J FakeTimers::execute_next(global)?; - Ok(frame.this()) + Ok(bun_jsc::cpp::JSMock__toThisStrict(global, frame.this())) } #[bun_jsc::host_fn] @@ -438,7 +407,8 @@ fn advance_timers_by_time(global: &JSGlobalObject, frame: &CallFrame) -> JsResul "advanceTimersByTime() expects a number of milliseconds" ))); } - let Some(current) = CURRENT_TIME.get_timespec_now() else { + // SAFETY: per-thread `timer::All`, live for the VM lifetime. + let Some(current) = (unsafe { (*timer_all()).fake_timers.now }) else { return Err(global.throw_invalid_arguments(format_args!( "Fake timers not initialized. Initialize with useFakeTimers() first." ))); @@ -458,10 +428,15 @@ fn advance_timers_by_time(global: &JSGlobalObject, frame: &CallFrame) -> JsResul let target = current.add_ms_float(effective_advance); let advanced = FakeTimers::execute_until(global, target); - CURRENT_TIME.set(global, &target, None); + // SAFETY: `timer::All` is per-thread, and `set_now` doesn't re-enter it. + let fake_timers = unsafe { &mut (*timer_all()).fake_timers }; + // A fired callback can call `useRealTimers()` or move the clock past `target`. + if fake_timers.now.is_some_and(|now| !now.greater(&target)) { + fake_timers.set_now(global, &target, None); + } advanced?; - Ok(frame.this()) + Ok(bun_jsc::cpp::JSMock__toThisStrict(global, frame.this())) } #[bun_jsc::host_fn] @@ -470,7 +445,7 @@ fn run_only_pending_timers(global: &JSGlobalObject, frame: &CallFrame) -> JsResu FakeTimers::execute_only_pending_timers(global)?; - Ok(frame.this()) + Ok(bun_jsc::cpp::JSMock__toThisStrict(global, frame.this())) } #[bun_jsc::host_fn] @@ -479,7 +454,7 @@ fn run_all_timers(global: &JSGlobalObject, frame: &CallFrame) -> JsResult JsResult, - id: i32, + id: u64, callback: JSValue, arguments: JSValue, ) -> JSValue { diff --git a/src/runtime/timer/TimeoutObject.rs b/src/runtime/timer/TimeoutObject.rs index ee70bde4217a..b6dacf05ad18 100644 --- a/src/runtime/timer/TimeoutObject.rs +++ b/src/runtime/timer/TimeoutObject.rs @@ -12,7 +12,7 @@ super::impl_timer_object!(TimeoutObject, TimeoutObject, "Timeout"); impl TimeoutObject { pub(crate) fn init( cx: &bun_jsc::JsThread<'_>, - id: i32, + id: u64, kind: Kind, interval: u32, callback: JSValue, diff --git a/src/runtime/timer/Timer.rs b/src/runtime/timer/Timer.rs index 5df917012288..eb9d24737e90 100644 --- a/src/runtime/timer/Timer.rs +++ b/src/runtime/timer/Timer.rs @@ -17,26 +17,13 @@ use super::{ All, CountdownOverflowBehavior, DateHeaderTimer, EventLoopTimer, EventLoopTimerState, EventLoopTimerTag, ImmediateObject, Kind, TimeoutObject, TimeoutWarning, TimerObjectInternals, }; -use crate::jsc_hooks::{timer_all, timer_all_mut}; +use crate::jsc_hooks::timer_all_mut; // ════════════════════════════════════════════════════════════════════════════ // JS-facing surface on `super::All` // ════════════════════════════════════════════════════════════════════════════ impl All { - #[unsafe(no_mangle)] - pub(crate) extern "C" fn Bun__Timer__getNextID() -> i32 { - let all = timer_all(); - if all.is_null() { - return 0; - } - // SAFETY: `all` is the live per-thread `All`; single-threaded JS heap. - unsafe { - (*all).last_id = (*all).last_id.wrapping_add(1); - (*all).last_id - } - } - /// # Safety /// `vm` must point to the live per-thread `VirtualMachine`. // Forwards `vm` to `DateHeaderTimer::enable` without dereferencing it here; @@ -193,8 +180,7 @@ impl All { bun_jsc::mark_binding!(); debug_assert!(!promise.is_empty() && !countdown.is_empty()); let all = timer_all_mut(); - let id = all.last_id; - all.last_id = all.last_id.wrapping_add(1); + let id = all.next_id(); let countdown_int = all.js_value_to_countdown( cx.global(), @@ -221,8 +207,7 @@ impl All { bun_jsc::mark_binding!(); debug_assert!(!callback.is_empty() && !arguments.is_empty()); let all = timer_all_mut(); - let id = all.last_id; - all.last_id = all.last_id.wrapping_add(1); + let id = all.next_id(); let wrapped_callback = callback.with_async_context_if_needed(cx.global()); Ok(ImmediateObject::init(cx, id, wrapped_callback, arguments)) @@ -237,8 +222,7 @@ impl All { bun_jsc::mark_binding!(); debug_assert!(!callback.is_empty() && !arguments.is_empty() && !countdown.is_empty()); let all = timer_all_mut(); - let id = all.last_id; - all.last_id = all.last_id.wrapping_add(1); + let id = all.next_id(); let wrapped_callback = callback.with_async_context_if_needed(cx.global()); let countdown_int = all.js_value_to_countdown( @@ -266,8 +250,7 @@ impl All { bun_jsc::mark_binding!(); debug_assert!(!callback.is_empty() && !arguments.is_empty() && !countdown.is_empty()); let all = timer_all_mut(); - let id = all.last_id; - all.last_id = all.last_id.wrapping_add(1); + let id = all.next_id(); let wrapped_callback = callback.with_async_context_if_needed(cx.global()); let countdown_int = all.js_value_to_countdown( @@ -286,12 +269,15 @@ impl All { )) } + /// Largest id a JS number can represent exactly. A larger number or numeric string never matches a timer. + const MAX_ID: u64 = bun_jsc::MAX_SAFE_INTEGER as u64; + /// The id a JS number names, whether JSC holds it as an int32 or as a double. - fn timer_id_from_number(value: JSValue) -> Option { + fn timer_id_from_number(value: JSValue) -> Option { let number = value.as_number(); - // `as` saturates and maps NaN to 0; the round trip rejects those and fractions. - let id = number as i32; - (f64::from(id) == number).then_some(id) + // `as` saturates and maps NaN to 0. The range check and the round trip reject those and fractions. + let id = number as i64; + ((1..=Self::MAX_ID as i64).contains(&id) && id as f64 == number).then_some(id as u64) } /// The timer `id` names for the running script, removed from the map @@ -300,7 +286,7 @@ impl All { &mut self, vm: &VirtualMachine, context: &bun_jsc::ScriptExecutionContext, - id: i32, + id: u64, ) -> Option<*mut TimeoutObject> { let (map, idx) = if let Some(idx) = self.maps.set_timeout.get_index(&id) { (&mut self.maps.set_timeout, idx) @@ -359,8 +345,8 @@ impl All { // // The reason is that in Node.js this function's parameter is used for an array // lookup, and array[0] is the same as array['0'] in JS but not the same as array['00']. - let parsed: i32 = { - let mut accumulator: i32 = 0; + let parsed: u64 = { + let mut accumulator: u64 = 0; // We can handle all encodings the same way since the only permitted characters // are ASCII. macro_rules! parse_slice { @@ -380,12 +366,11 @@ impl All { Some(v) => v, None => return Ok(()), }; - accumulator = match accumulator - .checked_add(i32::try_from(c - '0' as u32).expect("int cast")) - { - Some(v) => v, - None => return Ok(()), - }; + accumulator = + match accumulator.checked_add(u64::from(c - '0' as u32)) { + Some(v) => v, + None => return Ok(()), + }; } }}; } @@ -399,6 +384,9 @@ impl All { } accumulator }; + if parsed > Self::MAX_ID { + return Ok(()); + } let Some(t) = all.remove_timer_by_id(global_this.bun_vm(), context, parsed) else { return Ok(()); }; @@ -604,4 +592,24 @@ pub(crate) mod internal_bindings { pub(crate) fn monotonic_now_ms() -> f64 { Timespec::now(TimespecMockMode::ForceRealTime).ms() as f64 } + + /// Implements `timerInternals.setNextTimerId(id)`. The id can only increase, so ids never repeat. + #[bun_jsc::host_fn] + pub(crate) fn set_next_timer_id( + global_this: &JSGlobalObject, + call_frame: &CallFrame, + ) -> JsResult { + let current = timer_all_mut().last_id; + let id = global_this.validate_integer_range::( + call_frame.argument(0), + current, + bun_jsc::IntegerRange { + min: i128::from(current), + max: i128::from(All::MAX_ID), + field_name: b"id", + }, + )?; + timer_all_mut().last_id = id; + Ok(JSValue::UNDEFINED) + } } diff --git a/src/runtime/timer/mod.rs b/src/runtime/timer/mod.rs index ec72b1beed2b..19259842199a 100644 --- a/src/runtime/timer/mod.rs +++ b/src/runtime/timer/mod.rs @@ -119,7 +119,7 @@ macro_rules! impl_timer_object { /// picks `kind`/`interval` and forwards here. pub(crate) fn init_with( cx: &bun_jsc::JsThread<'_>, - id: i32, + id: u64, kind: super::Kind, interval: u32, callback: ::bun_jsc::JSValue, @@ -315,8 +315,8 @@ impl TimerHeap { } } -/// i32 is exposed to JavaScript and can be used with clearTimeout, clearInterval, etc. -pub(crate) type TimeoutMap = ArrayHashMap; +/// Keyed by the id that `+timer` returns in JavaScript. +pub(crate) type TimeoutMap = ArrayHashMap; #[derive(Default)] pub(crate) struct Maps { @@ -403,7 +403,9 @@ pub(crate) struct EventLoopDelayMonitor { /// `stop_active_handles` drops it before `~VM` (`All` outlives the heap). histogram: bun_jsc::Weak<()>, pub(crate) event_loop_timer: EventLoopTimer, - pub(crate) resolution_ms: i32, + /// Never 0. `on_fire` re-arms at the drain's fixed `now` plus this, and + /// `All::drain_timers` pops every node due at or before that `now`. + pub(crate) resolution_ms: u32, pub(crate) last_fire_ns: u64, pub(crate) enabled: bool, } @@ -428,16 +430,17 @@ impl EventLoopDelayMonitor { &mut self, vm: &mut bun_jsc::virtual_machine::VirtualMachine, histogram: JSValue, - resolution_ms: i32, + resolution_ms: f64, ) { self.disable(); self.histogram = bun_jsc::Weak::create_passive(histogram, vm.global()); - self.resolution_ms = resolution_ms; + // `as` saturates and maps NaN to 0. + self.resolution_ms = (resolution_ms as u32).max(1); self.enabled = true; // Schedule timer let now = Timespec::now(TimespecMockMode::ForceRealTime); - let next = now.add_ms(i64::from(resolution_ms)); + let next = now.add_ms(i64::from(self.resolution_ms)); self.event_loop_timer.next = ElTimespec { sec: next.sec, nsec: next.nsec, @@ -481,9 +484,7 @@ impl EventLoopDelayMonitor { let now_ns = now.ns(); if self.last_fire_ns > 0 { - let expected_ns = u64::try_from(self.resolution_ms) - .expect("int cast") - .saturating_mul(1_000_000); + let expected_ns = u64::from(self.resolution_ms) * 1_000_000; let actual_ns = now_ns - self.last_fire_ns; if actual_ns > expected_ns { @@ -582,7 +583,8 @@ pub(crate) use wtf_timer::WTFTimer; // ─── All ───────────────────────────────────────────────────────────────────── pub(crate) struct All { - pub(crate) last_id: i32, + /// Id of the next timer. Allocate ids with [`All::next_id`]. + pub(crate) last_id: u64, pub(crate) thread_id: std::thread::ThreadId, pub(crate) timers: TimerHeap, pub(crate) active_timer_count: i32, @@ -628,6 +630,14 @@ impl All { } } + /// Returns a new timer id. Ids are positive and never repeat, because a 64-bit counter won't wrap. + #[inline] + pub(crate) fn next_id(&mut self) -> u64 { + let id = self.last_id; + self.last_id = id.wrapping_add(1); + id + } + #[inline] fn assert_js_thread(&self) { debug_assert!( @@ -1314,23 +1324,16 @@ pub(crate) enum CountdownOverflowBehavior { // type so `TimeoutObject`/`TimerObjectInternals` can call it as a method. pub(crate) use bun_event_loop::EventLoopTimer::{Kind, KindBig}; -/// Sized to be the same as one pointer. -#[repr(C)] #[derive(Copy, Clone)] pub(crate) struct ID { - pub id: i32, + pub id: u64, pub kind: KindBig, } impl ID { + /// Returns the inspector's key for this timer, with the kind in the low two bits and the id above them. #[inline] fn async_id(self) -> u64 { - // Layout: 8 bytes, `id` (i32) then `kind` (u32). Reassemble via - // native-endian byte concat so the value is stable on every supported - // target without relying on struct-layout reinterpretation. - let mut bytes = [0u8; 8]; - bytes[..4].copy_from_slice(&self.id.to_ne_bytes()); - bytes[4..].copy_from_slice(&(self.kind as u32).to_ne_bytes()); - u64::from_ne_bytes(bytes) + (self.id << 2) | self.kind as u64 } } diff --git a/src/runtime/timer/timer_object_internals.rs b/src/runtime/timer/timer_object_internals.rs index 0a15698cf178..987c7063279b 100644 --- a/src/runtime/timer/timer_object_internals.rs +++ b/src/runtime/timer/timer_object_internals.rs @@ -29,9 +29,9 @@ use super::{ #[repr(C)] pub(crate) struct TimerObjectInternals { /// Identifier for this timer that is exposed to JavaScript (by `+timer`). - pub(crate) id: i32, - pub(crate) interval: Cell, + pub(crate) id: u64, pub this_value: JsCell, + pub(crate) interval: Cell, pub(crate) flags: Cell, /// The context whose script created the timer. pub(crate) context: bun_jsc::ContextId, @@ -39,6 +39,9 @@ pub(crate) struct TimerObjectInternals { pub(crate) generation: u32, } +// Putting `interval` between `id` and `this_value` would pad this to 48 bytes. +const _: () = assert!(core::mem::size_of::() == 40); + impl TimerObjectInternals { /// Read-modify-write `self.flags` through the `Cell` (R-2: `flags` is /// `Cell` so the write is interior-mutable, callable from @@ -54,9 +57,9 @@ impl TimerObjectInternals { impl Default for TimerObjectInternals { fn default() -> Self { Self { - id: -1, - interval: Cell::new(0), + id: 0, this_value: JsCell::new(JsRef::empty()), + interval: Cell::new(0), flags: Cell::new(Flags::default()), context: bun_jsc::ContextId::default(), generation: 0, @@ -280,7 +283,7 @@ impl TimerObjectInternals { &mut self, timer: JSValue, cx: &bun_jsc::JsThread<'_>, - id: i32, + id: u64, kind: Kind, interval: u32, callback: JSValue, @@ -848,7 +851,7 @@ impl TimerObjectInternals { } // (c) `vm.timer.maps.get(kind).swapRemove(id)` if - // `has_accessed_primitive` — drops the i32→*mut EventLoopTimer + // `has_accessed_primitive` — drops the id→*mut EventLoopTimer // entry minted by `to_primitive`. Swap-remove: the id map is only // ever keyed into, never iterated in order, and `deinit` runs for // every id-accessed timer a GC sweep collects, so the ordered @@ -859,11 +862,9 @@ impl TimerObjectInternals { let map = unsafe { (*state).timer.maps.get(kind) }; if map.swap_remove(&self.id) { // If this map got - // large, shrink it back down. Keys are i32, values are one - // pointer (~12 bytes per entry), so 21,000 timers accessed by - // ID ≈ 252 KiB; reclaim once the slack exceeds 256 KiB. + // large, shrink it back down: reclaim once the slack exceeds 256 KiB. const ENTRY_SIZE: usize = - core::mem::size_of::() + core::mem::size_of::<*mut EventLoopTimer>(); + core::mem::size_of::() + core::mem::size_of::<*mut EventLoopTimer>(); let allocated_bytes = map.capacity() * ENTRY_SIZE; let used_bytes = map.count() * ENTRY_SIZE; if allocated_bytes - used_bytes > 256 * 1024 { @@ -998,10 +999,7 @@ impl TimerObjectInternals { debug_assert!(self.flags.get().kind() != Kind::SetImmediate); // setImmediate does not support refreshing and we do not support refreshing after cleanup - if self.id == -1 - || self.flags.get().kind() == Kind::SetImmediate - || self.flags.get().has_cleared_timer() - { + if self.flags.get().kind() == Kind::SetImmediate || self.flags.get().has_cleared_timer() { return Ok(this_value); } @@ -1042,10 +1040,10 @@ impl TimerObjectInternals { .timer .maps .get(self.flags.get().kind()) - .put(self.id, elt) + .put_no_clobber(self.id, elt) }?; } - Ok(JSValue::js_number(f64::from(self.id))) + Ok(JSValue::js_number_from_uint64(self.id)) } /// Getter for `_destroyed` diff --git a/src/runtime/valkey_jsc/js_valkey.rs b/src/runtime/valkey_jsc/js_valkey.rs index f90e8acd8603..6a49a168bc10 100644 --- a/src/runtime/valkey_jsc/js_valkey.rs +++ b/src/runtime/valkey_jsc/js_valkey.rs @@ -1295,7 +1295,9 @@ impl JSValkeyClient { }; Js::hello_set_cached(this_value, &global_object, hello_value); // Call onConnect callback if defined by the user - if let Some(on_connect) = Js::onconnect_get_cached(this_value) { + if let Some(on_connect) = Js::onconnect_get_cached(this_value) + .filter(|handler| handler.without_async_context().is_callable()) + { let js_value = this_value; js_value.ensure_still_alive(); global_object.queue_microtask(on_connect, &[js_value, hello_value]); @@ -1342,7 +1344,6 @@ impl JSValkeyClient { } pub(crate) fn on_valkey_unsubscribe(&self) -> JsResult<()> { - debug_assert!(self.is_subscriber()); debug_assert!(self.this_value.get().is_strong()); self.client_mut().on_writable(); @@ -1442,7 +1443,9 @@ impl JSValkeyClient { } // Call onClose callback if it exists - if let Some(on_close) = Js::onclose_get_cached(this_jsvalue) { + if let Some(on_close) = Js::onclose_get_cached(this_jsvalue) + .filter(|handler| handler.without_async_context().is_callable()) + { on_close.call(&global_object, this_jsvalue, &[error_value])?; } Ok(()) diff --git a/src/runtime/valkey_jsc/js_valkey_functions.rs b/src/runtime/valkey_jsc/js_valkey_functions.rs index aedb635a630e..e5ef9ce9dfb5 100644 --- a/src/runtime/valkey_jsc/js_valkey_functions.rs +++ b/src/runtime/valkey_jsc/js_valkey_functions.rs @@ -1,4 +1,4 @@ -use crate::node::{BlobOrStringOrBuffer as JSArgument, FileBlobs}; +use crate::node::{BlobOrStringOrBuffer as JSArgument, FileBlobs, StringOrBuffer}; use bun_collections::VecExt as _; use bun_jsc::{ self as jsc, CallFrame, ErrorCode, JSGlobalObject, JSPromise, JSPropertyIterator, JSValue, @@ -70,6 +70,16 @@ fn from_js(global: &JSGlobalObject, value: JSValue) -> JsResult s3.unlink( - store, + store.clone(), &global_this.js_thread_of_caller(callframe), args.next_eat(), ), @@ -1815,10 +1815,12 @@ impl BlobExt for Blob { return Ok(unsafe { BlobExt::to_js(&*ptr, global_this) }); } + let size_i64 = i64::try_from(self.size.get()).unwrap_or(i64::MAX); + // If the optional start parameter is not used as a parameter, let relativeStart be 0. let mut relative_start: i64 = 0; // If the optional end parameter is not used, let relativeEnd be size. - let mut relative_end: i64 = i64::try_from(self.size.get()).expect("int cast"); + let mut relative_end: i64 = size_i64; // Mutate the fixed-3 args array in place to shift the string arg into [2]. if args[0].is_string() { @@ -1835,11 +1837,9 @@ impl BlobExt for Blob { if start_.is_number() { let start = start_.to_int64(); if start < 0 { - relative_start = (start - .wrapping_add(i64::try_from(self.size.get()).expect("int cast"))) - .max(0); + relative_start = start.saturating_add(size_i64).max(0); } else { - relative_start = start.min(i64::try_from(self.size.get()).expect("int cast")); + relative_start = start.min(size_i64); } } } @@ -1848,11 +1848,9 @@ impl BlobExt for Blob { if end_.is_number() { let end = end_.to_int64(); if end < 0 { - relative_end = (end - .wrapping_add(i64::try_from(self.size.get()).expect("int cast"))) - .max(0); + relative_end = end.saturating_add(size_i64).max(0); } else { - relative_end = end.min(i64::try_from(self.size.get()).expect("int cast")); + relative_end = end.min(size_i64); } } } @@ -2706,6 +2704,7 @@ impl BlobExt for Blob { let buf = unsafe { &*buf }; match crate::webcore::form_data::FormData::to_js(global, buf, &encoder.encoding) { Ok(v) => v, + Err(crate::Error::JSError) => global.take_error(jsc::JsError::Thrown), Err(err) => { global.create_error_instance(format_args!("FormData encoding failed: {err}")) } @@ -3862,10 +3861,11 @@ fn on_structured_clone_deserialize>( } PathOrFileDescriptorSerializeTag::Path => { let path_len = reader.read_int_le::()?; + // Apply the same checks as the JS API (`Valid::path_string_length`, `Valid::path_null_bytes`). + if path_len as usize >= bun_paths::MAX_PATH_BYTES { + return Err(crate::Error::InvalidValue); + } let path = read_slice(reader, path_len as usize)?; - // Same constraint the JS entry (`Valid::path_null_bytes`) - // enforces: a NUL-embedded path cannot be handed to the - // syscall layer (`ZStr::as_cstr` would truncate / panic). if strings::index_of_char(&path, 0).is_some() { return Err(crate::Error::InvalidValue); } @@ -3924,7 +3924,8 @@ fn on_structured_clone_deserialize>( blob.offset.set(offset as SizeType); // intentional truncate if let Some(size) = file_size { // resolve_size() clamps this to the actual file size on first use. - if size != MAX_SIZE { + // A size of MAX_SIZE or more means the size is unknown. + if size < MAX_SIZE { blob.size.set(size as SizeType); } } diff --git a/src/runtime/webcore/Body.rs b/src/runtime/webcore/Body.rs index 558c917fa360..1368a40e588a 100644 --- a/src/runtime/webcore/Body.rs +++ b/src/runtime/webcore/Body.rs @@ -2129,6 +2129,11 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized { let js_value = match webcore::form_data::FormData::to_js(global_object, blob.slice(), &encoding) { Ok(v) => v, + Err(crate::Error::JSError) => { + blob.detach(); + let error = global_object.take_error(jsc::JsError::Thrown); + return Ok(JSPromise::rejected_promise(global_object, error).to_js()); + } Err(err) => { blob.detach(); return Ok(global_object diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index 1aea4c7d7fa6..65329f705716 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -776,8 +776,10 @@ impl FileSink { return sys::Result::Err(err); } sys::Result::Ok(()) => { - self.writer - .with_mut(|w| w.update_ref(self.io_evtloop(), false)); + self.writer.with_mut(|w| { + w.owns_fd = true; + w.update_ref(self.io_evtloop(), false); + }); } } return sys::Result::Ok(()); @@ -791,6 +793,9 @@ impl FileSink { return sys::Result::Err(err); } sys::Result::Ok(()) => { + // `open_for_writing` opened or dup'd `fd` for this sink, so `end()` must close it. + #[cfg(windows)] + self.writer.with_mut(|w| w.owns_fd = true); // Only keep the event loop ref'd while there's a pending write in progress. // If there's no pending write, no need to keep the event loop ref'd. self.writer @@ -1068,9 +1073,7 @@ impl FileSink { // Per-wrapper accounting is on `ref_count` directly: each path that // hands `self` to C++ (`to_js` / `to_js_with_destructor`) takes a +1 // via `self.ref_()`, and `finalize`'s `deref()` below releases it. - // `JsSinkType::construct` allocates with `ref_count=1` and that +1 - // belongs to the wrapper it's about to be stored in, so no extra - // `ref_()` there. Callers that allocate via `init`/`create` and then + // Callers that allocate via `init`/`create` and then // `to_js()` must `deref()` once to release init's +1 (see // `Blob::get_writer`). `pending`/`pipe` are left for // `deinit` (Box drop) since in-flight IO may still need them. @@ -1147,23 +1150,6 @@ impl FileSink { this } - // Called by JSSink codegen on a pre-allocated `m_ctx` slot via - // `JsSinkType::construct(&mut MaybeUninit)`, which `write`s this - // by-value result into the slot. - pub(crate) fn construct() -> FileSink { - // `construct` is only called from JSSink codegen on a thread that - // already has a Bun VM (`get()` panics otherwise); `event_loop()` is - // the live per-thread `jsc::EventLoop`. - FileSink::new( - EventLoopHandle::init( - (*bun_jsc::VirtualMachineRef::get()) - .event_loop() - .cast::<()>(), - ), - Fd::INVALID, - ) - } - pub fn write(&self, data: &streams::Result) -> streams::Writable { if self.done.get() { return streams::Writable::Done; @@ -1476,7 +1462,6 @@ crate::impl_js_sink_abi!(FileSink, "FileSink"); // `Sink.JSSink(@This(), "FileSink")`. impl crate::webcore::sink::JsSinkType for FileSink { const NAME: &'static str = "FileSink"; - const HAS_CONSTRUCT: bool = true; const HAS_FLUSH_FROM_JS: bool = true; const HAS_PROTECT_JS_WRAPPER: bool = true; const HAS_UPDATE_REF: bool = true; @@ -1493,11 +1478,6 @@ impl crate::webcore::sink::JsSinkType for FileSink { // SAFETY: same contract, forwarded. unsafe { Self::controller_finalize(this) } } - fn construct(this: &mut core::mem::MaybeUninit) { - // `Self::construct()` allocates with `ref_count=1`; that +1 belongs to - // the C++ `JSFileSink` wrapper `js_construct` is about to create. - this.write(Self::construct()); - } fn end_from_js(&mut self, cx: &bun_jsc::JsThread<'_>) -> sys::Result { Self::end_from_js(self, cx) } diff --git a/src/runtime/webcore/FormData.rs b/src/runtime/webcore/FormData.rs index 71420851f2f9..c0d5a1eb8250 100644 --- a/src/runtime/webcore/FormData.rs +++ b/src/runtime/webcore/FormData.rs @@ -40,6 +40,10 @@ impl AsyncFormDataExt for AsyncFormData { let js_value = match FormData::to_js(global, data, &self.encoding) { Ok(v) => v, + Err(crate::Error::JSError) => { + promise.reject(global, global.take_error(JsError::Thrown))?; + return Ok(()); + } Err(e) => { scoped_log!(FormData, "AsnycFormData.toJS -> failed "); promise.reject( @@ -163,10 +167,15 @@ pub(crate) fn to_js_from_multipart_data( struct Wrapper<'a> { global: &'a JSGlobalObject, form: &'a mut DOMFormData, + /// True after an append throws. The remaining entries are skipped. + threw: bool, } impl<'a> Wrapper<'a> { fn on_entry(wrap: &mut Self, name: bun_semver::String, field: &Field<'_>, buf: &[u8]) { + if wrap.threw { + return; + } let value_str: &[u8] = field.value; let key = EncodedSlice::utf8(name.slice(buf)); @@ -202,7 +211,7 @@ pub(crate) fn to_js_from_multipart_data( } } - wrap.form.append_blob( + let result = wrap.form.append_blob( wrap.global, &key, (&raw mut blob).cast::(), @@ -210,6 +219,7 @@ pub(crate) fn to_js_from_multipart_data( ); // `append_blob` dupes the content type; release this stack-local. blob.detach(); + wrap.threw = result.is_err(); } else { let value = EncodedSlice::utf8( // > Each part whose `Content-Disposition` header does not @@ -221,15 +231,23 @@ pub(crate) fn to_js_from_multipart_data( // > `charset` parameter. strings::without_utf8_bom(value_str), ); - wrap.form.append(&key, &value); + wrap.threw = wrap.form.append(wrap.global, &key, &value).is_err(); } } } { - let mut wrap = Wrapper { global, form }; + let mut wrap = Wrapper { + global, + form, + threw: false, + }; - if let Err(e) = for_each_multipart_entry(input, boundary, &mut wrap, Wrapper::on_entry) { + let parsed = for_each_multipart_entry(input, boundary, &mut wrap, Wrapper::on_entry); + if wrap.threw { + return Err(crate::Error::JSError); + } + if let Err(e) = parsed { scoped_log!(FormData, "failed to parse multipart data"); return Err(e); } diff --git a/src/runtime/webcore/ReadableStream.rs b/src/runtime/webcore/ReadableStream.rs index 3660c67d5b18..48de4fe7f82a 100644 --- a/src/runtime/webcore/ReadableStream.rs +++ b/src/runtime/webcore/ReadableStream.rs @@ -23,8 +23,9 @@ pub enum NativeWireResult { /// The sink was installed on the native source; the source will call /// `SinkHandle::end` itself when it's done. Wired, - /// Not a native source (no `Bytes`/`File` backing, or it already has a - /// sink). Fall through to the JS-pump path. + /// Not a native source this sink can use (no `Bytes`/`File` backing, it + /// already has a sink, or a consumer has started or locked the stream). + /// Nothing was written. Fall through to the JS-pump path. NotNative, /// The source was already done or errored. The sink was not left /// installed; the caller runs its own end-of-stream handling. @@ -149,6 +150,7 @@ unsafe extern "C" { safe fn ReadableStream__markConsumedAsBody(stream: JSValue, global: &JSGlobalObject); safe fn ReadableStream__closeConsumedAsBody(stream: JSValue, global: &JSGlobalObject); safe fn ReadableStream__lockNative(stream: JSValue, global: &JSGlobalObject); + safe fn ReadableStream__tryLockNative(stream: JSValue, global: &JSGlobalObject) -> bool; /// BunStreamSource.cpp: queue the adapter's close on the microtask queue. safe fn Bun__NativeStreamSourceAdapter__onClose(global: &JSGlobalObject, adapter: JSValue); safe fn ZigGlobalObject__createNativeReadableStream( @@ -307,11 +309,18 @@ impl ReadableStream { /// Mark the stream disturbed + locked-without-reader. Called by native /// fast-paths after wiring a `SinkHandle` directly so `.locked`, /// `.getReader()`, and body-mixin disturbed checks behave as they would - /// after `readStreamIntoSink` acquires a reader. + /// after `readStreamIntoSink` acquires a reader. It doesn't check the + /// stream's state, so callers must do that first. pub fn lock_native(&self, global_object: &JSGlobalObject) { ReadableStream__lockNative(self.value, global_object); } + /// Calls [`Self::lock_native`] if the stream hasn't been started or locked. + /// Otherwise returns `false` without touching the stream. + pub(crate) fn try_lock_native(&self, global_object: &JSGlobalObject) -> bool { + ReadableStream__tryLockNative(self.value, global_object) + } + /// Wire `sink` directly to this stream's native `ByteStream`/`FileReader` /// source, skipping the JS pump. `set_source` is called with the matching /// [`SourceHandle`](streams::SourceHandle) before the source is driven; @@ -329,11 +338,13 @@ impl ReadableStream { if let Some(byte_stream) = self.ptr.bytes() { if byte_stream.sink.get().is_none() { + if !self.try_lock_native(global) { + return NativeWireResult::NotNative; + } set_source(SourceHandle::ByteStream(byte_stream)); byte_stream.parent_const().set_sink_owner(owner_cell); byte_stream.sink.set(sink); byte_stream.sink_paused.set(false); - self.lock_native(global); byte_stream.signal_consumer_attached(); if let Some(err) = byte_stream.take_pending_error() { @@ -373,6 +384,9 @@ impl ReadableStream { if let Some(file_reader) = self.ptr.file() { if !file_reader.done.get() && file_reader.sink.get().is_none() { + if !self.try_lock_native(global) { + return NativeWireResult::NotNative; + } match file_reader.start_for_sink(global) { Some(Start::Err(e)) => { use bun_sys_jsc::SystemErrorJsc; @@ -380,12 +394,10 @@ impl ReadableStream { err_js.ensure_still_alive(); let err = StreamError::JSValue(jsc::strong::Optional::create(err_js, global)); - self.lock_native(global); file_reader.parent_const().end_locked_stream(Some(&err)); return NativeWireResult::EndedInline(Some(err)); } Some(Start::OwnedAndDone(bytes)) => { - self.lock_native(global); let _ = sink.write(&StreamResult::OwnedAndDone(bytes)); file_reader.parent_const().end_locked_stream(None); return NativeWireResult::EndedInline(None); @@ -396,7 +408,6 @@ impl ReadableStream { file_reader.parent_const().set_sink_owner(owner_cell); file_reader.sink.set(sink); file_reader.sink_paused.set(true); - self.lock_native(global); file_reader.pull_into_sink(); return NativeWireResult::Wired; } @@ -1419,7 +1430,10 @@ impl NewSource { streams::Result::TemporaryAndDone(_) | streams::Result::OwnedAndDone(_) | streams::Result::IntoArrayAndDone(_) => { - flags.put_index(cx.global(), 0, JSValue::TRUE)?; + // `flags` is a `pull()` argument. Bun's callers pass an array, but other code can pass anything. + if flags.is_array() { + flags.put_index(cx.global(), 0, JSValue::TRUE)?; + } result.to_js(cx) } _ => result.to_js(cx), diff --git a/src/runtime/webcore/S3Client.rs b/src/runtime/webcore/S3Client.rs index 72594dc7b4a2..b0814755e9dc 100644 --- a/src/runtime/webcore/S3Client.rs +++ b/src/runtime/webcore/S3Client.rs @@ -427,13 +427,13 @@ impl S3Client { global: &JSGlobalObject, callframe: &CallFrame, ) -> JsResult { - let (mut blob, options) = ptr.blob_and_options( + let (blob, options) = ptr.blob_and_options( global, callframe, "presign", MissingPathError::MissingOrInvalid, )?; - S3File::get_presign_url_from(&mut blob, global, options) + S3File::get_presign_url_from(&blob, global, options) } #[bun_jsc::host_fn(method)] @@ -550,9 +550,9 @@ impl S3Client { ptr.request_payer, )?; - let store = blob.store.get().as_ref().unwrap(); + let store = blob.store.get().as_ref().unwrap().clone(); store.data.as_s3().list_objects( - store, + store.clone(), &global.js_thread_of_caller(callframe), object_keys, options, @@ -571,11 +571,12 @@ impl S3Client { "unlink", MissingPathError::AlwaysMissingArgs, )?; - let store = blob.store.get().as_ref().unwrap(); - store - .data - .as_s3() - .unlink(store, &global.js_thread_of_caller(callframe), options) + let store = blob.store.get().as_ref().unwrap().clone(); + store.data.as_s3().unlink( + store.clone(), + &global.js_thread_of_caller(callframe), + options, + ) } // ── Static methods ──────────────────────────────────────────────────── @@ -658,9 +659,9 @@ impl S3Client { &existing_credentials, )?; - let store = blob.store.get().as_ref().unwrap(); + let store = blob.store.get().as_ref().unwrap().clone(); store.data.as_s3().list_objects( - store, + store.clone(), &global.js_thread_of_caller(callframe), object_keys, options, diff --git a/src/runtime/webcore/S3File.rs b/src/runtime/webcore/S3File.rs index 31c581ae001d..4f5958e39bd9 100644 --- a/src/runtime/webcore/S3File.rs +++ b/src/runtime/webcore/S3File.rs @@ -160,8 +160,8 @@ pub(crate) fn presign(global: &JSGlobalObject, callframe: &CallFrame) -> JsResul let error_message = "Expected a S3 or path to presign"; let path_or_blob = parse_s3_path_or_blob(global, &mut args, error_message)?; - let (mut blob, options) = resolve_s3_blob(global, &mut args, path_or_blob, error_message)?; - get_presign_url_from(&mut blob, global, options) + let (blob, options) = resolve_s3_blob(global, &mut args, path_or_blob, error_message)?; + get_presign_url_from(&blob, global, options) } #[bun_jsc::host_fn] @@ -173,11 +173,12 @@ pub(crate) fn unlink(global: &JSGlobalObject, callframe: &CallFrame) -> JsResult let error_message = "Expected a S3 or path to delete"; let path_or_blob = parse_s3_path_or_blob(global, &mut args, error_message)?; let (blob, options) = resolve_s3_blob(global, &mut args, path_or_blob, error_message)?; - let store = blob.store.get().as_ref().unwrap(); - store - .data - .as_s3() - .unlink(store, &global.js_thread_of_caller(callframe), options) + let store = blob.store.get().as_ref().unwrap().clone(); + store.data.as_s3().unlink( + store.clone(), + &global.js_thread_of_caller(callframe), + options, + ) } #[bun_jsc::host_fn] @@ -374,7 +375,7 @@ impl S3BlobStatTask { result: s3::S3StatResult, this: *mut core::ffi::c_void, ) -> JsResult<()> { - // SAFETY: `this` was allocated via heap::alloc in `exists`; reconstructing here replaces `defer this.deinit()` + // SAFETY: `this` is the box `start` leaked with heap::into_raw. Taking it back frees it on return. let mut this = unsafe { bun_core::heap::take(this.cast::()) }; // Copy the BackRef out so `this` is not borrowed across `&mut this.promise`. let global_ref = this.global; @@ -405,7 +406,7 @@ impl S3BlobStatTask { } fn on_s3_size_resolved(result: s3::S3StatResult, this: *mut core::ffi::c_void) -> JsResult<()> { - // SAFETY: `this` was allocated via heap::alloc in `size`; reconstructing here replaces `defer this.deinit()` + // SAFETY: `this` is the box `start` leaked with heap::into_raw. Taking it back frees it on return. let mut this = unsafe { bun_core::heap::take(this.cast::()) }; // Copy the BackRef out so `this` is not borrowed across `&mut this.promise`. let global_ref = this.global; @@ -430,7 +431,7 @@ impl S3BlobStatTask { } fn on_s3_stat_resolved(result: s3::S3StatResult, this: *mut core::ffi::c_void) -> JsResult<()> { - // SAFETY: `this` was allocated via heap::alloc in `stat`; reconstructing here replaces `defer this.deinit()` + // SAFETY: `this` is the box `start` leaked with heap::into_raw. Taking it back frees it on return. let mut this = unsafe { bun_core::heap::take(this.cast::()) }; // Copy the BackRef out so `this` is not borrowed across `&mut this.promise`. let global_ref = this.global; @@ -464,78 +465,71 @@ impl S3BlobStatTask { Ok(()) } - pub(crate) fn exists(cx: &bun_jsc::JsThread<'_>, blob: &Blob) -> JsResult { + /// Sends the HEAD request behind `exists()`, `size()` and `stat()`. + fn start( + cx: &bun_jsc::JsThread<'_>, + blob: &Blob, + verb: &str, + callback: fn(s3::S3StatResult, *mut core::ffi::c_void) -> JsResult<()>, + ) -> JsResult { + let store = match s3_store(blob, cx.global(), verb) { + Ok(store) => store, + Err(err) => { + return Ok(bun_jsc::JSPromise::rejected_promise_with_caught_exception( + cx.global(), + err, + )? + .to_js()); + } + }; let this = S3BlobStatTask::new(S3BlobStatTask { promise: bun_jsc::JSPromiseStrong::init(cx.global()), - store: blob.store.get().as_ref().unwrap().clone(), + store, global: bun_ptr::BackRef::new(cx.global()), }); // SAFETY: `this` is a freshly leaked Box; sole pointer until handed to the s3 // callback below. Scoped shared access. - let promise = unsafe { (*this).promise.value() }; - let s3_store = blob.store.get().as_ref().unwrap().data.as_s3(); - let credentials = s3_store.get_credentials(); - let path = s3_store.path(); + let task = unsafe { &*this }; + let (promise, s3_store) = (task.promise.value(), task.store.data.as_s3()); s3::stat( - credentials, + s3_store.get_credentials(), cx.context(), - path, - S3BlobStatTask::on_s3_exists_resolved, + s3_store.path(), + callback, this.cast::(), s3_store.request_payer, )?; Ok(promise) } + pub(crate) fn exists(cx: &bun_jsc::JsThread<'_>, blob: &Blob) -> JsResult { + Self::start(cx, blob, "exists", S3BlobStatTask::on_s3_exists_resolved) + } + pub(crate) fn stat(cx: &bun_jsc::JsThread<'_>, blob: &Blob) -> JsResult { - let this = S3BlobStatTask::new(S3BlobStatTask { - promise: bun_jsc::JSPromiseStrong::init(cx.global()), - store: blob.store.get().as_ref().unwrap().clone(), - global: bun_ptr::BackRef::new(cx.global()), - }); - // SAFETY: `this` is a freshly leaked Box; sole pointer until handed to the s3 - // callback below. Scoped shared access. - let promise = unsafe { (*this).promise.value() }; - let s3_store = blob.store.get().as_ref().unwrap().data.as_s3(); - let credentials = s3_store.get_credentials(); - let path = s3_store.path(); - s3::stat( - credentials, - cx.context(), - path, - S3BlobStatTask::on_s3_stat_resolved, - this.cast::(), - s3_store.request_payer, - )?; - Ok(promise) + Self::start(cx, blob, "stat", S3BlobStatTask::on_s3_stat_resolved) } pub(crate) fn size(cx: &bun_jsc::JsThread<'_>, blob: &mut Blob) -> JsResult { - let this = S3BlobStatTask::new(S3BlobStatTask { - promise: bun_jsc::JSPromiseStrong::init(cx.global()), - store: blob.store.get().as_ref().unwrap().clone(), - global: bun_ptr::BackRef::new(cx.global()), - }); - // SAFETY: `this` is a freshly leaked Box; sole pointer until handed to the s3 - // callback below. Scoped shared access. - let promise = unsafe { (*this).promise.value() }; - let s3_store = blob.store.get().as_ref().unwrap().data.as_s3(); - let credentials = s3_store.get_credentials(); - let path = s3_store.path(); - s3::stat( - credentials, - cx.context(), - path, - S3BlobStatTask::on_s3_size_resolved, - this.cast::(), - s3_store.request_payer, - )?; - Ok(promise) + Self::start(cx, blob, "size", S3BlobStatTask::on_s3_size_resolved) } // Teardown (store deref, promise deinit, freeing the box) is handled by Box Drop. } +/// Returns an owned ref to `blob`'s S3 store, because the option getters that run next can detach it from `blob`. +fn s3_store(blob: &Blob, global: &JSGlobalObject, verb: &str) -> JsResult> { + match blob.store().cloned() { + Some(store) if matches!(store.data, blob::store::Data::S3(_)) => Ok(store), + _ => Err(global + .err( + bun_jsc::ErrorCode::INVALID_THIS, + format_args!("{verb} is only possible for s3:// files"), + ) + .throw()), + } +} + // `Method.fromJS` lives in `bun_http_jsc` so `bun_http_types` stays // JSC-free. Thin local alias for the `getPresignUrlFrom` body. #[inline] @@ -544,23 +538,15 @@ fn method_from_js(global: &JSGlobalObject, value: JSValue) -> JsResult, ) -> JsResult { - if !this.is_s3() { - return Err(global - .err( - bun_jsc::ErrorCode::INVALID_THIS, - format_args!("presign is only possible for s3:// files"), - ) - .throw()); - } - let mut method: Method = Method::GET; let mut expires: usize = 86400; // 1 day default - let s3 = this.store.get().as_ref().unwrap().data.as_s3(); + let store = s3_store(this, global, "presign")?; + let s3 = store.data.as_s3(); // `acl`/`storage_class`/`content_*` deliberately stay at their `None` // defaults here — they are only seeded from the store when extra_options // is provided (via `getCredentialsWithOptions` below). @@ -657,7 +643,7 @@ fn get_bucket(this: &Blob, global: &JSGlobalObject) -> JsResult { } fn get_presign_url( - this: &mut Blob, + this: &Blob, global: &JSGlobalObject, callframe: &CallFrame, ) -> JsResult { @@ -730,7 +716,7 @@ pub(crate) mod exports { callframe: *mut CallFrame, ) -> JSValue { // SAFETY: JSC method shim passes live `m_ctx`/global/callframe. - let (this, global, callframe) = unsafe { (&mut *this, &*global, &*callframe) }; + let (this, global, callframe) = unsafe { (&*this, &*global, &*callframe) }; bun_jsc::to_js_host_call(global, || super::get_presign_url(this, global, callframe)) } @@ -742,7 +728,7 @@ pub(crate) mod exports { callframe: *mut CallFrame, ) -> JSValue { // SAFETY: JSC method shim passes live `m_ctx`/global/callframe. - let (this, global, callframe) = unsafe { (&mut *this, &*global, &*callframe) }; + let (this, global, callframe) = unsafe { (&*this, &*global, &*callframe) }; bun_jsc::to_js_host_call(global, || super::get_stat(this, global, callframe)) } } diff --git a/src/runtime/webcore/Sink.rs b/src/runtime/webcore/Sink.rs index c089fec98023..b282a1c39fa2 100644 --- a/src/runtime/webcore/Sink.rs +++ b/src/runtime/webcore/Sink.rs @@ -562,6 +562,15 @@ impl JSSink { } let view = arg.to_js_string_view(global)?; + let this = if arg.is_string_literal() { + this + } else { + // Converting a String object runs user JS, which can free the sink. + let Some(this) = Self::get_this(global, frame)? else { + return Ok(JSValue::js_number(0.0)); + }; + this + }; if view.is_empty() { return Ok(JSValue::js_number(0.0)); } diff --git a/src/runtime/webcore/TextDecoder.rs b/src/runtime/webcore/TextDecoder.rs index fb0f0e9d7175..04256c85a583 100644 --- a/src/runtime/webcore/TextDecoder.rs +++ b/src/runtime/webcore/TextDecoder.rs @@ -251,7 +251,7 @@ impl TextDecoder { unsafe { core::slice::from_raw_parts_mut(units.as_mut_ptr(), out_length) }; let out = strings::copy_cp1252_into_utf16(buf, buffer_slice); // SAFETY: the copy above initialized `out.written` (≤ `out_length`) units. - unsafe { units.set_len(out.written as usize) }; + unsafe { units.set_len(out.written) }; heap.create_utf16_string_from_vec(units) } EncodingLabel::Utf8 => { diff --git a/src/runtime/webcore/TextEncoder.rs b/src/runtime/webcore/TextEncoder.rs index a8c14102ab30..e09d982e4c57 100644 --- a/src/runtime/webcore/TextEncoder.rs +++ b/src/runtime/webcore/TextEncoder.rs @@ -44,8 +44,8 @@ unsafe extern "C" fn TextEncoder__encode8( }; debug_assert!(array_buffer.len == utf8_len); let result = strings::copy_latin1_into_utf8(array_buffer.byte_slice_mut(), slice); - debug_assert!(result.written as usize == utf8_len); - debug_assert!(result.read as usize == slice.len()); + debug_assert!(result.written == utf8_len); + debug_assert!(result.read == slice.len()); uint8array } @@ -69,8 +69,8 @@ fn encode16_impl(global_this: &JSGlobalObject, slice: &[u16]) -> JSValue { if result.read == 0 || result.written == 0 { return replacement_char_uint8_array(global_this); } - let written = result.written as usize; - debug_assert!(result.read as usize == slice.len()); + let written = result.written; + debug_assert!(result.read == slice.len()); let Ok(uint8array) = create_uninitialized_uint8_array(global_this, written) else { return JSValue::ZERO; }; @@ -99,7 +99,7 @@ fn encode16_impl(global_this: &JSGlobalObject, slice: &[u16]) -> JSValue { debug_assert!(array_buffer.len == need); let result = strings::copy_utf16_into_utf8_with_utf8_len(array_buffer.byte_slice_mut(), slice, need); - if result.written as usize == need && result.read as usize == slice.len() { + if result.written == need && result.read == slice.len() { return uint8array; } @@ -177,11 +177,11 @@ impl<'a> RopeStringEncoder<'a> { &mut this.buf[this.tail..], src, ); - if result.read == u32::MAX && result.written == u32::MAX { + if result.read == usize::MAX && result.written == usize::MAX { it.stop = 1; this.any_non_ascii = true; } else { - this.tail += result.written as usize; + this.tail += result.written; } } @@ -199,7 +199,7 @@ impl<'a> RopeStringEncoder<'a> { &mut this.buf[offset as usize..], src, ); - if result.read == u32::MAX && result.written == u32::MAX { + if result.read == usize::MAX && result.written == usize::MAX { it.stop = 1; this.any_non_ascii = true; } @@ -267,17 +267,12 @@ unsafe extern "C" fn TextEncoder__encodeInto16( input_len: usize, buf_ptr: *mut u8, buf_len: usize, -) -> u64 { +) -> strings::EncodeIntoResult { // SAFETY: caller guarantees buf_ptr[0..buf_len] is a valid mutable buffer let output = unsafe { core::slice::from_raw_parts_mut(buf_ptr, buf_len) }; // SAFETY: caller guarantees input_ptr[0..input_len] is valid UTF-16 data let input = unsafe { core::slice::from_raw_parts(input_ptr, input_len) }; - let result: strings::EncodeIntoResult = strings::copy_utf16_into_utf8(output, input); - // Pack `read` at byte offset 0 and `written` at offset 4 via native-endian bytes — no `unsafe`. - let mut b = [0u8; 8]; - b[..4].copy_from_slice(&result.read.to_ne_bytes()); - b[4..].copy_from_slice(&result.written.to_ne_bytes()); - u64::from_ne_bytes(b) + strings::copy_utf16_into_utf8(output, input) } /// # Safety @@ -289,15 +284,10 @@ unsafe extern "C" fn TextEncoder__encodeInto8( input_len: usize, buf_ptr: *mut u8, buf_len: usize, -) -> u64 { +) -> strings::EncodeIntoResult { // SAFETY: caller guarantees buf_ptr[0..buf_len] is a valid mutable buffer let output = unsafe { core::slice::from_raw_parts_mut(buf_ptr, buf_len) }; // SAFETY: caller guarantees input_ptr[0..input_len] is valid Latin-1 data let input = unsafe { core::slice::from_raw_parts(input_ptr, input_len) }; - let result: strings::EncodeIntoResult = strings::copy_latin1_into_utf8(output, input); - // Pack `read` at byte offset 0 and `written` at offset 4 via native-endian bytes — no `unsafe`. - let mut b = [0u8; 8]; - b[..4].copy_from_slice(&result.read.to_ne_bytes()); - b[4..].copy_from_slice(&result.written.to_ne_bytes()); - u64::from_ne_bytes(b) + strings::copy_latin1_into_utf8(output, input) } diff --git a/src/runtime/webcore/TextEncoderStreamEncoder.rs b/src/runtime/webcore/TextEncoderStreamEncoder.rs index d4cb41203daf..091babcd52aa 100644 --- a/src/runtime/webcore/TextEncoderStreamEncoder.rs +++ b/src/runtime/webcore/TextEncoderStreamEncoder.rs @@ -73,10 +73,10 @@ impl TextEncoderStreamEncoder { let result = unsafe { bun_core::vec::fill_spare(buffer, 0, |spare| { let r = strings::copy_latin1_into_utf8(spare, remain); - (r.written as usize, r) + (r.written, r) }) }; - remain = &remain[result.read as usize..]; + remain = &remain[result.read..]; if result.written == 0 && result.read == 0 { buffer.try_reserve(2).map_err(|_| AllocError)?; diff --git a/src/runtime/webcore/blob/Store.rs b/src/runtime/webcore/blob/Store.rs index 33a6aaa89aba..0025f5d749e5 100644 --- a/src/runtime/webcore/blob/Store.rs +++ b/src/runtime/webcore/blob/Store.rs @@ -68,16 +68,17 @@ pub(crate) trait S3Ext { global_object: &JSGlobalObject, ) -> JsResult; /// `store` is the heap `Store` that owns `self` (`self == &store.data.S3`). + /// It's an owned ref because reading the options runs user JS, which can detach the store from its Blob. fn unlink( &self, - store: &RefPtr, + store: RefPtr, cx: &bun_jsc::JsThread<'_>, extra_options: Option, ) -> JsResult; /// See `unlink`. fn list_objects( &self, - store: &RefPtr, + store: RefPtr, cx: &bun_jsc::JsThread<'_>, list_options: JSValue, extra_options: Option, @@ -270,7 +271,7 @@ impl S3Ext for S3 { fn unlink( &self, - store: &RefPtr, + store: RefPtr, cx: &bun_jsc::JsThread<'_>, extra_options: Option, ) -> JsResult { @@ -324,7 +325,7 @@ impl S3Ext for S3 { Wrapper::resolve, bun_core::heap::into_raw(Wrapper::new(Wrapper { promise, - store: store.clone(), + store, global: bun_ptr::BackRef::new(cx.global()), })) .cast::(), @@ -336,7 +337,7 @@ impl S3Ext for S3 { fn list_objects( &self, - store: &RefPtr, + store: RefPtr, cx: &bun_jsc::JsThread<'_>, list_options: JSValue, extra_options: Option, @@ -407,7 +408,7 @@ impl S3Ext for S3 { // `Drop` after the async callback. let wrapper = bun_core::heap::into_raw(Box::new(Wrapper { promise, - store: store.clone(), + store, resolved_list_options: options, global: bun_ptr::BackRef::new(cx.global()), })); diff --git a/src/runtime/webcore/blob/read_file.rs b/src/runtime/webcore/blob/read_file.rs index d6ed99f1f2e2..e37f1b844e29 100644 --- a/src/runtime/webcore/blob/read_file.rs +++ b/src/runtime/webcore/blob/read_file.rs @@ -487,7 +487,15 @@ impl ReadFile { /// caller finishes it): fail with ECANCELED. #[cfg(not(windows))] fn fail_cancelled(&mut self) { - let err = bun_sys::Error::from_code(bun_sys::E::ECANCELED, bun_sys::Tag::read); + self.fail_wait(&bun_sys::Error::from_code( + bun_sys::E::ECANCELED, + bun_sys::Tag::read, + )); + } + + /// Records `err` when the wait on the io loop couldn't start. The caller finishes the job. + #[cfg(not(windows))] + fn fail_wait(&mut self, err: &bun_sys::Error) { self.errno = Some(bun_errno::from_errno(err.errno as i32).into()); self.system_error = Some(err.to_system_error().into()); self.state @@ -500,6 +508,10 @@ impl ReadFile { #[cfg(not(windows))] pub(crate) fn wait_for_readable(&mut self) { bloblog!("ReadFile.waitForReadable"); + if let Err(err) = io::IoRequestLoop::ensure_init() { + self.fail_wait(&err); + return self.on_finish(); + } if !self.io_parking.park() { self.fail_cancelled(); return self.on_finish(); diff --git a/src/runtime/webcore/blob/write_file.rs b/src/runtime/webcore/blob/write_file.rs index 1b0cf6612a57..42895c40d1a7 100644 --- a/src/runtime/webcore/blob/write_file.rs +++ b/src/runtime/webcore/blob/write_file.rs @@ -263,7 +263,12 @@ impl WriteFile { /// See `ReadFile::fail_cancelled`. #[cfg(not(windows))] fn fail_cancelled(&mut self) { - let err = sys::Error::from_code(sys::E::ECANCELED, sys::Tag::write); + self.fail_wait(&sys::Error::from_code(sys::E::ECANCELED, sys::Tag::write)); + } + + /// See `ReadFile::fail_wait`. + #[cfg(not(windows))] + fn fail_wait(&mut self, err: &sys::Error) { self.errno = Some(bun_errno::from_errno(err.errno as i32).into()); self.system_error = Some(err.to_system_error().into()); self.state @@ -274,6 +279,10 @@ impl WriteFile { /// `self` again. #[cfg(not(windows))] pub(crate) fn wait_for_writable(&mut self) { + if let Err(err) = io::IoRequestLoop::ensure_init() { + self.fail_wait(&err); + return self.on_finish(); + } if !self.io_parking.park() { self.fail_cancelled(); return self.on_finish(); diff --git a/src/runtime/webcore/encoding.rs b/src/runtime/webcore/encoding.rs index 2645db26fb3a..121e8e95d09d 100644 --- a/src/runtime/webcore/encoding.rs +++ b/src/runtime/webcore/encoding.rs @@ -505,13 +505,13 @@ pub(crate) fn write_u8( } Encoding::Utf8 => { let r = strings::copy_latin1_into_utf8(to, input); - let mut written = r.written as usize; + let mut written = r.written; // `copy_latin1_into_utf8` stops at whole code points. Under // byte-level truncation, a Latin-1 char >= 0x80 whose 2-byte // sequence straddles the end still gets its lead byte. - if ALLOW_PARTIAL_WRITE && written < to.len() && (r.read as usize) < input.len() { - debug_assert!(input[r.read as usize] >= 0x80); - to[written] = 0xC0 | (input[r.read as usize] >> 6); + if ALLOW_PARTIAL_WRITE && written < to.len() && r.read < input.len() { + debug_assert!(input[r.read] >= 0x80); + to[written] = 0xC0 | (input[r.read] >> 6); written += 1; } Ok(written) @@ -526,7 +526,7 @@ pub(crate) fn write_u8( 0 } else if (to.as_ptr() as usize).is_multiple_of(core::mem::align_of::()) { let output: &mut [u16] = bytemuck::cast_slice_mut(&mut to[..out_units * 2]); - strings::copy_latin1_into_utf16(output, buf).written as usize * 2 + strings::copy_latin1_into_utf16(output, buf).written * 2 } else { // Rust `&mut [u16]` requires natural alignment, so inline the // (trivial) widen loop for the misaligned-dest case @@ -610,7 +610,7 @@ pub(crate) unsafe fn write_u16(to_slice, input_slice) - .written as usize, + .written, ) } Encoding::Latin1 | Encoding::Ascii | Encoding::Buffer => { diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index dc2e3beee39f..f31aedd4a5eb 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -1233,9 +1233,10 @@ fn fetch_impl( } Some(from_fetch_headers( + global_this, Some(headers_ref), any_blob_content_type_opt(body.get_any_blob().map(|b| &*b)), - )) + )?) } else { headers }; @@ -1327,6 +1328,10 @@ fn fetch_impl( } }; let url_path_decoded = &path_buf2[0..decoded_len as usize]; + // `open` would truncate the path at a decoded `%00`. `Bun.file(url)` and node:fs reject it the same way. + if url_type == URLType::File { + crate::node::types::Valid::path_null_bytes(url_path_decoded, global_this)?; + } let url_string: BunString; @@ -1395,7 +1400,7 @@ fn fetch_impl( } }; #[cfg(not(windows))] - let cwd = bun_resolver::fs::FileSystem::get().top_level_dir; + let cwd = bun_resolver::fs::FileSystem::get().top_level_dir(); // SAFETY: bun_vm() returns the live thread-local VM pointer. // Relative to the folder of the entry module, or to the working directory when there @@ -1490,9 +1495,10 @@ fn fetch_impl( if headers.is_none() && body.has_body() && body.has_content_type_from_user() { headers = Some(from_fetch_headers( + global_this, None, any_blob_content_type_opt(body.get_any_blob().map(|b| &*b)), - )); + )?); } // Userinfo in the URL is the request's credentials, like curl and diff --git a/src/runtime/webcore/fetch/FetchSession.rs b/src/runtime/webcore/fetch/FetchSession.rs index 182b04197b0a..c16e982b689b 100644 --- a/src/runtime/webcore/fetch/FetchSession.rs +++ b/src/runtime/webcore/fetch/FetchSession.rs @@ -140,9 +140,9 @@ pub(crate) fn parse_proxy(global: &JSGlobalObject, proxy_arg: JSValue) -> JsResu if !headers_value.is_undefined_or_null() { if let Some(fetch_headers) = FetchHeaders::cast(headers_value) { let fetch_headers = bun_ptr::BackRef::from(fetch_headers); - headers = Some(from_fetch_headers(Some(&*fetch_headers), None)); + headers = Some(from_fetch_headers(global, Some(&*fetch_headers), None)?); } else if let Some(fetch_headers) = HeadersRef::create_from_js(global, headers_value)? { - headers = Some(from_fetch_headers(Some(&fetch_headers), None)); + headers = Some(from_fetch_headers(global, Some(&fetch_headers), None)?); } } } diff --git a/src/runtime/webcore/fetch/FetchTasklet.rs b/src/runtime/webcore/fetch/FetchTasklet.rs index 47483f238bbf..2f64e8ccc8cc 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.rs +++ b/src/runtime/webcore/fetch/FetchTasklet.rs @@ -326,6 +326,13 @@ jsc::impl_abort_handle_owner!(FetchTasklet, abort_handle, |this, cause| { unsafe { match cause { jsc::AbortCause::Signal(reason) => (*this).abort_listener(reason), + jsc::AbortCause::ContextStopped(jsc::StopReason::VmTeardown) => { + (*this).abort_task(); + if (*this).detach_native_request_body_sink() { + FetchTasklet::deref(this); + } + } + // The VM keeps running, so the final progress update cancels the request body sink. jsc::AbortCause::ContextStopped(_) => (*this).abort_task(), } } @@ -2492,6 +2499,29 @@ impl FetchTasklet { } } + /// Ends a request body sink that's wired to a native source. VM teardown drops the progress + /// update without running it and destroys the source without ending its sink. A sink fed by + /// a JS pump is finalized by its controller cell instead. + /// Returns true if the caller must release the ref from `start_request_stream`. + fn detach_native_request_body_sink(&mut self) -> bool { + let global_this = self.global_this; + let Some(sink) = self.sink_mut() else { + return false; + }; + if sink.ended + || !matches!( + sink.source, + SourceHandle::ByteStream(_) | SourceHandle::FileReader(_) + ) + { + return false; + } + sink.ended = true; + sink.done = true; + sink.source.detach(&global_this); + sink.task.take().is_some() + } + pub(crate) fn queue( cx: &bun_jsc::JsThread<'_>, fetch_options: FetchOptions, diff --git a/src/runtime/webcore/prompt.rs b/src/runtime/webcore/prompt.rs index 76c9facadd47..412cd2bf92ef 100644 --- a/src/runtime/webcore/prompt.rs +++ b/src/runtime/webcore/prompt.rs @@ -381,7 +381,6 @@ pub(crate) mod prompt { } debug_assert!(!input.is_empty()); - debug_assert!(input[input.len() - 1] != b'\r'); // 8. Let result be null if the user aborts, or otherwise the string // that the user responded with. diff --git a/src/runtime/webcore/s3/multipart.rs b/src/runtime/webcore/s3/multipart.rs index 3fbc3be70b2a..ff1dc4ab23b5 100644 --- a/src/runtime/webcore/s3/multipart.rs +++ b/src/runtime/webcore/s3/multipart.rs @@ -92,6 +92,7 @@ use core::cell::Cell; use core::ffi::c_void; use std::io::Write as _; +use std::rc::Rc; use bstr::BStr; @@ -268,11 +269,9 @@ pub(crate) enum PartState { } pub(crate) struct UploadPart { - /// Raw owned slice; backing allocation length is `allocated_size` (may exceed `data.len()`). - /// Freed via `free_allocated_slice`. Default is a static empty slice. - pub(crate) data: Cell<*const [u8]>, + /// Refcounted so `perform` can keep the bytes alive if the request frees the part. + pub(crate) data: JsCell>>>, pub ctx: bun_ptr::BackRef, // BACKREF (LIFETIMES.tsv) - pub(crate) allocated_size: Cell, pub(crate) state: Cell, pub(crate) part_number: Cell, // max is 10,000 pub(crate) retry: Cell, // auto retry, decrement until 0 and fail after this @@ -284,26 +283,18 @@ pub(crate) struct UploadPartResult { pub(crate) etag: Box<[u8]>, } -impl UploadPart { - fn free_allocated_slice(&self) { - let allocated_size = self.allocated_size.get(); - if allocated_size > 0 { - // SAFETY: `data.ptr` was allocated by the global allocator with capacity == allocated_size - // (either via `to_vec().into_boxed_slice()` where len==cap, or by taking ownership of - // StreamBuffer's backing allocation). Reconstruct and drop. - unsafe { - let ptr = (*self.data.get()).as_ptr().cast_mut(); - drop(Vec::from_raw_parts(ptr, allocated_size, allocated_size)); - } - } - self.data.set(std::ptr::from_ref::<[u8]>(b"" as &[u8])); - self.allocated_size.set(0); - } +/// Selects the bytes of `buffered` that a new part uploads. +#[derive(Clone, Copy)] +enum PartBytes { + /// All of them. The buffer's allocation is moved into the part. + Whole, + /// A copy of the first `usize` bytes. + First(usize), +} - #[inline] - fn data(&self) -> &[u8] { - // SAFETY: data is either a static empty slice or a live heap slice owned by this part - unsafe { &*self.data.get() } +impl UploadPart { + fn free_data(&self) { + self.data.set(None); } fn on_part_response(result: S3PartResult, this: *mut c_void) -> bun_jsc::JsResult<()> { @@ -316,7 +307,7 @@ impl UploadPart { if this.state.get() == PartState::Canceled || ctx.state.get() == State::Finished { scoped_log!(S3MultiPartUpload, "onPartResponse {} canceled", part_number); - this.free_allocated_slice(); + this.free_data(); MultiPartUpload::deref_(ctx_ptr); return Ok(()); } @@ -334,7 +325,7 @@ impl UploadPart { } else { scoped_log!(S3MultiPartUpload, "onPartResponse {} failed", part_number); this.state.set(PartState::NotAssigned); - this.free_allocated_slice(); + this.free_data(); // The ctx deref must run after fail(): let r = ctx.fail(err); MultiPartUpload::deref_(ctx_ptr); @@ -343,8 +334,8 @@ impl UploadPart { } S3PartResult::Etag(etag) => { scoped_log!(S3MultiPartUpload, "onPartResponse {} success", part_number); - let sent = this.data().len(); - this.free_allocated_slice(); + let sent = this.data.get().as_deref().map_or(0, Vec::len); + this.free_data(); // we will need to order this ctx.multipart_etags.with_mut(|etags| { etags.push(UploadPartResult { @@ -368,6 +359,7 @@ impl UploadPart { fn perform(&self) -> bun_jsc::JsResult<()> { let ctx = self.ctx.get(); + let data = self.data.get().clone(); let mut params_buffer = [0u8; 2048]; let written = { let mut w: &mut [u8] = &mut params_buffer[..]; @@ -388,7 +380,7 @@ impl UploadPart { path: &ctx.path, method: bun_http::Method::PUT, proxy_url: ctx.proxy_url(), - body: self.data(), + body: data.as_deref().map_or(&[][..], Vec::as_slice), search_params: Some(search_params), request_payer: ctx.request_payer, ..Default::default() @@ -413,7 +405,7 @@ impl UploadPart { match state { PartState::Pending => { - self.free_allocated_slice(); + self.free_data(); } // if is not pending we will free later or is already freed _ => {} @@ -424,7 +416,7 @@ impl UploadPart { impl Drop for MultiPartUpload { fn drop(&mut self) { scoped_log!(S3MultiPartUpload, "deinit"); - // queue: Box<[UploadPart]> — dropped automatically (parts' raw `data` already freed during lifecycle) + // queue: Box<[UploadPart]> — dropped automatically, with any `data` a part still owns // KeepAlive::unref takes an `EventLoopCtx` (aio cycle-break vtable), // not `&VirtualMachine`. Route through the global hook like simple_request does. let _ = self.vm; @@ -502,12 +494,8 @@ impl MultiPartUpload { } /// This is the only place we allocate the queue or the parts, this is responsible for the flow of parts and the max allowed concurrency - fn get_create_part( - &self, - chunk: &[u8], - allocated_size: usize, - needs_clone: bool, - ) -> Option<&UploadPart> { + /// The bytes are only removed from `buffered` if the queue has a free slot, and then the part owns them. + fn get_create_part(&self, bytes: PartBytes) -> Option<&UploadPart> { let mut available = self.available.get(); let Some(index) = available.find_first_set() else { // this means that the queue is full and we cannot flush it @@ -530,8 +518,7 @@ impl MultiPartUpload { // zero set just in case for _ in 0..queue_size { queue.push(UploadPart { - data: Cell::new(std::ptr::from_ref::<[u8]>(b"" as &[u8])), - allocated_size: Cell::new(0), + data: JsCell::new(None), part_number: Cell::new(0), ctx: self_ref, index: Cell::new(0), @@ -541,21 +528,16 @@ impl MultiPartUpload { } self.queue.set(Some(queue.into_boxed_slice())); } - let (data, allocated_len): (*const [u8], usize) = if needs_clone { - let owned = Box::<[u8]>::from(chunk); - let len = owned.len(); - (bun_core::heap::into_raw(owned).cast_const(), len) - } else { - (std::ptr::from_ref::<[u8]>(chunk), allocated_size) - }; - let part_number = self.current_part_number.get(); self.current_part_number.set(part_number + 1); let queue = self.queue.get().as_deref().expect("queue allocated above"); let queue_item = &queue[index]; - queue_item.data.set(data); - queue_item.allocated_size.set(allocated_len); + let data = match bytes { + PartBytes::Whole => self.buffered.take().list, + PartBytes::First(len) => self.buffered.get().slice()[..len].to_vec(), + }; + queue_item.data.set(Some(Rc::new(data))); queue_item.part_number.set(part_number); queue_item.index.set(index as u8); // @truncate queue_item.retry.set(self.options.get().retry); @@ -943,13 +925,9 @@ impl MultiPartUpload { ) } - fn enqueue_part( - &self, - chunk: &[u8], - allocated_size: usize, - needs_clone: bool, - ) -> bun_jsc::JsResult { - let Some(part) = self.get_create_part(chunk, allocated_size, needs_clone) else { + /// Returns `Ok(false)` without touching `buffered` if the queue is full. On `Err`, a part owns the bytes. + fn enqueue_part(&self, bytes: PartBytes) -> bun_jsc::JsResult { + let Some(part) = self.get_create_part(bytes) else { return Ok(false); }; @@ -1013,42 +991,26 @@ impl MultiPartUpload { } // if is one big chunk we can pass ownership and avoid dupe if self.buffered.get().cursor == 0 && self.buffered.get().size() == len { - let owned = self.buffered.replace(StreamBuffer::default()); - // we need to know the allocated size to free the memory later - let allocated_size = owned.memory_cost(); - let slice_len = owned.slice().len(); - // we dont care about the result because we are sending everything - if self.enqueue_part(owned.slice(), allocated_size, false)? { + if self.enqueue_part(PartBytes::Whole)? { scoped_log!( S3MultiPartUpload, "processMultiPart {} {} full buffer enqueued", BStr::new(&self.path), - slice_len + len + ); + } else { + scoped_log!( + S3MultiPartUpload, + "processMultiPart {} {} queue full", + BStr::new(&self.path), + len ); - let _ = core::mem::ManuallyDrop::new(owned); - return Ok(()); - } - let appended = self.buffered.replace(owned); - if appended.is_not_empty() { - self.buffered - .with_mut(|b| b.write(appended.slice()).map(|_| ())) - .unwrap_or(()); } - scoped_log!( - S3MultiPartUpload, - "processMultiPart {} {} queue full", - BStr::new(&self.path), - slice_len - ); - return Ok(()); } - let slice_ptr = std::ptr::from_ref::<[u8]>(&self.buffered.get().slice()[..len]); - // allocated size is the slice len because we dupe the buffer - // SAFETY: slice_ptr points at self.buffered's storage which is not mutated until after enqueue_part dupes it - if self.enqueue_part(unsafe { &*slice_ptr }, len, true)? { + if self.enqueue_part(PartBytes::First(len))? { scoped_log!( S3MultiPartUpload, "processMultiPart {} {} slice enqueued", diff --git a/src/runtime/webview/ChromeBackend.cpp b/src/runtime/webview/ChromeBackend.cpp index 8744953d8ad7..75400390daba 100644 --- a/src/runtime/webview/ChromeBackend.cpp +++ b/src/runtime/webview/ChromeBackend.cpp @@ -25,6 +25,7 @@ #include #include +#include #include #include #include @@ -606,6 +607,14 @@ static std::span sidSpan(const WTF::String& s) return { reinterpret_cast(span.data()), span.size() }; } +// Session ids are ASCII, and sidSpan relies on that. Returns the empty string (never null) for an invalid id. +static WTF::String decodeSessionId(std::span bytes) +{ + auto chars = byteCast(bytes); + if (chars.empty() || !charactersAreAllASCII(chars)) return WTF::emptyString(); + return WTF::String(chars); +} + // Bun click button → CDP button enum string. CDP's Input.dispatchMouseEvent // takes a string: "none", "left", "middle", "right". static constexpr ASCIILiteral cdpButton(uint8_t b) @@ -791,8 +800,14 @@ void Transport::handleResponse(uint32_t id, std::span result, std::s } case Method::TargetAttachToTarget: { // {"sessionId":""} - auto sid = jsonString(jsonField(result, { "sessionId", 9 })); - view->m_sessionId = WTF::String::fromUTF8(sid); + auto session = decodeSessionId(jsonString(jsonField(result, { "sessionId", 9 }))); + if (session.isEmpty()) { + // Commands can't reach the page without a session id, so the load can't finish. + settleFailure(g, view, entry.slot, entry.method, + createError(g, "malformed attach response"_s)); + return; + } + view->m_sessionId = WTF::move(session); // Route events to this view via its viewId — m_views holds the one // Weak per view. A view with a slot set is rooted via the owner // predicate reading m_pendingActivityCount. @@ -838,8 +853,11 @@ void Transport::handleResponse(uint32_t id, std::span result, std::s // sessionId — actually the event handler uses m_sessions, not // m_pending, so we just drop here. auto err = jsonString(jsonField(result, { "errorText", 9 })); - if (!err.empty()) - settleFailure(g, view, entry.slot, entry.method, createError(g, WTF::String::fromUTF8(err))); + if (!err.empty()) { + auto errStr = WTF::String::fromUTF8(err); // null if not valid UTF-8 + settleFailure(g, view, entry.slot, entry.method, + createError(g, errStr.isEmpty() ? "navigation failed"_s : errStr)); + } // Else: don't settle — Page.loadEventFired does. return; } @@ -1044,22 +1062,20 @@ void Transport::handleResponse(uint32_t id, std::span result, std::s settle(g, view, entry.slot, false, errorFromExceptionDetails(g, excDetails)); return; } - // result.result.value = [cx, cy]. jsonField gives us the array - // slice "[,]"; scan for the comma. + // result.result.value = [cx, cy] from kActionabilityIIFE. auto inner = jsonField(result, { "result", 6 }); auto value = jsonField(inner, { "value", 5 }); - // Skip leading '[' - const char* p = value.data(); - const char* end = p + value.size(); - while (p < end && (*p == '[' || *p == ' ')) - ++p; - // Parse cx (float until comma) - char* ep; - float cx = strtof(p, &ep); - p = ep; - while (p < end && (*p == ',' || *p == ' ')) - ++p; - float cy = strtof(p, nullptr); + auto root = value.empty() ? nullptr : JSON::Value::parseJSON(StringView(byteCast(value))); + auto point = root ? root->asArray() : nullptr; + auto px = point && point->length() == 2 ? point->get(0)->asDouble() : std::nullopt; + auto py = px ? point->get(1)->asDouble() : std::nullopt; + // "1e999" parses to infinity, which can't be serialized back to JSON. + if (!py || !std::isfinite(*px) || !std::isfinite(*py)) { + settle(g, view, entry.slot, false, createError(g, "malformed click response"_s)); + return; + } + double cx = *px; + double cy = *py; // Chain into dispatchMouseEvent. Same down+up pair as Ops::click. auto ss = view->m_sessionId.utf8(); @@ -1105,8 +1121,7 @@ void Transport::handleEvent(std::span method, std::span if (sessionId.empty()) { if (method.size() != 25 || memcmp(method.data(), "Target.detachedFromTarget", 25) != 0) return; - auto sid = jsonString(jsonField(params, { "sessionId", 9 })); - auto sidStr = WTF::String::fromUTF8(sid); + auto sidStr = decodeSessionId(jsonString(jsonField(params, { "sessionId", 9 }))); auto it = m_sessions.find(sidStr); if (it == m_sessions.end()) return; uint32_t vid = it->value; @@ -1121,7 +1136,7 @@ void Transport::handleEvent(std::span method, std::span updateKeepAlive(); return; } - auto sidStr = WTF::String::fromUTF8(sessionId); + auto sidStr = decodeSessionId(sessionId); auto it = m_sessions.find(sidStr); if (it == m_sessions.end()) return; JSWebView* view = viewFor(it->value); diff --git a/src/runtime/webview/ObjCRuntime.cpp b/src/runtime/webview/ObjCRuntime.cpp index d8500fa3f768..85be4c9e5587 100644 --- a/src/runtime/webview/ObjCRuntime.cpp +++ b/src/runtime/webview/ObjCRuntime.cpp @@ -119,6 +119,7 @@ Class WKWebViewConfiguration::cls_WKWebsiteDataStoreConfiguration; Class WKWebViewConfiguration::cls_WKProcessPool; SEL WKWebViewConfiguration::s_nonPersistentDataStore; SEL WKWebViewConfiguration::s_initWithDirectory; +bool WKWebViewConfiguration::s_hasInitWithDirectory; SEL WKWebViewConfiguration::s_initWithConfiguration; SEL WKWebViewConfiguration::s_setProcessPool; @@ -140,6 +141,7 @@ id WKWebViewConfiguration::sharedProcessPool() // share committed state. Retained once on insert, never released. id WKWebViewConfiguration::persistentStoreForDirectory(const WTF::String& directory) { + ASSERT(s_hasInitWithDirectory); static NeverDestroyed> cache; auto it = cache->find(directory); if (it != cache->end()) return it->value; @@ -332,6 +334,7 @@ bool ObjCRuntime::load() SEL (*sel)(const char*); Class (*allocateClassPair)(Class, const char*, size_t); BOOL (*addMethod)(Class, SEL, IMP, const char*); + BOOL (*respondsToSelector)(Class, SEL); BOOL (*addProtocol)(Class, Protocol*); Protocol* (*getProtocol)(const char*); void (*registerClassPair)(Class); @@ -340,6 +343,7 @@ bool ObjCRuntime::load() SYM(sel, libobjc, "sel_registerName"); SYM(allocateClassPair, libobjc, "objc_allocateClassPair"); SYM(addMethod, libobjc, "class_addMethod"); + SYM(respondsToSelector, libobjc, "class_respondsToSelector"); SYM(addProtocol, libobjc, "class_addProtocol"); SYM(getProtocol, libobjc, "objc_getProtocol"); SYM(registerClassPair, libobjc, "objc_registerClassPair"); @@ -458,12 +462,13 @@ bool ObjCRuntime::load() CLS(WKWebViewConfiguration::cls, "WKWebViewConfiguration"); CLS(WKWebViewConfiguration::cls_WKWebsiteDataStore, "WKWebsiteDataStore"); // _WKWebsiteDataStoreConfiguration is SPI but stable since macOS 10.13. - // initWithDirectory: is 15.2+. + // initWithDirectory: ships with macOS 15.2+, but a Safari update can also install that WebKit, so ask the class. CLS(WKWebViewConfiguration::cls_WKWebsiteDataStoreConfiguration, "_WKWebsiteDataStoreConfiguration"); CLS(WKWebViewConfiguration::cls_WKProcessPool, "WKProcessPool"); WKWebViewConfiguration::s_setProcessPool = sel("setProcessPool:"); WKWebViewConfiguration::s_nonPersistentDataStore = sel("nonPersistentDataStore"); WKWebViewConfiguration::s_initWithDirectory = sel("initWithDirectory:"); + WKWebViewConfiguration::s_hasInitWithDirectory = respondsToSelector(WKWebViewConfiguration::cls_WKWebsiteDataStoreConfiguration, WKWebViewConfiguration::s_initWithDirectory); WKWebViewConfiguration::s_initWithConfiguration = sel("_initWithConfiguration:"); WKWebViewConfiguration::s_setWebsiteDataStore = sel("setWebsiteDataStore:"); WKWebViewConfiguration::s_userContentController = sel("userContentController"); diff --git a/src/runtime/webview/ObjCRuntime.h b/src/runtime/webview/ObjCRuntime.h index 87a15bd356dc..cf16dfa99174 100644 --- a/src/runtime/webview/ObjCRuntime.h +++ b/src/runtime/webview/ObjCRuntime.h @@ -474,6 +474,7 @@ struct WKWebViewConfiguration : Ref { static Class cls_WKProcessPool; static SEL s_nonPersistentDataStore; static SEL s_initWithDirectory; // macOS 15.2+ (SPI) + static bool s_hasInitWithDirectory; // if false, sending initWithDirectory: raises and the host aborts static SEL s_initWithConfiguration; // _initWithConfiguration: (SPI) static SEL s_setWebsiteDataStore; static SEL s_setProcessPool; diff --git a/src/runtime/webview/host_main.cpp b/src/runtime/webview/host_main.cpp index 867d9beb3cc4..1cea47b23eee 100644 --- a/src/runtime/webview/host_main.cpp +++ b/src/runtime/webview/host_main.cpp @@ -143,6 +143,8 @@ struct Host { FrameWriter writer; std::unordered_map> views; + // Views whose Create failed, with the reason. Every later op on one fails with that reason. + std::unordered_map failedViews; // CFFileDescriptor delivers one callback then disarms; read until EAGAIN, // then re-enable. Incomplete frame at buffer tail stays until more bytes. @@ -172,11 +174,16 @@ struct Host { WebViewHost* view(uint32_t viewId, Op op) { auto it = views.find(viewId); - if (it == views.end()) { + if (it != views.end()) return it->second.ptr(); + auto failed = failedViews.find(viewId); + if (failed == failedViews.end()) { writer.sendReplyStr(viewId, failureFor(op), "invalid viewId"_s); return nullptr; } - return it->second.ptr(); + // Send NavFailEvent first, like navigateIPC does, because a constructor url's promise is marked handled. + if (op == Op::Navigate) writer.sendReplyStr(viewId, Reply::NavFailEvent, failed->second); + writer.sendReplyStr(viewId, failureFor(op), failed->second); + return nullptr; } }; @@ -243,6 +250,11 @@ void Host::dispatch(uint32_t viewId, Op op, Reader r) WTF::String persistDir; if (static_cast(p.dataStoreKind) == DataStoreKind::Persistent) persistDir = r.str(); + // An older WebKit raises an exception for initWithDirectory:, and nothing here catches it. + if (!persistDir.isEmpty() && !objc::WKWebViewConfiguration::s_hasInitWithDirectory) { + failedViews.emplace(viewId, "dataStore.directory needs a newer WebKit (macOS 15.2 or later); use dataStore: \"ephemeral\" or backend: \"chrome\""_s); + return; + } views.emplace(viewId, WebViewHost::createForIPC(viewId, p.width, p.height, persistDir)); // No Ack for Create — parent doesn't await it (fire-and-forget). return; @@ -263,6 +275,7 @@ void Host::dispatch(uint32_t viewId, Op op, Reader r) } case Op::Close: views.erase(viewId); + failedViews.erase(viewId); writer.sendReply(viewId, Reply::Ack); return; case Op::Resize: { diff --git a/src/sema/check/errors_declaration_emit.rs b/src/sema/check/errors_declaration_emit.rs index 0660c11f7346..c64609669758 100644 --- a/src/sema/check/errors_declaration_emit.rs +++ b/src/sema/check/errors_declaration_emit.rs @@ -3724,9 +3724,19 @@ impl<'p> DeclarationEmit<'_, 'p, '_> { text.extend_from_slice(b" from "); match from_expression { // `rewriteModuleSpecifier` doesn't change anything that isn't a string literal. + // A missing expression starts at the next token and ends before it, so its text is empty. Some(&(_, e)) if e.is_some() => { - let (start, end) = (hir[e].pos, self.c.end_of_expr(self.file(), e)); - text.extend_from_slice(&hir.text[start as usize..end as usize]); + let around = parentheses_around(hir, e); + let end = match around.first() { + // The parentheses end after the expression, so stop at the innermost `)`. + Some(innermost) if innermost.2 > hir[e].end => innermost.2 - 1, + _ => hir[e].end, + }; + let start = start_inside_parentheses(hir, e); + let inside = hir.text.get(start as usize..end as usize); + text.resize(text.len() + around.len(), b'('); + text.extend_from_slice(inside.unwrap_or_default()); + text.resize(text.len() + around.len(), b')'); } Some(_) => {} None => text.extend_from_slice(&self.module_specifier_text(s, spec)), diff --git a/src/sema/check/errors_declaration_emit/commonjs.rs b/src/sema/check/errors_declaration_emit/commonjs.rs index f15d38cc1756..c329a70bf228 100644 --- a/src/sema/check/errors_declaration_emit/commonjs.rs +++ b/src/sema/check/errors_declaration_emit/commonjs.rs @@ -333,11 +333,7 @@ impl<'p> DeclarationEmit<'_, 'p, '_> { let (argument, _) = crate::bind::require_call_argument(hir, hir[d].init)?; // `rewriteModuleSpecifier` self.result_has_external_module_indicator = true; - let (start, end) = ( - hir[argument].pos as usize, - self.c.end_of_expr(self.file(), argument) as usize, - ); - let specifier = &hir.text[start..end]; + let specifier = self.c.get_text_of_node(self.file(), hir.node(argument)); match hir[hir[d].pat].kind { // `const x = require("something")` -> `import x = require("something")` PatKind::Ident(name) => { @@ -443,9 +439,9 @@ impl<'p> DeclarationEmit<'_, 'p, '_> { } let name_text = match hir[target].kind { // `getLiteralTextOfNode` - ExprKind::Index { index, .. } => hir.text - [hir[index].pos as usize..self.c.end_of_expr(file, index) as usize] - .to_vec(), + ExprKind::Index { index, .. } => { + self.c.get_text_of_node(file, hir.node(index)).to_vec() + } _ if bun_core::lexer::is_identifier(self.name(name)) => self.name(name).to_vec(), _ => super::super::print::quoted(self.name(name), b'"', false), }; diff --git a/src/sema/check/print.rs b/src/sema/check/print.rs index d357d42167b1..fecedda826ee 100644 --- a/src/sema/check/print.rs +++ b/src/sema/check/print.rs @@ -368,6 +368,15 @@ impl Checker<'_, '_> { } impl<'p, 's> Checker<'p, 's> { + /// `GetTextOfNode`. Returns an empty slice if the node is missing, the HIR doesn't record its + /// start, or the file's text isn't retained (the default library). + pub(super) fn get_text_of_node(&self, file: FileId, node: hir::Node) -> &'p [u8] { + let hir = self.hir(file); + let (start, end) = (hir.start(node), self.end_of_node(file, node)); + let text = hir.text.get(start as usize..end as usize); + text.filter(|_| start != 0).unwrap_or_default() + } + /// `NodeBuilder.SerializeTypeForDeclaration` for a declaration of `file`. `ty` is /// `getTypeOfSymbol(symbol)`. `declaration` is `None` if nothing is reused from its syntax. pub(super) fn serialize_type_for_declaration( @@ -2074,20 +2083,11 @@ impl<'p, 's> Printer<'_, 'p, 's> { } } - /// `GetTextOfNode`. Returns an empty text if the node is missing, the HIR doesn't record its - /// start, or the text isn't retained (the default library). - fn get_text_of_node(&self, file: FileId, node: hir::Node) -> &'p [u8] { - let hir = self.c.hir(file); - let (start, end) = (hir.start(node), self.c.end_of_node(file, node)); - let text = hir.text.get(start as usize..end as usize); - text.filter(|_| start != 0).unwrap_or_default() - } - /// `DeclarationNameToString`. The source text is used only for what `node.Text()` lacks, the /// escapes of an identifier. A name isn't always at `start` (the name of an `@overload` is at /// the tag), and a missing name still has a token at that position. fn declaration_name_to_string(&self, file: FileId, name: hir::Node) -> Vec { - let text = match self.get_text_of_node(file, name) { + let text = match self.c.get_text_of_node(file, name) { raw_text if bun_core::strings::contains_char(raw_text, b'\\') => raw_text.to_vec(), _ => self.property_key_text(file, name), }; @@ -2105,7 +2105,7 @@ impl<'p, 's> Printer<'_, 'p, 's> { /// identifier has its escapes decoded. fn property_key_text(&self, file: FileId, name: hir::Node) -> Vec { let hir = self.c.hir(file); - let (text, raw_text) = (hir.text(name), self.get_text_of_node(file, name)); + let (text, raw_text) = (hir.text(name), self.c.get_text_of_node(file, name)); match (hir.kind(name), raw_text.first()) { (_, Some(b'[' | b'"' | b'\'' | b'0'..=b'9')) => raw_text.to_vec(), // `.5`. A name that's missing before a `.` isn't written. diff --git a/src/sema/node.rs b/src/sema/node.rs index c7d3bce7b55d..de612a3654a6 100644 --- a/src/sema/node.rs +++ b/src/sema/node.rs @@ -324,7 +324,7 @@ macro_rules! node_vectors { impl FileIn { /// True if every node fits in a handle and every id fits in `ROW_BITS` bits. - pub(crate) fn has_room_for_handles(&self) -> bool { + pub fn has_room_for_handles(&self) -> bool { let rows = 1 $(+ self.$field.len())*; rows + self.fns.len() + self.classes.len() + self.modules.len() < 1 << ROW_BITS } diff --git a/src/sema/parser/parser/mod.rs b/src/sema/parser/parser/mod.rs index 67baa990869b..cdeb2792dc31 100644 --- a/src/sema/parser/parser/mod.rs +++ b/src/sema/parser/parser/mod.rs @@ -414,6 +414,10 @@ pub(crate) fn run<'a>( after: lx.rejected_after, }); } + if !f.has_room_for_handles() { + scratch.recycled = Some(f); + return Err(Rejected::new(Rejection::TooLarge)); + } f.comment_directives = comment_directives; let names = &mut scratch.names; let source = text; diff --git a/src/shell_parser/error.rs b/src/shell_parser/error.rs index 65e57cb68c73..4e6a8019013c 100644 --- a/src/shell_parser/error.rs +++ b/src/shell_parser/error.rs @@ -14,6 +14,8 @@ pub enum Error { Unknown, #[error("Lex")] Lex, + #[error("NestingTooDeep")] + NestingTooDeep, #[error(transparent)] Alloc(#[from] bun_alloc::AllocError), } @@ -29,6 +31,7 @@ impl Error { Self::Unexpected => "Unexpected", Self::Unknown => "Unknown", Self::Lex => "Lex", + Self::NestingTooDeep => "NestingTooDeep", Self::Alloc(_) => "OutOfMemory", } } diff --git a/src/shell_parser/parse.rs b/src/shell_parser/parse.rs index 7a29d7d3b5a4..6e1a04f52a10 100644 --- a/src/shell_parser/parse.rs +++ b/src/shell_parser/parse.rs @@ -8,7 +8,7 @@ use std::io::Write as _; use bun_alloc::Arena as Bump; use bun_alloc::ArenaVecExt as _; -use bun_core::{String as BunString, strings}; +use bun_core::{StackCheck, String as BunString, strings}; // `strings::Cursor` aliased as `CodepointCursor` for readability. type CodepointCursor = strings::Cursor; @@ -37,6 +37,8 @@ pub enum ParseError { Unknown, #[error("Lex")] Lex, + #[error("NestingTooDeep")] + NestingTooDeep, } impl From for crate::Error { @@ -47,6 +49,7 @@ impl From for crate::Error { ParseError::Unexpected => crate::Error::Unexpected, ParseError::Unknown => crate::Error::Unknown, ParseError::Lex => crate::Error::Lex, + ParseError::NestingTooDeep => crate::Error::NestingTooDeep, } } } @@ -717,6 +720,8 @@ pub struct Parser<'bump> { pub(crate) current: u32, pub errors: bun_alloc::ArenaVec<'bump, ParserError<'bump>>, pub(crate) inside_subshell: Option, + /// `if`, subshell and command substitution all recurse through `parse_stmt`, which checks this. + stack_check: StackCheck, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -756,6 +761,7 @@ impl<'bump> Parser<'bump> { current: 0, errors: bun_alloc::ArenaVec::new_in(bump), inside_subshell: None, + stack_check: StackCheck::init(), }) } @@ -776,6 +782,7 @@ impl<'bump> Parser<'bump> { current: self.current, errors: core::mem::replace(&mut self.errors, bun_alloc::ArenaVec::new_in(self.alloc)), inside_subshell: Some(kind), + stack_check: self.stack_check, } } @@ -835,6 +842,11 @@ impl<'bump> Parser<'bump> { } pub(crate) fn parse_stmt(&mut self) -> ParseResult> { + if !self.stack_check.is_safe_to_recurse() { + self.add_error(format_args!("Nesting depth exceeded"))?; + return Err(ParseError::NestingTooDeep.into()); + } + let mut exprs = bun_alloc::ArenaVec::new_in(self.alloc); while if self.inside_subshell.is_none() { diff --git a/src/simdutf_sys/simdutf.rs b/src/simdutf_sys/simdutf.rs index 47be59bcb46d..360445be7d6e 100644 --- a/src/simdutf_sys/simdutf.rs +++ b/src/simdutf_sys/simdutf.rs @@ -282,3 +282,9 @@ pub mod base64 { } } } + +/// Returns false if this CPU can't run any compiled-in kernel. simdutf then falls back to a stub +/// that returns false for every query. +pub fn has_any_implementation() -> bool { + validate::ascii(b"a") +} diff --git a/src/sourcemap/InternalSourceMap.rs b/src/sourcemap/InternalSourceMap.rs index 06be5175c69f..e8baf923a87d 100644 --- a/src/sourcemap/InternalSourceMap.rs +++ b/src/sourcemap/InternalSourceMap.rs @@ -68,7 +68,7 @@ //! [28..32] stream_offset: u32 -- byte offset from blob start to stream //! [32.. ] SyncEntry[sync_count] -- 24 bytes each //! [stream_offset..total_len-stream_tail_pad] Window[sync_count] -//! [total_len-stream_tail_pad..total_len] zero bytes (read-past pad) +//! [total_len-stream_tail_pad..total_len] zero bytes (see `STREAM_TAIL_PAD`) //! ``` //! //! SyncEntry: absolute state of this window's first mapping plus stream offset @@ -108,10 +108,7 @@ pub(crate) const SYNC_INTERVAL: usize = 64; pub const HEADER_SIZE: usize = 32; -/// `read_varint`'s 1-byte fast path reads `bytes[pos]` unconditionally; the -/// exception cursors in `WindowReader` advance to one byte past their last -/// varint, so a 1-byte tail pad keeps that read in-bounds for a window at the -/// very end of the stream. +/// Trailing zero byte in the blob layout. The reader is bounds-checked and doesn't need it. const STREAM_TAIL_PAD: usize = 1; /// The blob is stored in the SavedSourceMap table as a tagged pointer to its @@ -243,9 +240,7 @@ impl InternalSourceMap { /// Sanity-check the blob's outer header (total_len, sync_count, stream_offset) /// against its actual length so a *truncated* embedded section in a `--compile` -/// binary degrades to "no sourcemap". This does not walk per-window -/// `SyncEntry.byte_offset`/section lengths; the blob is self-produced at build -/// time, and a tampered executable already implies arbitrary execution. +/// binary degrades to "no sourcemap". `WindowReader` bounds-checks the rest. pub fn is_valid_blob(blob: &[u8]) -> bool { if blob.len() < HEADER_SIZE { return false; @@ -288,14 +283,15 @@ impl State { } fn to_mapping(self) -> Mapping { + // Don't use `from_zero_based` here, because a corrupt blob can decode to a negative coordinate. Mapping { generated: LineColumnOffset { - lines: Ordinal::from_zero_based(self.generated_line), - columns: Ordinal::from_zero_based(self.generated_column), + lines: Ordinal(self.generated_line), + columns: Ordinal(self.generated_column), }, original: LineColumnOffset { - lines: Ordinal::from_zero_based(self.original_line), - columns: Ordinal::from_zero_based(self.original_column), + lines: Ordinal(self.original_line), + columns: Ordinal(self.original_column), }, source_index: self.source_index, name_index: -1, @@ -338,21 +334,22 @@ fn write_varint(buf: *mut u8, signed: i32) -> usize { } } -fn read_varint(bytes: &[u8], pos: &mut usize) -> i32 { +/// Returns `None` if the varint runs past the end of `bytes`. +fn read_varint(bytes: &[u8], pos: &mut usize) -> Option { let mut i = *pos; - let first = bytes[i]; + let first = *bytes.get(i)?; i += 1; if first < 0x80 { *pos = i; - return zigzag_decode(first as u32); + return Some(zigzag_decode(first as u32)); } let mut result: u32 = (first & 0x7f) as u32; let mut shift: u32 = 7; loop { - if i >= bytes.len() || shift > 28 { + if shift > 28 { break; } - let byte = bytes[i]; + let byte = *bytes.get(i)?; i += 1; result |= ((byte & 0x7f) as u32) << shift; if byte & 0x80 == 0 { @@ -361,13 +358,19 @@ fn read_varint(bytes: &[u8], pos: &mut usize) -> i32 { shift += 7; } *pos = i; - zigzag_decode(result) + Some(zigzag_decode(result)) } +/// Reads a 64-bit mask. Bit `i` of the result is bit `i & 7` of byte `pos + (i >> 3)`. #[inline] -fn test_bit(base: *const u8, idx: usize) -> bool { - // SAFETY: caller guarantees base[idx >> 3] is within the window header / mask region. - unsafe { (*base.add(idx >> 3) >> (idx & 7)) & 1 != 0 } +fn read_u64_le(bytes: &[u8], pos: usize) -> Option { + Some(u64::from_le_bytes(*bytes.get(pos..)?.first_chunk()?)) +} + +/// `idx` is a delta index, so it's less than `SYNC_INTERVAL - 1` and the shift can't overflow. +#[inline] +fn test_bit(mask: u64, idx: u8) -> bool { + (mask >> idx) & 1 != 0 } const FLAG_HAS_GEN_LINE_EXCEPTIONS: u8 = 1 << 2; @@ -390,20 +393,19 @@ mod win_hdr { /// Parses a window header and steps through its deltas in order. Exception /// streams are consumed in order, so a reader is forward-only. -// Invariant: `bytes`/`base`/`src_idx_mask` point into the blob and are only -// valid while the blob is live. They are raw pointers (not lifetimes) because -// readers are stored in a lifetime-less cache (`FindCacheSlot`) that -// follows `InternalSourceMap`'s Copy-view design. +// `bytes` is a raw pointer into the blob, like `InternalSourceMap` itself. It's valid while the blob is live. #[derive(Copy, Clone)] struct WindowReader { bytes: *const [u8], - base: *const u8, gen_col_pos: usize, orig_line_exc_pos: usize, orig_col_exc_pos: usize, gen_line_exc_pos: usize, - src_idx_mask: *const u8, src_idx_exc_pos: usize, + gen_line_mask: u64, + orig_line_eq_mask: u64, + orig_col_eq_mask: u64, + src_idx_eq_mask: u64, count: u8, flags: u8, gen_line_exc_next_idx: u8, @@ -411,15 +413,18 @@ struct WindowReader { } impl WindowReader { + /// An empty window. `done()` is true, so nothing reads `bytes`. const DANGLING: WindowReader = WindowReader { bytes: ptr::slice_from_raw_parts(ptr::null(), 0), - base: ptr::null(), gen_col_pos: 0, orig_line_exc_pos: 0, orig_col_exc_pos: 0, gen_line_exc_pos: 0, - src_idx_mask: ptr::null(), src_idx_exc_pos: 0, + gen_line_mask: 0, + orig_line_eq_mask: 0, + orig_col_eq_mask: 0, + src_idx_eq_mask: 0, count: 0, flags: 0, gen_line_exc_next_idx: 0, @@ -437,58 +442,57 @@ impl WindowReader { unsafe { &*self.bytes } } - fn parse(&mut self, bytes: &[u8], start: usize) { - // SAFETY: `start` is a valid window header offset within `bytes` (came - // from a SyncEntry.byte_offset produced by Builder). - let b = unsafe { bytes.as_ptr().add(start) }; - self.bytes = std::ptr::from_ref::<[u8]>(bytes); - self.base = b; - // Clamp `count` so a corrupted header byte cannot drive `next()` past - // `FindCacheSlot.decoded[SYNC_INTERVAL]`. Well-formed blobs never - // exceed K; this is defense-in-depth for the standalone-graph path. - // SAFETY: window header is 32 bytes within the stream; COUNT_OFF/FLAGS_OFF - // are fixed offsets within that 32-byte header at `b`. - self.count = unsafe { *b.add(win_hdr::COUNT_OFF) }.min(SYNC_INTERVAL as u8); - // SAFETY: same — FLAGS_OFF is within the 32-byte header at `b`. - let flags = unsafe { *b.add(win_hdr::FLAGS_OFF) }; - self.flags = flags; + /// Returns `None` if the window at `start` extends past the end of `bytes`. The reader is then `done()`. + fn parse(&mut self, bytes: &[u8], start: usize) -> Option<()> { + // Keep the reader empty until the whole window is validated. + self.count = 0; self.delta_idx = 0; + let header: &[u8; win_hdr::GEN_COL_LANE_OFF] = bytes.get(start..)?.first_chunk()?; + // One load covers count, flags and the three u16 LE section lengths. + let fixed = read_u64_le(header, 0)?; + let len_at = |off: usize| ((fixed >> (8 * off)) & 0xffff) as usize; + let flags = (fixed >> (8 * win_hdr::FLAGS_OFF)) as u8; - // SAFETY: u16 LE fields at fixed header offsets within the 32-byte header. - let gen_col_len: usize = - unsafe { u16::from_ne_bytes(*b.add(win_hdr::GEN_COL_LEN_OFF).cast::<[u8; 2]>()) } - as usize; - // SAFETY: ORIG_LINE_LEN_OFF is a fixed offset within the 32-byte header at `b`. - let orig_line_len: usize = - unsafe { u16::from_ne_bytes(*b.add(win_hdr::ORIG_LINE_LEN_OFF).cast::<[u8; 2]>()) } - as usize; - // SAFETY: ORIG_COL_LEN_OFF is a fixed offset within the 32-byte header at `b`. - let orig_col_len: usize = - unsafe { u16::from_ne_bytes(*b.add(win_hdr::ORIG_COL_LEN_OFF).cast::<[u8; 2]>()) } - as usize; - + self.bytes = std::ptr::from_ref::<[u8]>(bytes); self.gen_col_pos = start + win_hdr::GEN_COL_LANE_OFF; - self.orig_line_exc_pos = self.gen_col_pos + gen_col_len; - self.orig_col_exc_pos = self.orig_line_exc_pos + orig_line_len; - let mut pos = self.orig_col_exc_pos + orig_col_len; + self.orig_line_exc_pos = self.gen_col_pos + len_at(win_hdr::GEN_COL_LEN_OFF); + self.orig_col_exc_pos = self.orig_line_exc_pos + len_at(win_hdr::ORIG_LINE_LEN_OFF); + self.gen_line_mask = read_u64_le(header, win_hdr::GEN_LINE_MASK_OFF)?; + self.orig_line_eq_mask = read_u64_le(header, win_hdr::ORIG_LINE_EQ_MASK_OFF)?; + self.orig_col_eq_mask = read_u64_le(header, win_hdr::ORIG_COL_EQ_MASK_OFF)?; + self.flags = flags; self.gen_line_exc_next_idx = 0xFF; if flags != 0 { - if flags & FLAG_HAS_GEN_LINE_EXCEPTIONS != 0 && pos < bytes.len() { - self.gen_line_exc_pos = pos; - self.gen_line_exc_next_idx = bytes[pos]; - while pos < bytes.len() && bytes[pos] != 0xFF { - pos += 1; - let _ = read_varint(bytes, &mut pos); + let pos = self.orig_col_exc_pos + len_at(win_hdr::ORIG_COL_LEN_OFF); + self.parse_flagged_sections(bytes, pos)?; + } + // Clamp so every delta index is below 64, the masks' width. + self.count = ((fixed >> (8 * win_hdr::COUNT_OFF)) as u8).min(SYNC_INTERVAL as u8); + Some(()) + } + + #[cold] + fn parse_flagged_sections(&mut self, bytes: &[u8], mut pos: usize) -> Option<()> { + if self.flags & FLAG_HAS_GEN_LINE_EXCEPTIONS != 0 { + self.gen_line_exc_pos = pos; + self.gen_line_exc_next_idx = *bytes.get(pos)?; + // A window has at most one pair per delta. + let mut pairs = 0; + while *bytes.get(pos)? != 0xFF { + if pairs == SYNC_INTERVAL - 1 { + return None; } + pairs += 1; pos += 1; + read_varint(bytes, &mut pos)?; } - if flags & FLAG_HAS_SRC_IDX != 0 { - // SAFETY: pos is within bytes (mask region is 8 bytes). - self.src_idx_mask = unsafe { bytes.as_ptr().add(pos) }; - pos += 8; - self.src_idx_exc_pos = pos; - } + pos += 1; } + if self.flags & FLAG_HAS_SRC_IDX != 0 { + self.src_idx_eq_mask = read_u64_le(bytes, pos)?; + self.src_idx_exc_pos = pos + 8; + } + Some(()) } #[inline] @@ -496,53 +500,46 @@ impl WindowReader { self.delta_idx + 1 >= self.count } + /// Applies the next delta to `state`. If a lane runs past the end of the stream, `state` is + /// unchanged and `count` becomes 0. fn next(&mut self, state: &mut State) { + if self.apply_next(state).is_none() { + self.count = 0; + } + } + + fn apply_next(&mut self, state: &mut State) -> Option<()> { let delta_idx = self.delta_idx; self.delta_idx = delta_idx + 1; - let b = self.base; let bytes = self.bytes(); - let mut d_gen_line: i32 = if test_bit( - // SAFETY: header masks are at fixed offsets within the 32-byte header at `b`. - unsafe { b.add(win_hdr::GEN_LINE_MASK_OFF) }, - delta_idx as usize, - ) { - 1 - } else { - 0 - }; - let d_gen_col = read_varint(bytes, &mut self.gen_col_pos); - let mut d_orig_line: i32 = if test_bit( - // SAFETY: header masks are at fixed offsets within the 32-byte header at `b`. - unsafe { b.add(win_hdr::ORIG_LINE_EQ_MASK_OFF) }, - delta_idx as usize, - ) { + let mut d_gen_line: i32 = test_bit(self.gen_line_mask, delta_idx) as i32; + let d_gen_col = read_varint(bytes, &mut self.gen_col_pos)?; + let mut d_orig_line: i32 = if test_bit(self.orig_line_eq_mask, delta_idx) { d_gen_line } else { - read_varint(bytes, &mut self.orig_line_exc_pos) + read_varint(bytes, &mut self.orig_line_exc_pos)? }; - let d_orig_col: i32 = if test_bit( - // SAFETY: header masks are at fixed offsets within the 32-byte header at `b`. - unsafe { b.add(win_hdr::ORIG_COL_EQ_MASK_OFF) }, - delta_idx as usize, - ) { + let d_orig_col: i32 = if test_bit(self.orig_col_eq_mask, delta_idx) { d_gen_col } else { - read_varint(bytes, &mut self.orig_col_exc_pos) + read_varint(bytes, &mut self.orig_col_exc_pos)? }; if self.flags != 0 { - self.next_rare(delta_idx, &mut d_gen_line, &mut d_orig_line, state); + self.next_rare(delta_idx, &mut d_gen_line, &mut d_orig_line, state)?; } + // Use wrapping adds so a corrupt delta can't trip the overflow check. if d_gen_line != 0 { - state.generated_line += d_gen_line; + state.generated_line = state.generated_line.wrapping_add(d_gen_line); state.generated_column = d_gen_col; } else { - state.generated_column += d_gen_col; + state.generated_column = state.generated_column.wrapping_add(d_gen_col); } - state.original_line += d_orig_line; - state.original_column += d_orig_col; + state.original_line = state.original_line.wrapping_add(d_orig_line); + state.original_column = state.original_column.wrapping_add(d_orig_col); + Some(()) } #[cold] @@ -552,24 +549,22 @@ impl WindowReader { d_gen_line: &mut i32, d_orig_line: &mut i32, state: &mut State, - ) { + ) -> Option<()> { let bytes = self.bytes(); if self.gen_line_exc_next_idx == delta_idx { let mut p = self.gen_line_exc_pos + 1; - *d_gen_line = read_varint(bytes, &mut p); - if test_bit( - // SAFETY: header mask at fixed offset within `base`. - unsafe { self.base.add(win_hdr::ORIG_LINE_EQ_MASK_OFF) }, - delta_idx as usize, - ) { + *d_gen_line = read_varint(bytes, &mut p)?; + if test_bit(self.orig_line_eq_mask, delta_idx) { *d_orig_line = *d_gen_line; } self.gen_line_exc_pos = p; - self.gen_line_exc_next_idx = bytes[p]; + self.gen_line_exc_next_idx = *bytes.get(p)?; } - if self.flags & FLAG_HAS_SRC_IDX != 0 && !test_bit(self.src_idx_mask, delta_idx as usize) { - state.source_index += read_varint(bytes, &mut self.src_idx_exc_pos); + if self.flags & FLAG_HAS_SRC_IDX != 0 && !test_bit(self.src_idx_eq_mask, delta_idx) { + let d_src_idx = read_varint(bytes, &mut self.src_idx_exc_pos)?; + state.source_index = state.source_index.wrapping_add(d_src_idx); } + Some(()) } } @@ -595,10 +590,16 @@ impl InternalSourceMap { Some(u32::try_from(lo - 1).expect("int cast")) } - fn seed_window(self, sync_idx: u32, state: &mut State, reader: &mut WindowReader) { + /// Returns `None` if the window extends past the end of the stream. + fn seed_window( + self, + sync_idx: u32, + state: &mut State, + reader: &mut WindowReader, + ) -> Option<()> { let se = self.sync_entry(sync_idx as usize); *state = se.to_state(); - reader.parse(self.stream(), se.byte_offset as usize); + reader.parse(self.stream(), se.byte_offset as usize) } /// Matches the semantics of `Mapping.List.find`: returns the last mapping with @@ -611,7 +612,7 @@ impl InternalSourceMap { let mut state = State::default(); let mut reader = WindowReader::DANGLING; - self.seed_window(sync_idx, &mut state, &mut reader); + self.seed_window(sync_idx, &mut state, &mut reader)?; let mut best = state; while !reader.done() { @@ -624,7 +625,8 @@ impl InternalSourceMap { state = nxt; } - if best.generated_line != target_line { + // A `count` of 0 means the window is corrupt. Either its header says 0 or a lane overran. + if reader.count == 0 || best.generated_line != target_line { return None; } Some(best.to_mapping()) @@ -637,10 +639,15 @@ impl InternalSourceMap { for idx in 0..self.sync_count() { let mut state = State::default(); let mut reader = WindowReader::DANGLING; - self.seed_window(idx, &mut state, &mut reader); + if self.seed_window(idx, &mut state, &mut reader).is_none() { + return; + } each(state.to_mapping()); while !reader.done() { reader.next(&mut state); + if reader.count == 0 { + return; + } each(state.to_mapping()); } } @@ -663,10 +670,15 @@ impl InternalSourceMap { while idx < n_sync { let mut state = State::default(); let mut reader = WindowReader::DANGLING; - self.seed_window(idx, &mut state, &mut reader); + if self.seed_window(idx, &mut state, &mut reader).is_none() { + return; + } emit_vlq(&state, &mut prev, &mut generated_line, out); while !reader.done() { reader.next(&mut state); + if reader.count == 0 { + return; + } emit_vlq(&state, &mut prev, &mut generated_line, out); } idx += 1; diff --git a/src/sourcemap/Mapping.rs b/src/sourcemap/Mapping.rs index d16f464ecdb5..9618e9b9111c 100644 --- a/src/sourcemap/Mapping.rs +++ b/src/sourcemap/Mapping.rs @@ -395,10 +395,11 @@ impl Lookup { // `platform::Auto` is // cfg-selected (Posix on unix, Windows on windows). let dir = bun_paths::resolve_path::dirname::(base_filename); - let normalized = bun_paths::resolve_path::join_abs_string_buf_z::< + // `name` comes from the source map and can be any length. A path too long for `buf` is too long to open. + let path = bun_paths::resolve_path::join_abs_string_buf_checked::< bun_paths::platform::Loose, - >(dir, &mut buf, &[name]); - match bun_sys::File::read_from(bun_sys::Fd::cwd(), normalized) { + >(dir, &mut buf[..], &[name])?; + match bun_sys::File::read_from(bun_sys::Fd::cwd(), path) { Ok(r) => break 'bytes r, Err(_) => return None, } diff --git a/src/sourcemap/lib.rs b/src/sourcemap/lib.rs index b81937ac4ce3..47de3f994d80 100644 --- a/src/sourcemap/lib.rs +++ b/src/sourcemap/lib.rs @@ -686,7 +686,13 @@ pub mod SerializedSourceMap { pub(crate) fn source_file_contents(&self, index: usize) -> Option<&[u8]> { let decompressed = self.decompressed_files[index].get_or_init(|| { let sp = self.map.compressed_source_file_at(index); - let compressed_file = sp.slice(self.map.bytes); + let start = sp.offset as usize; + let Some(compressed_file) = start + .checked_add(sp.length as usize) + .and_then(|end| self.map.bytes.get(start..end)) + else { + return Vec::new(); + }; bun_zstd::decompress_alloc(compressed_file).unwrap_or_else(|err| { bun_core::warn!("Source map decompression error: {}", err); Vec::new() diff --git a/src/spawn/ctrl_c.rs b/src/spawn/ctrl_c.rs index 2161124fd435..6b7f0f60d3d7 100644 --- a/src/spawn/ctrl_c.rs +++ b/src/spawn/ctrl_c.rs @@ -44,14 +44,7 @@ extern "C" fn handler(sig: core::ffi::c_int) { RECEIVED.store(true, Ordering::SeqCst); return; } - // SAFETY: SIG_DFL is a valid disposition; SIGINT is blocked while we run, - // so the re-raise is delivered (fatally) once we return. - unsafe { - let mut sa: libc::sigaction = bun_core::ffi::zeroed(); - sa.sa_sigaction = libc::SIG_DFL; - libc::sigaction(sig, &raw const sa, core::ptr::null_mut()); - libc::raise(sig); - } + bun_core::Global::raise_default_action(sig); } #[cfg(windows)] diff --git a/src/spawn/process.rs b/src/spawn/process.rs index eab686175988..4cfd95fd3742 100644 --- a/src/spawn/process.rs +++ b/src/spawn/process.rs @@ -470,6 +470,7 @@ impl Process { { let ctx = self.event_loop_ctx(); if WaiterThread::should_use_waiter_thread() { + WaiterThread::start()?; self.poller = Poller::WaiterThread(KeepAlive::default()); if let Poller::WaiterThread(w) = &mut self.poller { w.ref_(ctx); @@ -535,6 +536,7 @@ impl Process { pub(crate) fn rewatch_posix(&mut self) -> bun_sys::Result<()> { let ctx = self.event_loop_ctx(); if WaiterThread::should_use_waiter_thread() { + WaiterThread::start()?; if !matches!(self.poller, Poller::WaiterThread(_)) { self.poller = Poller::WaiterThread(KeepAlive::default()); } @@ -1423,11 +1425,10 @@ pub mod waiter_thread_posix { /// Shared borrow of the singleton — sole deref site for the set-once /// `INSTANCE` cell. All fields are either atomic (`started`), /// interior-mutable (`js_process.active` via `UnsafeCell`, `js_process.queue` - /// lock-free), or write-once-before-spawn (`eventfd`, set in `init()` under - /// the `started` fetch_max guard before any reader thread exists), so a - /// shared `&'static` is sound. The lone mutating write (`eventfd` in - /// `init()`) goes through the raw [`instance()`] pointer; no `&` from this - /// accessor is live across it. + /// lock-free), or write-once-before-spawn (`eventfd`, set in `start()` under + /// `START_LOCK` before any reader thread exists), so a shared `&'static` is + /// sound. The lone mutating write (`eventfd` in `start()`) goes through the + /// raw [`instance()`] pointer; no `&` from this accessor is live across it. #[inline] fn instance_ref() -> &'static WaiterThreadPosix { // SAFETY: see doc comment — process-lifetime singleton, fields are @@ -1446,17 +1447,49 @@ pub mod waiter_thread_posix { bun_spawn_sys::waiter_thread_flag::get() } + /// Starts the thread on first use. If that fails, the next call retries. + pub(crate) fn start() -> bun_sys::Result<()> { + debug_assert!(bun_spawn_sys::waiter_thread_flag::get()); + + if instance_ref().started.load(Ordering::Acquire) > 0 { + return Ok(()); + } + let _guard = START_LOCK.lock_guard(); + if instance_ref().started.load(Ordering::Relaxed) > 0 { + return Ok(()); + } + + #[cfg(any(target_os = "linux", target_os = "android"))] + if !instance_ref().eventfd.is_valid() { + let fd = bun_sys::eventfd(0, libc::EFD_NONBLOCK | libc::EFD_CLOEXEC)?; + // SAFETY: `START_LOCK` is held and the thread doesn't exist yet, so nothing reads `eventfd`. + unsafe { (*instance()).eventfd = fd }; + } + + // Dropping the JoinHandle detaches the thread. + std::thread::Builder::new() + .stack_size(STACK_SIZE) + .spawn(loop_) + .map_err(|err| { + bun_sys::Error::from_code_int( + err.raw_os_error().unwrap_or(libc::EAGAIN), + bun_sys::Tag::pthread_create, + ) + })?; + instance_ref().started.store(1, Ordering::Release); + Ok(()) + } + + /// Only call this after `start()` returns `Ok`. pub(crate) fn append(process: *mut Process) { // `js_process.queue` is an MPSC lock-free queue; `append` is the // producer half and only touches `queue`, never `active`. instance_ref().js_process.append(process); - init().unwrap_or_else(|_| panic!("Failed to start WaiterThread")); - #[cfg(any(target_os = "linux", target_os = "android"))] { let one: [u8; 8] = (1usize).to_ne_bytes(); - // SAFETY: write(2) is async-signal-safe; eventfd valid after init(). + // SAFETY: write(2) is async-signal-safe; eventfd valid after start(). let n = unsafe { libc::write(instance_ref().eventfd.native(), one.as_ptr().cast(), 8) }; if n < 0 { @@ -1490,32 +1523,12 @@ pub mod waiter_thread_posix { } } - pub(crate) fn init() -> Result<(), std::io::Error> { - debug_assert!(bun_spawn_sys::waiter_thread_flag::get()); - - if instance_ref().started.fetch_max(1, Ordering::Relaxed) > 0 { - return Ok(()); - } - - #[cfg(any(target_os = "linux", target_os = "android"))] - { - let fd = bun_sys::eventfd(0, libc::EFD_NONBLOCK | libc::EFD_CLOEXEC) - .map_err(|e| std::io::Error::from_raw_os_error(e.errno as i32))?; - // SAFETY: single-writer init path (guarded by fetch_max above). - unsafe { (*instance()).eventfd = fd }; - } - - let thread = std::thread::Builder::new() - .stack_size(STACK_SIZE) - .spawn(loop_)?; - drop(thread); // detach - Ok(()) - } + static START_LOCK: bun_threading::Mutex = bun_threading::Mutex::new(); #[cfg(any(target_os = "linux", target_os = "android"))] extern "C" fn wakeup(_: c_int) { let one: [u8; 8] = (1usize).to_ne_bytes(); - // eventfd is write-once in init() before this handler is installed. + // eventfd is write-once in start() before this handler is installed. let _ = bun_sys::write(instance_ref().eventfd, &one).unwrap_or(0); } @@ -1538,7 +1551,7 @@ pub mod waiter_thread_posix { #[cfg(any(target_os = "linux", target_os = "android"))] { - // `eventfd` is written once in `init()` before this thread is + // `eventfd` is written once in `start()` before this thread is // spawned; read-only thereafter. let efd = this.eventfd; let mut polls = [libc::pollfd { @@ -3264,7 +3277,7 @@ mod spawn_process_body { } } - Bun__currentSyncPID.store(0, core::sync::atomic::Ordering::Relaxed); + Bun__currentSyncPID.store(0, core::sync::atomic::Ordering::SeqCst); let _signals = SignalForwarding::register(); // SAFETY: caller-built argv/envp are null-terminated C-string @@ -3278,13 +3291,14 @@ mod spawn_process_body { // Negative → kill() in the C++ signal forwarder targets the pgroup, so // a SIGTERM/SIGINT delivered to `bun run` reaches every descendant // that hasn't `setsid()`-escaped. + // SeqCst pairs with the signal handler (see `Bun__sendPendingSignalIfNecessary`). Bun__currentSyncPID.store( if no_orphans { -i64::from(process.pid) } else { i64::from(process.pid) }, - core::sync::atomic::Ordering::Relaxed, + core::sync::atomic::Ordering::SeqCst, ); let mut jc = JobControl { diff --git a/src/spawn_sys/lib.rs b/src/spawn_sys/lib.rs index ec584d419ceb..0caf9805c495 100644 --- a/src/spawn_sys/lib.rs +++ b/src/spawn_sys/lib.rs @@ -99,15 +99,12 @@ pub mod ffi { pub safe fn Bun__noOrphans_onFork(); pub safe fn Bun__noOrphans_onExit(pid: pid_t); - /// The PID to forward signals to. Set to 0 when unregistering. - /// - /// C++ declares this as plain `int64_t`; `AtomicI64` is `#[repr(C)]` - /// with the same size/align, and the C side only does word-sized - /// loads/stores from the signal handler, so `Relaxed` here matches. + /// The PID to forward signals to (`std::atomic` in C++). + /// Set to 0 when unregistering. pub safe static Bun__currentSyncPID: core::sync::atomic::AtomicI64; - /// Race condition: a signal could be sent before `spawn_process_posix` - /// returns. Call after the child PID is known. + /// Forwards a signal that arrived before the child PID was known. + /// Call after a `SeqCst` store of the PID. pub safe fn Bun__sendPendingSignalIfNecessary(); } } diff --git a/src/standalone_graph/StandaloneModuleGraph.rs b/src/standalone_graph/StandaloneModuleGraph.rs index f08cb4171f42..b680b2eaa4fe 100644 --- a/src/standalone_graph/StandaloneModuleGraph.rs +++ b/src/standalone_graph/StandaloneModuleGraph.rs @@ -22,6 +22,8 @@ use bun_resolver::LINKED_BYTECODE_REGION_COUNT; use bun_sourcemap as SourceMap; use bun_sys::{self as Syscall, E, Fd, FdExt as _, Stat}; +use crate::error::Corruption; + bun_core::declare_scope!(StandaloneModuleGraph, hidden); // `bun_webcore::Blob` lives in a higher tier and `cached_blob` is only ever @@ -633,19 +635,67 @@ mod elf { // `dlpi_addr` of the object containing BUN_COMPILED itself; for // non-PIE it is 0. // Format at target: [u64 payload_len][payload bytes] - // Synthesize a `*mut u8` directly so the provenance carries write - // permission for the in-place bytecode mutation done by JSC. let load_bias = bun_sys::elf::find_loaded_module(vaddr_ptr as usize).map_or(0, |m| m.base_address); - let target = (vaddr as usize).wrapping_add(load_bias) as *mut u8; - // SAFETY: target points to 8-byte little-endian length prefix. + let target = (vaddr as usize).wrapping_add(load_bias); + // A corrupt file can contain any vaddr and length, so check both against the PT_LOAD that maps `target`. + let segment_end = bun_sys::elf::find_loaded_module(target)?.segment_end; + let payload_capacity = (segment_end - target).checked_sub(8)?; + exit_if_page_is_past_eof(target); + // Synthesize a `*mut u8` directly so the provenance carries write + // permission for the in-place bytecode mutation done by JSC. + let target = target as *mut u8; + // SAFETY: the segment has at least 8 bytes at `target`. let payload_len = u64::from_le_bytes(unsafe { core::ptr::read_unaligned(target.cast::<[u8; 8]>()) }); - if payload_len < 8 { + if payload_len < 8 || payload_len > payload_capacity as u64 { return None; } - // SAFETY: payload_len bytes follow the 8-byte header at `target`. - Some((unsafe { target.add(8) }, payload_len as usize)) + // SAFETY: the segment has at least `payload_len` bytes after the 8-byte header. + let payload = unsafe { target.add(8) }; + // Truncation only removes the end of the file, so if the last page is present, the whole payload is. + exit_if_page_is_past_eof(payload as usize + payload_len as usize - 1); + Some((payload, payload_len as usize)) + } + + /// Exits with an error if the page at `addr` is past the end of a truncated executable. + /// + /// Linux maps a `PT_LOAD`'s full `p_filesz` without checking the file's length, so a truncated + /// executable still starts, and reading a page past the end of the file raises SIGBUS. + /// `MADV_POPULATE_READ` (Linux 5.14) faults the page in like a read, but fails with `EFAULT` + /// instead. Any other result, such as `EINVAL` from an older kernel, skips the check. + #[cfg(any(target_os = "linux", target_os = "android"))] + fn exit_if_page_is_past_eof(addr: usize) { + let page_size = bun_alloc::page_size(); + let page = (addr & !(page_size - 1)) as *mut core::ffi::c_void; + // SAFETY: `madvise` validates the range and doesn't read or write through it. + let rc = unsafe { libc::madvise(page, page_size, libc::MADV_POPULATE_READ) }; + if rc != 0 && bun_sys::last_errno() == libc::EFAULT { + exit_truncated(); + } + } + + /// No-op, because FreeBSD's `execve` rejects a truncated executable. + #[cfg(target_os = "freebsd")] + fn exit_if_page_is_past_eof(_: usize) {} + + #[cfg(any(target_os = "linux", target_os = "android"))] + #[cold] + #[inline(never)] + fn exit_truncated() -> ! { + match bun_core::self_exe_path() { + Ok(exe) => bun_core::err_generic!( + "{} is truncated. Part of its embedded program is missing.", + bun_core::fmt::quote(exe.as_bytes()) + ), + Err(_) => bun_core::err_generic!( + "This executable is truncated. Part of its embedded program is missing." + ), + } + bun_core::note!( + "The download or copy was probably interrupted. Download or install it again." + ); + bun_core::Global::exit(1); } /// `MADV_WILLNEED` over `[lo, hi)`: queues page-cache readahead for the @@ -840,6 +890,9 @@ impl LazySourceMap { data: blob.as_ptr(), }; let mut stored = SourceMap::ParsedSourceMap::from_internal(ism); + // The blob is part of the executable, so dropping `stored` must never free it. Set this before the + // early return below. + stored.is_standalone_module_graph = true; let source_files_count = serialized.source_files_count(); // PERF: `external_source_names` is `Vec>` so we @@ -852,15 +905,19 @@ impl LazySourceMap { .collect(); for i in 0..source_files_count { // SAFETY: `serialized.bytes` is a 'static read-only sourcemap subrange - // (disjoint from bytecode); StringPointer offsets were serialized by - // `to_bytes` and are in-bounds. - file_names.push(Box::from(unsafe { + // (disjoint from bytecode). `slice_to` bounds-checks the name pointer against it. + let Ok(name) = (unsafe { slice_to( serialized.bytes.as_ptr(), serialized.bytes.len(), serialized.source_file_name(i), + Corruption::ModuleSourceMap, ) - })); + }) else { + *self = LazySourceMap::None; + return None; + }; + file_names.push(Box::from(name)); } let data = Box::new(SerializedSourceMapLoaded { @@ -878,7 +935,6 @@ impl LazySourceMap { stored.underlying_provider = SourceMap::SourceContentPtr::from_provider( bun_core::heap::into_raw(data).cast::(), ); - stored.is_standalone_module_graph = true; let parsed = Arc::new(stored); // The Arc clone held in self keeps the parsed map alive. @@ -989,9 +1045,20 @@ impl StandaloneModuleGraph { // the bytecode/module_info regions never have a shared reference formed over them. let raw_const: *const u8 = raw_ptr; + // `byte_count` becomes the length of `bytes`, so it can't exceed the section. + if offsets.byte_count > raw_len { + return Err(Corruption::ByteCount.into()); + } // SAFETY: modules metadata blob is a read-only subrange of `[0, raw_len)` disjoint // from bytecode/module_info, serialized by `to_bytes`. - let modules_list_bytes = unsafe { slice_to(raw_const, raw_len, offsets.modules_ptr) }; + let modules_list_bytes = unsafe { + slice_to( + raw_const, + raw_len, + offsets.modules_ptr, + Corruption::ModuleList, + )? + }; // Note: the modules blob sits at an arbitrary byte offset in the section, and // `&[CompiledModuleGraphFile]` would require natural alignment (StringPointer's u32 fields // → 4-byte). We instead iterate by index and `read_unaligned` each fixed-size record into a @@ -999,137 +1066,92 @@ impl StandaloneModuleGraph { let modules_list_count = modules_list_bytes.len() / size_of::(); let modules_list_base = modules_list_bytes.as_ptr(); - if offsets.entry_point_id as usize > modules_list_count { - return Err(crate::Error::CorruptedModuleGraphEntryPointIDIsGreaterThanModuleListCount); - } - - let read_u32 = |at: usize| -> u32 { - debug_assert!(at + 4 <= raw_len); - // SAFETY: callers pass offsets of records `to_bytes` wrote inside `[0, raw_len)`; unaligned-safe read. - unsafe { core::ptr::read_unaligned(raw_const.add(at).cast::()) } - }; - // The optional records `to_bytes` chains directly after the module table, in `Flags` bit order. - let mut record_at = - offsets.modules_ptr.offset as usize + offsets.modules_ptr.length as usize; + let mut records = RecordChain { + base: raw_const, + len: raw_len, + at: (offsets.modules_ptr.offset as usize) + .saturating_add(offsets.modules_ptr.length as usize), + }; let source_hashes: Option<&[u8]> = if offsets.flags.contains(Flags::HAS_SOURCE_HASHES) { let length = modules_list_count * size_of::(); - // SAFETY: written by `to_bytes` directly after the module table; read-only subrange. - let hashes = unsafe { - slice_to( - raw_const, - raw_len, - StringPointer { - offset: record_at as u32, - length: length as u32, - }, - ) - }; - record_at += length; - Some(hashes) + let at = records.take(length, Corruption::SourceHashes)?; + // SAFETY: `take` checked that `[at, at + length)` is inside `[0, raw_len)`. `to_bytes` + // writes this read-only subrange directly after the module table. + Some(unsafe { core::slice::from_raw_parts(raw_const.add(at), length) }) } else { None }; let mut builtin_bytecode: Vec<(u32, *mut [u8], u32)> = Vec::new(); if offsets.flags.contains(Flags::HAS_BUILTIN_BYTECODE) { - let count = read_u32(record_at) as usize; - record_at += size_of::(); + let count = records.read_u32(Corruption::BuiltinBytecode)? as usize; + // Before calling `reserve`, reject a count whose entries (`{ u32 id, StringPointer bytes }`) can't fit. + if count > records.remaining() / (3 * size_of::()) { + return Err(Corruption::BuiltinBytecode.into()); + } builtin_bytecode.reserve(count); for _ in 0..count { - let id = read_u32(record_at); - let pointer = StringPointer { - offset: read_u32(record_at + 4), - length: read_u32(record_at + 8), - }; - record_at += 3 * size_of::(); + let id = records.read_u32(Corruption::BuiltinBytecode)?; + let pointer = records.read_string_pointer(Corruption::BuiltinBytecode)?; // SAFETY: same provenance rules as `File::bytecode`: a writable subrange JSC may patch in place. - let bytes = unsafe { slice_to_mut(raw_ptr, raw_len, pointer) }; + let bytes = unsafe { + slice_to_mut(raw_ptr, raw_len, pointer, Corruption::BuiltinBytecode)? + }; builtin_bytecode.push((id, bytes, 0)); } } let bytecode_string_table: &'static [u8] = if offsets.flags.contains(Flags::HAS_BYTECODE_STRING_TABLE) { - let ptr = StringPointer { - offset: read_u32(record_at), - length: read_u32(record_at + 4), - }; - record_at += 2 * size_of::(); + let ptr = records.read_string_pointer(Corruption::BytecodeStringTable)?; // SAFETY: `to_bytes` placed the serialized table via `append_bytecode_aligned` into a read-only, disjoint subrange. - unsafe { slice_to(raw_const, raw_len, ptr) } + unsafe { slice_to(raw_const, raw_len, ptr, Corruption::BytecodeStringTable)? } } else { &[] }; - let startup_module_count = if offsets.flags.contains(Flags::HAS_STARTUP_MODULE_COUNT) - && record_at + size_of::() <= raw_len - { - let count = read_u32(record_at); - record_at += size_of::(); - count + let startup_module_count = if offsets.flags.contains(Flags::HAS_STARTUP_MODULE_COUNT) { + records.read_u32(Corruption::StartupModuleCount)? } else { 0 }; let module_info_string_table: &'static [u8] = - if offsets.flags.contains(Flags::HAS_MODULE_INFO_STRING_TABLE) - && record_at + 2 * size_of::() <= raw_len - { - let ptr = StringPointer { - offset: read_u32(record_at), - length: read_u32(record_at + 4), - }; - record_at += 2 * size_of::(); - if (ptr.offset as usize).saturating_add(ptr.length as usize) > raw_len { - &[] - } else { - // SAFETY: bounds checked above; read-only subrange placed by `to_bytes`, disjoint from - // the writable regions. - unsafe { slice_to(raw_const, raw_len, ptr) } - } + if offsets.flags.contains(Flags::HAS_MODULE_INFO_STRING_TABLE) { + let ptr = records.read_string_pointer(Corruption::ModuleInfoStringTable)?; + // SAFETY: read-only subrange placed by `to_bytes`, disjoint from the writable regions. + unsafe { slice_to(raw_const, raw_len, ptr, Corruption::ModuleInfoStringTable)? } } else { &[] }; let mut prelinked_module_graph: &'static [u8] = &[]; let mut prelinked_module_files: Vec = Vec::new(); - if offsets.flags.contains(Flags::HAS_PRELINKED_MODULE_GRAPH) - && record_at + 3 * size_of::() <= raw_len - { - let ptr = StringPointer { - offset: read_u32(record_at), - length: read_u32(record_at + 4), - }; - let count = read_u32(record_at + 8) as usize; - record_at += 3 * size_of::(); - if (ptr.offset as usize).saturating_add(ptr.length as usize) <= raw_len - && count <= modules_list_count - && record_at + count * size_of::() <= raw_len + if offsets.flags.contains(Flags::HAS_PRELINKED_MODULE_GRAPH) { + let ptr = records.read_string_pointer(Corruption::PrelinkedModuleGraph)?; + let count = records.read_u32(Corruption::PrelinkedModuleGraph)? as usize; + if count > modules_list_count { + return Err(Corruption::PrelinkedModuleGraph.into()); + } + // SAFETY: read-only subrange placed by `to_bytes`, disjoint from the writable regions. + prelinked_module_graph = + unsafe { slice_to(raw_const, raw_len, ptr, Corruption::PrelinkedModuleGraph)? }; + prelinked_module_files.reserve(count); + for _ in 0..count { + prelinked_module_files.push(records.read_u32(Corruption::PrelinkedModuleGraph)?); + } + if prelinked_module_files + .iter() + .any(|&file| file as usize >= modules_list_count) { - // SAFETY: bounds checked above; read-only subrange placed by `to_bytes`, disjoint from the writable regions. - prelinked_module_graph = unsafe { slice_to(raw_const, raw_len, ptr) }; - prelinked_module_files = (0..count) - .map(|i| read_u32(record_at + i * size_of::())) - .collect(); - record_at += count * size_of::(); - if prelinked_module_files - .iter() - .any(|&file| file as usize >= modules_list_count) - { - prelinked_module_graph = &[]; - prelinked_module_files = Vec::new(); - } - } else { - record_at = raw_len; + prelinked_module_graph = &[]; + prelinked_module_files = Vec::new(); } } let mut runtime_options = RuntimeOptions::default(); - if offsets.flags.contains(Flags::HAS_RUNTIME_OPTIONS) - && record_at + 2 * size_of::() <= raw_len - { - let flags = read_u32(record_at); - let value = read_u32(record_at + 4); - record_at += 2 * size_of::(); + if offsets.flags.contains(Flags::HAS_RUNTIME_OPTIONS) { + let flags = records.read_u32(Corruption::RuntimeOptions)?; + let value = records.read_u32(Corruption::RuntimeOptions)?; if flags & RuntimeOptions::HAS_JIT_POLICY != 0 { let scale = f32::from_bits(value); if scale.is_finite() && scale >= 1.0 { @@ -1139,25 +1161,26 @@ impl StandaloneModuleGraph { } let mut linked_bytecode_payload = StringPointer::default(); let mut linked_bytecode_region_ends = [0u32; LINKED_BYTECODE_REGION_COUNT]; - const LINKED_PAYLOAD_RECORD: usize = (2 + LINKED_BYTECODE_REGION_COUNT) * size_of::(); - if offsets.flags.contains(Flags::HAS_LINKED_BYTECODE_PAYLOAD) - && record_at + LINKED_PAYLOAD_RECORD <= raw_len - { - let ptr = StringPointer { - offset: read_u32(record_at), - length: read_u32(record_at + 4), - }; - for (i, end) in linked_bytecode_region_ends.iter_mut().enumerate() { - *end = read_u32(record_at + 8 + i * size_of::()); + if offsets.flags.contains(Flags::HAS_LINKED_BYTECODE_PAYLOAD) { + let ptr = records.read_string_pointer(Corruption::LinkedBytecodePayload)?; + for end in &mut linked_bytecode_region_ends { + *end = records.read_u32(Corruption::LinkedBytecodePayload)?; } - record_at += LINKED_PAYLOAD_RECORD; - if (ptr.offset as usize).saturating_add(ptr.length as usize) <= raw_len - && linked_bytecode_region_ends.is_sorted() + if linked_bytecode_region_ends.is_sorted() && linked_bytecode_region_ends[LINKED_BYTECODE_REGION_COUNT - 1] == ptr.length { linked_bytecode_payload = ptr; } } + // SAFETY: same provenance rules as `File::bytecode`. + let linked_bytecode_payload_bytes = unsafe { + slice_to_mut( + raw_ptr, + raw_len, + linked_bytecode_payload, + Corruption::LinkedBytecodePayload, + )? + }; if linked_bytecode_payload.length != 0 { // Like a module's `bytecode`, an internal module's runs from its cache entry to the end of the payload. builtin_bytecode.retain_mut(|(_, bytes, entry_offset)| { @@ -1169,8 +1192,7 @@ impl StandaloneModuleGraph { return false; }; *entry_offset = offset as u32; - // SAFETY: the payload's bounds were checked when its record was read. - *bytes = unsafe { slice_to_mut(raw_ptr, raw_len, linked_bytecode_payload) }; + *bytes = linked_bytecode_payload_bytes; true }); } else if offsets.flags.contains(Flags::HAS_LINKED_BYTECODE_PAYLOAD) { @@ -1178,7 +1200,6 @@ impl StandaloneModuleGraph { // refers to what lies before it, like a module's, so it cannot be decoded as a payload of its own either. builtin_bytecode.clear(); } - let _ = record_at; let has_linked_bytecode_payload = offsets.flags.contains(Flags::HAS_LINKED_BYTECODE_PAYLOAD); let mut file_prelinked_index = vec![u32::MAX; modules_list_count]; @@ -1209,15 +1230,30 @@ impl StandaloneModuleGraph { .wrapping_sub(linked_bytecode_payload.offset) }) .filter(|&entry_offset| entry_offset < linked_bytecode_payload.length); - // SAFETY: each name/contents/sourcemap/bytecode_origin_path subrange is in-bounds - // (serialized by `to_bytes`) and disjoint from the writable bytecode/module_info - // subranges; section bytes are a live 'static allocation. + // SAFETY: each name/contents/sourcemap/bytecode_origin_path subrange is checked + // against `raw_len` and is disjoint from the writable bytecode/module_info + // subranges (serialized by `to_bytes`). Section bytes are a live 'static allocation. let (name, contents, sourcemap_bytes, bytecode_origin) = unsafe { ( - slice_to_z(raw_const, raw_len, module.name), - slice_to_z(raw_const, raw_len, module.contents), - slice_to(raw_const, raw_len, module.sourcemap), - slice_to_z(raw_const, raw_len, module.bytecode_origin_path), + slice_to_z(raw_const, raw_len, module.name, Corruption::ModuleName)?, + slice_to_z( + raw_const, + raw_len, + module.contents, + Corruption::ModuleContents, + )?, + slice_to( + raw_const, + raw_len, + module.sourcemap, + Corruption::ModuleSourceMap, + )?, + slice_to_z( + raw_const, + raw_len, + module.bytecode_origin_path, + Corruption::BytecodeOriginPath, + )?, ) }; let _ = modules.put( @@ -1240,7 +1276,7 @@ impl StandaloneModuleGraph { && (linked_entry_offset.is_some() || !has_linked_bytecode_payload) { // SAFETY: section bytes are a writable 'static allocation; JSC mutates - // bytecode in place. Subrange is in-bounds (serialized by to_bytes) and + // bytecode in place. Subrange is checked against `raw_len` and is // disjoint from every read-only subslice handed out above — no // `&[u8]` is ever formed over this range. unsafe { @@ -1252,7 +1288,8 @@ impl StandaloneModuleGraph { } else { module.bytecode }, - ) + Corruption::ModuleBytecode, + )? } } else { std::ptr::from_mut::<[u8]>(&mut []) @@ -1260,7 +1297,14 @@ impl StandaloneModuleGraph { bytecode_entry_offset: linked_entry_offset.unwrap_or(0), module_info: if module.module_info.length > 0 { // SAFETY: see bytecode above. - unsafe { slice_to_mut(raw_ptr, raw_len, module.module_info) } + unsafe { + slice_to_mut( + raw_ptr, + raw_len, + module.module_info, + Corruption::ModuleInfo, + )? + } } else { std::ptr::from_mut::<[u8]>(&mut []) }, @@ -1284,6 +1328,10 @@ impl StandaloneModuleGraph { } let module_count = modules.count(); + // `entry_point()` indexes `files`, which has one entry per name, not one per record. + if offsets.entry_point_id as usize >= module_count { + return Err(Corruption::EntryPointId.into()); + } modules.lock_pointers(); // make the pointers stable forever // Keys are posix-separated already (see `to_bytes`), so byte-scan for `/`. @@ -1309,19 +1357,19 @@ impl StandaloneModuleGraph { entry_point_id: offsets.entry_point_id, // SAFETY: read-only argv string subrange, disjoint from writable regions. compile_exec_argv: unsafe { - slice_to_z(raw_const, raw_len, offsets.compile_exec_argv_ptr) + slice_to_z( + raw_const, + raw_len, + offsets.compile_exec_argv_ptr, + Corruption::CompileExecArgv, + )? } .as_bytes(), flags: offsets.flags, builtin_bytecode, bytecode_string_table, - linked_bytecode_payload: (linked_bytecode_payload.length != 0).then(|| { - ( - // SAFETY: the payload's bounds were checked when its record was read. - unsafe { slice_to_mut(raw_ptr, raw_len, linked_bytecode_payload) }, - linked_bytecode_region_ends, - ) - }), + linked_bytecode_payload: (linked_bytecode_payload.length != 0) + .then_some((linked_bytecode_payload_bytes, linked_bytecode_region_ends)), module_info_string_table, prelinked_module_graph, prelinked_module_files, @@ -1339,49 +1387,112 @@ impl StandaloneModuleGraph { } } +/// Reads the records after the module table. Every read is bounds-checked against `len`. +struct RecordChain { + base: *const u8, + len: usize, + at: usize, +} + +impl RecordChain { + fn remaining(&self) -> usize { + self.len.saturating_sub(self.at) + } + + /// Skips the next `n` bytes and returns their offset, or `Err(what)` if they run past the section. + fn take(&mut self, n: usize, what: Corruption) -> crate::Result { + let at = self.at; + self.at = at + .checked_add(n) + .filter(|&end| end <= self.len) + .ok_or(what)?; + Ok(at) + } + + fn read_u32(&mut self, what: Corruption) -> crate::Result { + let at = self.take(size_of::(), what)?; + // SAFETY: `take` checked that `[at, at + 4)` is inside the section, and the read is unaligned-safe. + Ok(unsafe { core::ptr::read_unaligned(self.base.add(at).cast::()) }) + } + + /// Reads a `StringPointer` record. `slice_to` and `slice_to_mut` bounds-check its range. + fn read_string_pointer(&mut self, what: Corruption) -> crate::Result { + let offset = self.read_u32(what)?; + let length = self.read_u32(what)?; + Ok(StringPointer { offset, length }) + } +} + +/// Returns `ptr`'s byte range if it and the `tail` bytes after it are within `len`. +fn checked_range(len: usize, ptr: StringPointer, tail: usize) -> Option> { + let start = ptr.offset as usize; + let end = start.checked_add(ptr.length as usize)?; + (end.checked_add(tail)? <= len).then_some(start..end) +} + /// Read-only subslice helper. Builds a `&'static [u8]` over the *subrange only* so no /// shared reference ever spans the writable bytecode/module_info regions of the same /// allocation (which would be invalidated by JSC's in-place writes). +/// Returns `Err(what)` if the range runs past `len`. /// /// SAFETY: caller guarantees `base[..len]` is a live 'static allocation and -/// `[ptr.offset, ptr.offset + ptr.length)` is in-bounds and never written through a -/// `*mut` alias for the lifetime of the returned reference. -unsafe fn slice_to(base: *const u8, len: usize, ptr: StringPointer) -> &'static [u8] { +/// `[ptr.offset, ptr.offset + ptr.length)` is never written through a `*mut` +/// alias for the lifetime of the returned reference. +unsafe fn slice_to( + base: *const u8, + len: usize, + ptr: StringPointer, + what: Corruption, +) -> crate::Result<&'static [u8]> { if ptr.length == 0 { - return b""; + return Ok(b""); } - let off = ptr.offset as usize; - let n = ptr.length as usize; - debug_assert!(off.checked_add(n).is_some_and(|end| end <= len)); - // SAFETY: caller contract — `[off, off+n)` lies within a live 'static read-only allocation. - unsafe { core::slice::from_raw_parts(base.add(off), n) } + let range = checked_range(len, ptr, 0).ok_or(what)?; + // SAFETY: `range` is within `len` (checked above) and, per the caller + // contract, is a live 'static read-only allocation. + Ok(unsafe { core::slice::from_raw_parts(base.add(range.start), range.len()) }) } /// Mutable-subslice helper for `from_bytes`. Derives a `*mut [u8]` directly from the raw /// section base so the result carries write provenance — going through `slice_to` (which /// returns `&[u8]`) and casting `*const [u8] as *mut [u8]` would be UB on write. +/// Returns `Err(what)` if the range runs past `len`. /// -/// SAFETY: caller guarantees `base[..len]` is a live allocation with write permission and -/// that `[ptr.offset, ptr.offset + ptr.length)` is in-bounds. -unsafe fn slice_to_mut(base: *mut u8, len: usize, ptr: StringPointer) -> *mut [u8] { - let off = ptr.offset as usize; - let n = ptr.length as usize; - debug_assert!(off.checked_add(n).is_some_and(|end| end <= len)); - // SAFETY: caller contract — `off` is in-bounds of the writable allocation at `base`. - core::ptr::slice_from_raw_parts_mut(unsafe { base.add(off) }, n) -} - -/// SAFETY: as `slice_to`, plus `base[ptr.offset + ptr.length] == 0` (written by -/// `to_bytes` via `appendCountZ`). -unsafe fn slice_to_z(base: *const u8, len: usize, ptr: StringPointer) -> &'static ZStr { +/// SAFETY: caller guarantees `base[..len]` is a live allocation with write permission. +unsafe fn slice_to_mut( + base: *mut u8, + len: usize, + ptr: StringPointer, + what: Corruption, +) -> crate::Result<*mut [u8]> { + let range = checked_range(len, ptr, 0).ok_or(what)?; + // SAFETY: `range` is within `len` (checked above) of the writable allocation at `base`. + Ok(core::ptr::slice_from_raw_parts_mut( + unsafe { base.add(range.start) }, + range.len(), + )) +} + +/// Like `slice_to`, but also returns `Err(what)` unless `base[ptr.offset + ptr.length] == 0` +/// (written by `to_bytes` via `appendCountZ`). +/// +/// SAFETY: same as `slice_to`. +unsafe fn slice_to_z( + base: *const u8, + len: usize, + ptr: StringPointer, + what: Corruption, +) -> crate::Result<&'static ZStr> { if ptr.length == 0 { - return ZStr::EMPTY; + return Ok(ZStr::EMPTY); + } + let range = checked_range(len, ptr, 1).ok_or(what)?; + // SAFETY: `range.end < len` (checked above), so the terminator byte is in bounds. + if unsafe { *base.add(range.end) } != 0 { + return Err(what.into()); } - let off = ptr.offset as usize; - let n = ptr.length as usize; - debug_assert!(off.checked_add(n).is_some_and(|end| end < len)); - // SAFETY: caller contract — `[off, off+n]` is in-bounds with a NUL terminator at `base[off+n]`. - unsafe { ZStr::from_raw(base.add(off), n) } + // SAFETY: `range` is in bounds with a NUL terminator at `base[range.end]`. + Ok(unsafe { ZStr::from_raw(base.add(range.start), range.len()) }) } /// A text import the bundler emitted as an asset (`Loader::Text` arm of @@ -1478,7 +1589,11 @@ pub(crate) fn to_bytes( let mut string_builder = bun_core::StringBuilder::default(); let mut module_count: usize = 0; for output_file in output_files { - string_builder.count_z(&output_file.dest_path); + // `module.name` is formatted through `BStr`, which expands each invalid UTF-8 byte to 3 bytes. + string_builder.fmt_count(format_args!( + "{}\0", + bstr::BStr::new(&output_file.dest_path) + )); string_builder.count_z(prefix); if let options::OutputFileValue::Buffer { bytes } = &output_file.value { if output_file.output_kind == options::OutputKind::Sourcemap { @@ -2770,7 +2885,17 @@ pub fn target_executable( let version_zstr = ZStr::from_slice_with_nul(&version_str[..]); let mut needs_download: bool = true; - let dest_z = target.exe_path(&mut exe_path_buf, version_zstr, env, &mut needs_download); + let dest_z = + match target.exe_path(&mut exe_path_buf, version_zstr, env, &mut needs_download) { + Ok(dest) => dest, + Err(_) => { + return Err(CompileError::fmt(format_args!( + "Cache directory path for '{}' is too long: {}", + target, + bstr::BStr::new(&bun_sys::fetch_cache_directory_path()) + ))); + } + }; if needs_download { if let Err(e) = download_to_path(target, env, dest_z) { @@ -3010,12 +3135,11 @@ pub fn to_executable( #[cfg(not(windows))] { let temp_posix = injected.temp_path; - let outfile_basename = bun_paths::basename(outfile); - let mut outfile_posix_buf = bun_paths::path_buffer_pool::get(); - let outfile_posix = path::resolve_path::z(outfile_basename, &mut outfile_posix_buf); + // `resolve_path::z` returns "" for a name that's too long, which would make the rename fail with ENOENT. + let outfile_posix = bun_core::ZBox::from_bytes(bun_paths::basename(outfile)); if let Err(e) = - bun_sys::move_file_z_with_handle(fd, Fd::cwd(), temp_posix, root_dir, outfile_posix) + bun_sys::move_file_z_with_handle(fd, Fd::cwd(), temp_posix, root_dir, &outfile_posix) { fd.close(); diff --git a/src/standalone_graph/error.rs b/src/standalone_graph/error.rs index 744fcb449273..3e1e1ed1c4d7 100644 --- a/src/standalone_graph/error.rs +++ b/src/standalone_graph/error.rs @@ -1,7 +1,71 @@ +/// Identifies the out-of-range field that made `StandaloneModuleGraph::from_bytes` reject the graph. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Corruption { + ByteCount, + ModuleList, + EntryPointId, + ModuleName, + ModuleContents, + ModuleSourceMap, + ModuleBytecode, + ModuleInfo, + BytecodeOriginPath, + CompileExecArgv, + SourceHashes, + BuiltinBytecode, + BytecodeStringTable, + StartupModuleCount, + ModuleInfoStringTable, + PrelinkedModuleGraph, + RuntimeOptions, + LinkedBytecodePayload, +} + +impl Corruption { + pub const fn message(self) -> &'static str { + match self { + Self::ByteCount => "Corrupted module graph: byte count is out of range", + Self::ModuleList => "Corrupted module graph: module list is out of range", + Self::EntryPointId => { + "Corrupted module graph: entry point ID is out of range for the module list" + } + Self::ModuleName => "Corrupted module graph: module name is out of range", + Self::ModuleContents => "Corrupted module graph: module contents are out of range", + Self::ModuleSourceMap => "Corrupted module graph: module source map is out of range", + Self::ModuleBytecode => "Corrupted module graph: module bytecode is out of range", + Self::ModuleInfo => "Corrupted module graph: module info is out of range", + Self::BytecodeOriginPath => { + "Corrupted module graph: bytecode origin path is out of range" + } + Self::CompileExecArgv => "Corrupted module graph: compile exec argv is out of range", + Self::SourceHashes => "Corrupted module graph: source hashes are out of range", + Self::BuiltinBytecode => { + "Corrupted module graph: builtin bytecode table is out of range" + } + Self::BytecodeStringTable => { + "Corrupted module graph: bytecode string table is out of range" + } + Self::StartupModuleCount => { + "Corrupted module graph: startup module count is out of range" + } + Self::ModuleInfoStringTable => { + "Corrupted module graph: module info string table is out of range" + } + Self::PrelinkedModuleGraph => { + "Corrupted module graph: prelinked module graph is out of range" + } + Self::RuntimeOptions => "Corrupted module graph: runtime options are out of range", + Self::LinkedBytecodePayload => { + "Corrupted module graph: linked bytecode payload is out of range" + } + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] pub enum Error { - #[error("Corrupted module graph: entry point ID is greater than module list count")] - CorruptedModuleGraphEntryPointIDIsGreaterThanModuleListCount, + #[error("{}", .0.message())] + CorruptedModuleGraph(Corruption), #[error("TargetNotFound")] TargetNotFound, #[error("NetworkError")] @@ -28,6 +92,12 @@ pub enum Error { Options(#[from] bun_options_types::Error), } +impl From for Error { + fn from(c: Corruption) -> Self { + Self::CorruptedModuleGraph(c) + } +} + impl From for Error { fn from(e: bun_sys::Error) -> Self { Self::Sys(e.into()) @@ -38,9 +108,7 @@ impl Error { #[allow(clippy::trivially_copy_pass_by_ref)] pub fn name(&self) -> &'static str { match self { - Self::CorruptedModuleGraphEntryPointIDIsGreaterThanModuleListCount => { - "Corrupted module graph: entry point ID is greater than module list count" - } + Self::CorruptedModuleGraph(c) => c.message(), Self::TargetNotFound => "TargetNotFound", Self::NetworkError => "NetworkError", Self::InvalidResponse => "InvalidResponse", diff --git a/src/sys/dirent.rs b/src/sys/dirent.rs new file mode 100644 index 000000000000..3ddae346c041 --- /dev/null +++ b/src/sys/dirent.rs @@ -0,0 +1,178 @@ +//! Parses records from a `getdents64`, `getdirentries64` or `getdents` buffer. A sandbox can emulate +//! the syscall, so every length in a record is bounds-checked. + +use crate::{E, Error, Tag}; + +/// Describes a record's field offsets. Every layout has `d_ino` at 0 and `d_reclen` at 16. +pub trait Layout { + const D_TYPE: usize; + /// `None` on Linux, where the name is only NUL-terminated. + const D_NAMLEN: Option; + /// Offset of `d_name[0]`, which is also the header size. + const D_NAME: usize; + /// True on Darwin and FreeBSD, which mark a removed entry with inode 0. + const INODE_ZERO_IS_REMOVED: bool; + /// False on platforms whose FAT driver can report an entry with a blank 8.3 name. + const EMPTY_NAME_IS_MALFORMED: bool; +} + +const D_INO: usize = 0; +const D_RECLEN: usize = 16; + +#[cfg(any(target_os = "linux", target_os = "android"))] +pub struct Linux; +#[cfg(any(target_os = "linux", target_os = "android"))] +impl Layout for Linux { + const D_TYPE: usize = 18; + const D_NAMLEN: Option = None; + const D_NAME: usize = 19; + const INODE_ZERO_IS_REMOVED: bool = false; + const EMPTY_NAME_IS_MALFORMED: bool = true; +} +#[cfg(any(target_os = "linux", target_os = "android"))] +const _: () = { + use core::mem::offset_of; + assert!(offset_of!(libc::dirent64, d_ino) == D_INO); + assert!(offset_of!(libc::dirent64, d_reclen) == D_RECLEN); + assert!(offset_of!(libc::dirent64, d_type) == Linux::D_TYPE); + assert!(offset_of!(libc::dirent64, d_name) == Linux::D_NAME); +}; + +#[cfg(target_os = "macos")] +pub struct Darwin; +#[cfg(target_os = "macos")] +impl Layout for Darwin { + const D_TYPE: usize = 20; + const D_NAMLEN: Option = Some(18); + const D_NAME: usize = 21; + const INODE_ZERO_IS_REMOVED: bool = true; + const EMPTY_NAME_IS_MALFORMED: bool = false; +} +#[cfg(target_os = "macos")] +const _: () = { + use core::mem::offset_of; + assert!(offset_of!(libc::dirent, d_ino) == D_INO); + assert!(offset_of!(libc::dirent, d_reclen) == D_RECLEN); + assert!(offset_of!(libc::dirent, d_namlen) == 18); + assert!(offset_of!(libc::dirent, d_type) == Darwin::D_TYPE); + assert!(offset_of!(libc::dirent, d_name) == Darwin::D_NAME); +}; + +#[cfg(target_os = "freebsd")] +pub struct FreeBsd; +#[cfg(target_os = "freebsd")] +impl Layout for FreeBsd { + const D_TYPE: usize = 18; + const D_NAMLEN: Option = Some(20); + const D_NAME: usize = 24; + const INODE_ZERO_IS_REMOVED: bool = true; + const EMPTY_NAME_IS_MALFORMED: bool = false; +} +#[cfg(target_os = "freebsd")] +const _: () = { + use core::mem::offset_of; + assert!(offset_of!(libc::dirent, d_fileno) == D_INO); + assert!(offset_of!(libc::dirent, d_reclen) == D_RECLEN); + assert!(offset_of!(libc::dirent, d_namlen) == 20); + assert!(offset_of!(libc::dirent, d_type) == FreeBsd::D_TYPE); + assert!(offset_of!(libc::dirent, d_name) == FreeBsd::D_NAME); +}; + +#[cfg(any(target_os = "linux", target_os = "android"))] +pub type Native = Linux; +#[cfg(target_os = "macos")] +pub type Native = Darwin; +#[cfg(target_os = "freebsd")] +pub type Native = FreeBsd; + +pub struct Record<'a> { + /// Offset of the next record, with `at < next <= filled.len()`. + pub next: usize, + /// `None` for records that walkers skip, such as `.`, `..` and removed entries. + pub entry: Option>, +} + +pub struct Entry<'a> { + /// `d_name` without its NUL. The NUL is the next byte and is inside the record. + pub name: &'a [u8], + pub d_type: u8, + pub ino: u64, +} + +/// Parses the record at `filled[at..]`, where `filled` is the part of the buffer the syscall wrote. +/// Returns `None` if the record is malformed. +#[inline] +pub fn parse(filled: &[u8], at: usize) -> Option> { + let rest = filled.get(at..)?; + let header = rest.get(..L::D_NAME)?; + let u16_at = |offset: usize| { + Some(usize::from(u16::from_ne_bytes( + *header.get(offset..)?.first_chunk()?, + ))) + }; + + let reclen = u16_at(D_RECLEN)?; + let name_field = rest.get(L::D_NAME..reclen)?; + let skipped = Some(Record { + next: at + reclen, + entry: None, + }); + + let ino = u64::from_ne_bytes(*header.get(D_INO..)?.first_chunk()?); + if L::INODE_ZERO_IS_REMOVED && ino == 0 { + return skipped; + } + + let (name, unreported) = match L::D_NAMLEN { + Some(offset) => { + let namlen = u16_at(offset)?; + if *name_field.get(namlen)? != 0 { + return None; + } + // C APIs stop at the first NUL, which can come before `d_namlen`. + let read_by_c = matches!(*name_field, [0, ..] | [b'.', 0, ..] | [b'.', b'.', 0, ..]); + (name_field.get(..namlen)?, read_by_c) + } + None => { + // A scalar loop here is visible in startup profiles for large directories. + let nul = bun_core::strings::index_of_char_usize(name_field, 0); + let nul = nul.unwrap_or(name_field.len()); + name_field.get(nul)?; + let name = name_field.get(..nul)?; + (name, nul <= 2 && matches!(name, b"" | b"." | b"..")) + } + }; + if unreported { + // An empty name resolves to the directory itself, so a recursive walk would never end. + if L::EMPTY_NAME_IS_MALFORMED && name.is_empty() { + return None; + } + return skipped; + } + + Some(Record { + next: at + reclen, + entry: Some(Entry { + name, + d_type: *header.get(L::D_TYPE)?, + ino, + }), + }) +} + +/// Sentinel `end_index` for a walk that hit a malformed record. It's larger than any cursor, so the +/// walker never refills, and larger than any buffer, so `record_at` never parses. +const ENDED: usize = usize::MAX; + +/// Ends a walk at a malformed record. The first call returns `EIO`, like the kernel does for a bad +/// FUSE reply. Later calls return end of directory. +#[cold] +// Not `#[inline(never)]`: an outlined call pushes `next` past the inline threshold in the recursive +// `readdirSync` loops. +pub fn end_walk(end_index: &mut usize, syscall: Tag) -> Result, Error> { + if *end_index == ENDED { + return Ok(None); + } + *end_index = ENDED; + Err(Error::from_code(E::EIO, syscall)) +} diff --git a/src/sys/lib.rs b/src/sys/lib.rs index c44a8abbc24d..14ee170f6bb6 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -96,6 +96,9 @@ pub mod copy_file; // Directory-entry kind — same set as `bun_core::FileKind`. pub use bun_core::FileKind as EntryKind; +#[cfg(unix)] +pub mod dirent; + // `bun.DirIterator`. // // A readdir-style directory iterator. Notable behaviors: @@ -149,9 +152,11 @@ pub unsafe fn getdirentries64(fd: Fd, buf: *mut u8, len: usize, basep: *mut i64) } pub mod dir_iterator { - use super::{EntryKind, Fd, Result}; #[cfg(not(target_os = "macos"))] - use super::{Error, Tag}; + use super::Error; + #[cfg(unix)] + use super::dirent; + use super::{EntryKind, Fd, Result, Tag}; use bun_paths::OSPathChar; const BUF_SIZE: usize = 8192; @@ -227,9 +232,8 @@ pub mod dir_iterator { #[cfg(not(windows))] #[inline] fn borrow(s: &[u8]) -> Name { - // SAFETY: `s` is a slice into a kernel-written dirent record; the - // byte at `s.as_ptr().add(s.len())` is the in-record NUL terminator - // and lies within the same `reclen`-sized allocation. + // SAFETY: `s` is a `dirent::Entry` name, so the byte after it is its NUL, + // inside the same record. debug_assert!(unsafe { *s.as_ptr().add(s.len()) } == 0); Name { ptr: core::ptr::NonNull::from(s).cast(), @@ -278,7 +282,7 @@ pub mod dir_iterator { #[cfg(not(windows))] #[inline] pub fn as_zstr(&self) -> &bun_core::ZStr { - // SAFETY: `ptr[len] == 0` (kernel NUL-terminates `d_name`); see + // SAFETY: `dirent::parse` checked that `ptr[len] == 0`. See the // `borrow()` debug_assert. unsafe { bun_core::ZStr::from_raw(self.ptr.as_ptr(), self.len) } } @@ -339,6 +343,19 @@ pub mod dir_iterator { } } + /// Parses the record at `index`. The last refill wrote `end_index` bytes. Returns `None` if the + /// record is malformed. + #[cfg(unix)] + #[inline] + fn record_at(buf: &AlignedBuf, index: usize, end_index: usize) -> Option> { + if end_index > BUF_SIZE { + return None; + } + // SAFETY: the syscall filled `[0..end_index]`, and `end_index <= BUF_SIZE`. + let filled = unsafe { buf.filled(end_index) }; + dirent::parse::(filled, index) + } + // ── Linux / Android ────────────────────────────────────────────────── // Same `getdents64(2)` walk for both — Android is the same kernel, and // `linux_syscall::getdents64` is a raw syscall (no libc wrapper involved). @@ -379,38 +396,16 @@ pub mod dir_iterator { self.index = 0; self.end_index = rc as usize; } - // struct linux_dirent64 { u64 d_ino; i64 d_off; u16 d_reclen; - // u8 d_type; char d_name[]; } - let base = self.index; - // SAFETY: kernel filled `[0..end_index]`; `base < end_index` and - // each record fits entirely in `[base..base+reclen) ⊆ [0..end_index)`. - let buf = unsafe { self.buf.filled(self.end_index) }; - let d_ino = u64::from_ne_bytes( - buf[base..base + 8] - .try_into() - .expect("infallible: size matches"), - ); - let reclen = u16::from_ne_bytes([buf[base + 16], buf[base + 17]]) as usize; - let d_type = buf[base + 18]; - self.index = base + reclen; - - // d_name is NUL-terminated within the record. Use a SIMD-vectorized - // scan for the terminator; a scalar - // byte loop here showed up in startup profiles on large directories. - let name_field = &buf[base + 19..base + reclen]; - let nul = bun_core::strings::index_of_char_usize(name_field, 0) - .unwrap_or(name_field.len()); - let name = &name_field[..nul]; - - // skip . and .. entries - if name == b"." || name == b".." { - continue; - } + let Some(record) = record_at(&self.buf, self.index, self.end_index) else { + return dirent::end_walk(&mut self.end_index, Tag::getdents64); + }; + self.index = record.next; + let Some(entry) = record.entry else { continue }; return Ok(Some(IteratorResult { - name: Name::borrow(name), - kind: kind_from_dt(d_type), - ino: d_ino, + name: Name::borrow(entry.name), + kind: kind_from_dt(entry.d_type), + ino: entry.ino, })); } } @@ -482,34 +477,16 @@ pub mod dir_iterator { let flag = u32::from_ne_bytes(flag); self.received_eof = self.end_index <= (BUF_SIZE - 4) && flag == 1; } - // Darwin `struct dirent` (64-bit ino): - // u64 d_ino; u64 d_seekoff; u16 d_reclen; u16 d_namlen; - // u8 d_type; char d_name[]; - let base = self.index; - // SAFETY: kernel filled `[0..end_index]`; each record fits in - // `[base..base+reclen) ⊆ [0..end_index)`. - let buf = unsafe { self.buf.filled(self.end_index) }; - let d_ino = u64::from_ne_bytes( - buf[base..base + 8] - .try_into() - .expect("infallible: size matches"), - ); - let reclen = u16::from_ne_bytes([buf[base + 16], buf[base + 17]]) as usize; - let namlen = u16::from_ne_bytes([buf[base + 18], buf[base + 19]]) as usize; - let d_type = buf[base + 20]; - self.index = base + reclen; - - // `d_name` is NUL-terminated at `[namlen]` (within `reclen`). - let name = &buf[base + 21..base + 21 + namlen]; - - if name == b"." || name == b".." || d_ino == 0 { - continue; - } + let Some(record) = record_at(&self.buf, self.index, self.end_index) else { + return dirent::end_walk(&mut self.end_index, Tag::getdirentries64); + }; + self.index = record.next; + let Some(entry) = record.entry else { continue }; return Ok(Some(IteratorResult { - name: Name::borrow(name), - kind: kind_from_dt(d_type), - ino: d_ino, + name: Name::borrow(entry.name), + kind: kind_from_dt(entry.d_type), + ino: entry.ino, })); } } @@ -555,34 +532,16 @@ pub mod dir_iterator { self.index = 0; self.end_index = rc as usize; } - // FreeBSD 12+ `struct dirent` (ino64): - // u64 d_fileno; i64 d_off; u16 d_reclen; u8 d_type; u8 pad0; - // u16 d_namlen; u16 pad1; char d_name[]; - let base = self.index; - // SAFETY: kernel filled `[0..end_index]`; each record fits in - // `[base..base+reclen) ⊆ [0..end_index)`. - let buf = unsafe { self.buf.filled(self.end_index) }; - let fileno = u64::from_ne_bytes( - buf[base..base + 8] - .try_into() - .expect("infallible: size matches"), - ); - let reclen = u16::from_ne_bytes([buf[base + 16], buf[base + 17]]) as usize; - let d_type = buf[base + 18]; - let namlen = u16::from_ne_bytes([buf[base + 20], buf[base + 21]]) as usize; - self.index = base + reclen; - - // `d_name` is NUL-terminated at `[namlen]` (within `reclen`). - let name = &buf[base + 24..base + 24 + namlen]; - - if name == b"." || name == b".." || fileno == 0 { - continue; - } + let Some(record) = record_at(&self.buf, self.index, self.end_index) else { + return dirent::end_walk(&mut self.end_index, Tag::getdents64); + }; + self.index = record.next; + let Some(entry) = record.entry else { continue }; return Ok(Some(IteratorResult { - name: Name::borrow(name), - kind: kind_from_dt(d_type), - ino: fileno, + name: Name::borrow(entry.name), + kind: kind_from_dt(entry.d_type), + ino: entry.ino, })); } } @@ -1420,6 +1379,8 @@ impl Tag { pub(crate) const flistxattr: Tag = Tag(110); #[cfg(not(windows))] pub(crate) const fgetxattr: Tag = Tag(111); + pub const epoll_create1: Tag = Tag(112); + pub const pthread_create: Tag = Tag(113); // `inotify_init1`/`inotify_add_watch` fold under the generic `.watch` // tag; `INotifyWatcher.rs` spells it `.inotify`. Alias to `.watch` // so the JS-facing `err.syscall == "watch"` string stays node-compatible. @@ -1427,7 +1388,7 @@ impl Tag { /// The tag name — spelling is frozen (JS-facing /// `err.syscall` string; node-compat code matches on it). pub fn name(self) -> &'static str { - const NAMES: [&str; 112] = [ + const NAMES: [&str; 114] = [ "TODO", "dup", "access", @@ -1541,6 +1502,8 @@ impl Tag { "flock", "flistxattr", "fgetxattr", + "epoll_create1", + "pthread_create", ]; NAMES.get(self.0 as usize).copied().unwrap_or("unknown") } @@ -8577,6 +8540,8 @@ pub mod elf { /// `dlpi_name` copied to an owned buffer (empty when libc reports `NULL`, /// as Android does for the main program). pub name: Box<[u8]>, + /// End address (exclusive) of the `PT_LOAD` segment that contains the lookup address. + pub segment_end: usize, } /// Walk loaded ELF objects via `dl_iterate_phdr`, returning the one whose @@ -8637,6 +8602,7 @@ pub mod elf { context.result = Some(LoadedModule { base_address: info.dlpi_addr as usize, name, + segment_end: seg_end, }); return 1; // error.Found → stop iteration } diff --git a/src/sys/windows/mod.rs b/src/sys/windows/mod.rs index 8bf44bc47161..583e458b91e9 100644 --- a/src/sys/windows/mod.rs +++ b/src/sys/windows/mod.rs @@ -507,7 +507,23 @@ pub fn CreateHardLinkW( rc } -pub use bun_windows_sys::externs::CopyFileW; +/// Calls `CopyFileW`. kernel32 reads the code unit before an empty destination +/// name, so an empty `dest` is replaced with a pointer to the second of two NULs. +/// +/// # Safety +/// `source` and `dest` must point to NUL-terminated wide strings that are +/// valid for the duration of the call. +pub unsafe fn CopyFileW(source: LPCWSTR, dest: LPCWSTR, bFailIfExists: BOOL) -> BOOL { + static EMPTY_DEST: [u16; 2] = [0, 0]; + // SAFETY: the caller guarantees `dest` is NUL-terminated, so its first unit is readable. + let dest = if unsafe { *dest } == 0 { + EMPTY_DEST[1..].as_ptr() + } else { + dest + }; + // SAFETY: same contract. `EMPTY_DEST[1..]` is also NUL-terminated. + unsafe { externs::CopyFileW(source, dest, bFailIfExists) } +} /// `bun.windows.Error` — alias for `Win32Error`. pub type Error = Win32Error; @@ -1566,19 +1582,15 @@ pub fn become_watcher_manager() -> ! { } loop { - if let Err(err) = spawn_watcher_child(&mut procinfo, job) { - bun_core::handle_error_return_trace(err); - if err == bun_errno::SystemErrno::EIO { - // This read is best-effort — Drop guards inside - // `spawn_watcher_child` (FreeEnvironmentStringsW, Vec drops - // via HeapFree) may have clobbered the thread's last-error - // before we get here. A proper fix would thread the captured - // Win32 code through the error payload, which requires - // changing `spawn_watcher_child`'s return type. - let last = Win32Error::get(); - bun_core::Output::panic(format_args!("Failed to spawn process: {:?}\n", last)); - } - bun_core::Output::panic(format_args!("Failed to spawn process: {}\n", err)); + if let Err(code) = spawn_watcher_child(&mut procinfo, job) { + // The executable can be renamed or replaced while `--watch` runs. + bun_core::err_generic!( + "Failed to start \"{}\": {} (CreateProcessW)", + bun_core::fmt::utf16(exe_path_w().as_slice()), + <&'static str>::from(code.to_system_errno()), + ); + bun_core::Output::flush(); + bun_core::Global::exit(1); } // `kernel32::WaitForSingleObject` is the local `safe fn` re-decl // (by-value `HANDLE`/`DWORD` only); check `WAIT_FAILED` inline. @@ -1612,10 +1624,11 @@ pub fn become_watcher_manager() -> ! { } } +/// Returns the failing call's `GetLastError()` as `Err`. pub(crate) fn spawn_watcher_child( procinfo: &mut PROCESS_INFORMATION, job: HANDLE, -) -> Result<(), bun_errno::SystemErrno> { +) -> Result<(), Win32Error> { // https://devblogs.microsoft.com/oldnewthing/20230209-00/?p=107812 let mut attr_size: usize = 0; // SAFETY: query size with null buffer @@ -1627,7 +1640,7 @@ pub(crate) fn spawn_watcher_child( if unsafe { externs::InitializeProcThreadAttributeList(p.as_mut_ptr(), 1, 0, &mut attr_size) } == 0 { - return Err(bun_errno::SystemErrno::EIO); + return Err(Win32Error::get()); } let mut job_local = job; // SAFETY: p initialized above; job_local valid for sizeof(HANDLE) @@ -1643,7 +1656,7 @@ pub(crate) fn spawn_watcher_child( ) } == 0 { - return Err(bun_errno::SystemErrno::EIO); + return Err(Win32Error::get()); } // The win32 layer exposes these as DWORD constants — assemble the raw mask. @@ -1739,7 +1752,7 @@ pub(crate) fn spawn_watcher_child( ) }; if rc == 0 { - return Err(bun_errno::SystemErrno::EIO); + return Err(Win32Error::get()); } let mut is_in_job: BOOL = 0; let _ = kernel32_2::IsProcessInJob(procinfo.hProcess, job, &mut is_in_job); diff --git a/src/uws_sys/App.rs b/src/uws_sys/App.rs index 70461654c4c8..5a130245d53f 100644 --- a/src/uws_sys/App.rs +++ b/src/uws_sys/App.rs @@ -273,17 +273,6 @@ impl App { thunk::zst::()(this, AnyRequest::H1(req), resp) } - pub fn domain(&mut self, pattern: &ZStr) { - // SAFETY: pattern is NUL-terminated; self is a valid app. - unsafe { - c::uws_app_domain( - Self::SSL_FLAG, - std::ptr::from_mut::(self).cast::(), - pattern.as_ptr().cast(), - ) - } - } - pub fn on_client_error( &mut self, handler: extern "C" fn(*mut c_void, c_int, *mut us_socket_t, u8, *mut u8, c_int), @@ -633,7 +622,6 @@ pub mod c { handler: uws_method_handler, user_data: *mut c_void, ); - pub(crate) fn uws_app_domain(ssl: i32, app: *mut uws_app_t, domain: *const c_char); pub(crate) fn uws_app_listen_with_config( ssl: i32, app: *mut uws_app_t, diff --git a/src/uws_sys/InternalLoopData.rs b/src/uws_sys/InternalLoopData.rs index 80cf50a7ed03..e1777af620e2 100644 --- a/src/uws_sys/InternalLoopData.rs +++ b/src/uws_sys/InternalLoopData.rs @@ -64,6 +64,9 @@ pub struct InternalLoopData { // Higher tier (`bun_runtime`) casts this back when reading. pub jsc_vm: *const c_void, pub tick_depth: c_int, + pub(crate) num_unresumable_sockets: c_int, + /// `us_internal_unresumable_socket_t *`. Only `loop.c` reads or frees this list. + pub(crate) unresumable_sockets: *mut c_void, } impl InternalLoopData { diff --git a/src/uws_sys/ListenSocket.rs b/src/uws_sys/ListenSocket.rs index 88796909465f..03a940d7416e 100644 --- a/src/uws_sys/ListenSocket.rs +++ b/src/uws_sys/ListenSocket.rs @@ -37,28 +37,17 @@ impl ListenSocket { } /// `ssl_ctx` is `SSL_CTX_up_ref`'d for the SNI node; the listener drops - /// that ref on close, or when `hostname` is registered again. `user` is the per-domain handle - /// `find_server_name_userdata` recovers (uWS uses an `HttpRouter*`; Bun.listen - /// passes null). + /// that ref on close, or when `hostname` is registered again. /// /// `ssl_ctx` is taken as a raw `*mut SslCtx` (not `&mut SslCtx`) because /// `SSL_CTX` is a refcounted shared object — C `SSL_CTX_up_ref`s it and /// stores the pointer past this call, so the caller cannot legitimately - /// hold exclusive `&mut` access. `user` is likewise raw `*mut` because the - /// C side stores it and `find_server_name_userdata` later hands it back as - /// a mutable pointer; accepting `&U` and const-casting would make that - /// round-trip UB. - pub fn add_server_name( - &mut self, - hostname: &core::ffi::CStr, - ssl_ctx: *mut SslCtx, - user: *mut c_void, - ) { + /// hold exclusive `&mut` access. + pub fn add_server_name(&mut self, hostname: &core::ffi::CStr, ssl_ctx: *mut SslCtx) { // SAFETY: self and hostname are valid for the duration of the call; // caller guarantees `ssl_ctx` is non-null and points at a live SSL_CTX - // (C up-refs and stores it); `user` is an opaque caller-owned pointer - // stored verbatim by C. - unsafe { us_listen_socket_add_server_name(self, hostname.as_ptr(), ssl_ctx, user) } + // (C up-refs and stores it). + unsafe { us_listen_socket_add_server_name(self, hostname.as_ptr(), ssl_ctx) } } /// Makes `ctx` the default `SSL_CTX` for sockets accepted from now on. @@ -86,7 +75,6 @@ unsafe extern "C" { ls: *mut ListenSocket, hostname: *const c_char, ssl_ctx: *mut SslCtx, - user: *mut c_void, ); fn us_listen_socket_set_default_ssl_ctx(ls: *mut ListenSocket, ctx: *mut SslCtx); safe fn us_listen_socket_on_server_name( diff --git a/src/uws_sys/WebSocket.rs b/src/uws_sys/WebSocket.rs index cb7a59d03364..8389c6e35469 100644 --- a/src/uws_sys/WebSocket.rs +++ b/src/uws_sys/WebSocket.rs @@ -92,6 +92,12 @@ impl AnyWebSocket { c::uws_ws_close(ssl, ws) } + /// Closes the connection with 1011 after failing to allocate a copy of an incoming message. + pub fn fail_out_of_memory(self) { + let (ssl, ws) = self.split(); + c::uws_ws_fail_out_of_memory(ssl, ws) + } + pub fn send(self, message: &[u8], opcode: Opcode, compress: bool, fin: bool) -> SendStatus { let (ssl, ws) = self.split(); // SAFETY: `ws` is a live uWS-owned socket (S012 opaque); ptr+len from &[u8]. @@ -602,6 +608,7 @@ pub mod c { ) -> *mut WebSocketUpgradeContext; pub(crate) safe fn uws_ws_get_user_data(ssl: i32, ws: &mut RawWebSocket) -> *mut c_void; pub(crate) safe fn uws_ws_close(ssl: i32, ws: &mut RawWebSocket); + pub(crate) safe fn uws_ws_fail_out_of_memory(ssl: i32, ws: &mut RawWebSocket); pub(crate) fn uws_ws_send_with_options( ssl: i32, ws: *mut RawWebSocket, diff --git a/src/uws_sys/libuwsockets.cpp b/src/uws_sys/libuwsockets.cpp index f628d59de1b5..8d1faf19ebaa 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -554,19 +554,6 @@ extern "C" } } - void uws_app_domain(int ssl, uws_app_t *app, const char *server_name) - { - if (ssl) - { - uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; - uwsApp->domain(server_name); - } - else - { - uWS::App *uwsApp = (uWS::App *)app; - uwsApp->domain(server_name); - } - } void uws_app_set_max_http_header_size(int ssl, uws_app_t *app, uint64_t max_header_size) { if (ssl) { uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; @@ -709,6 +696,11 @@ extern "C" withWebSocket(ssl, ws, [](auto *uws) { uws->close(); }); } + void uws_ws_fail_out_of_memory(int ssl, uws_websocket_t *ws) + { + withWebSocket(ssl, ws, [](auto *uws) { uws->failOutOfMemory(); }); + } + void uws_ws_pause(int ssl, uws_websocket_t *ws) { withWebSocket(ssl, ws, [](auto *uws) { uws->pause(); }); diff --git a/src/watcher/Watcher.rs b/src/watcher/Watcher.rs index 59cdd4020637..9e8f65f480cb 100644 --- a/src/watcher/Watcher.rs +++ b/src/watcher/Watcher.rs @@ -32,6 +32,10 @@ pub const REQUIRES_FILE_DESCRIPTORS: bool = true; #[cfg(not(any(target_os = "macos", target_os = "freebsd")))] pub const REQUIRES_FILE_DESCRIPTORS: bool = false; +/// True if events match watch items by path, so an item keeps working after its file is deleted +/// and recreated. +pub const WATCHES_BY_PATH: bool = cfg!(windows); + /// Open flags for an fd that exists only to receive kqueue VNODE events. /// Darwin has O_EVTONLY (no read/write access requested); FreeBSD has no /// equivalent, so the watch fd is a plain O_RDONLY. `O_CLOEXEC` keeps the fd @@ -290,13 +294,15 @@ impl Watcher { // Shared access suffices (atomics + mutex + column reads); the borrow // ends before the free below. let me = unsafe { &*this }; + // `thread_body` clears `watchloop_handle` under this mutex when it returns ownership. + me.mutex.lock(); if me.watchloop_handle.load() { - me.mutex.lock(); me.close_descriptors.store(close_descriptors); me.running.store(false); me.mutex.unlock(); false } else { + me.mutex.unlock(); if close_descriptors && me.running.load() { let fds = me.watchlist.items_fd(); for &fd in fds { @@ -354,27 +360,29 @@ impl Watcher { log!("Watcher started"); - let owner_still_alive = match self.watch_loop() { - Err(err) => { + if let Err(err) = self.watch_loop() { + self.platform.stop(); + // Hold the mutex so `shutdown` can't free `self` or `ctx` before `on_error` returns. + let _guard = self.mutex.lock_guard(); + if self.running.load() { self.watchloop_handle.store(false); - self.platform.stop(); - let running = self.running.load(); - if running { - (self.on_error)(self.ctx, err); - } - running + (self.on_error)(self.ctx, err); + return true; } - Ok(()) => false, - }; + } - // deinit and close descriptors if needed - if self.close_descriptors.load() { + // `thread_main` frees `self` next, so wait for `shutdown` to release the mutex. + let close_descriptors = { + let _guard = self.mutex.lock_guard(); + self.close_descriptors.load() + }; + if close_descriptors { let fds = self.watchlist.items_fd(); for &fd in fds { let _ = bun_sys::close(fd); } } - owner_still_alive + false } pub fn flush_evictions(&mut self) { @@ -557,6 +565,9 @@ impl Watcher { // so we must copy into a NUL-terminated scratch buffer (mirrors the // directory branch below) instead of pointing at the caller's slice. let mut buf = bun_paths::path_buffer_pool::get(); + if file_path.len() >= buf.len() { + return Err(sys::Error::new(sys::E::ENAMETOOLONG, sys::Tag::watch)); + } let slice: &ZStr = if CLONE_FILE_PATH { buf[0..file_path.len()].copy_from_slice(file_path); buf[file_path.len()] = 0; @@ -631,6 +642,9 @@ impl Watcher { #[cfg(any(target_os = "linux", target_os = "android"))] let eventlist_index = { let mut buf = bun_paths::path_buffer_pool::get(); + if file_path.len() >= buf.len() { + return Err(sys::Error::new(sys::E::ENAMETOOLONG, sys::Tag::watch)); + } let path: &ZStr = if CLONE_FILE_PATH && !file_path.is_empty() && file_path[file_path.len() - 1] == 0 diff --git a/src/watcher/WindowsWatcher.rs b/src/watcher/WindowsWatcher.rs index e39adde6e990..10b66891da71 100644 --- a/src/watcher/WindowsWatcher.rs +++ b/src/watcher/WindowsWatcher.rs @@ -32,6 +32,7 @@ impl Default for WindowsWatcher { overlapped: bun_core::ffi::zeroed(), buf: [0u8; 64 * 1024], dir_handle: w::INVALID_HANDLE_VALUE, + read_pending: false, }, buf: PathBuffer::ZEROED, base_idx: 0, @@ -77,6 +78,8 @@ pub struct DirWatcher { /// `EventIterator::next`). pub(crate) buf: [u8; 64 * 1024], pub(crate) dir_handle: HANDLE, + /// True while a read is in flight. The kernel owns `buf` and `overlapped` until it completes. + read_pending: bool, } // `OVERLAPPED` = 32 bytes / align 8 on Win64; `buf` must be ≥ 4-aligned for @@ -84,7 +87,7 @@ pub struct DirWatcher { // total size) is what proves that alignment requirement. bun_core::assert_ffi_layout!( DirWatcher, - 32 + 64 * 1024 + ::core::mem::size_of::(), + 32 + 64 * 1024 + ::core::mem::size_of::() + ::core::mem::align_of::(), ::core::mem::align_of::(); overlapped @ 0, buf @ 32, dir_handle @ 32 + 64 * 1024, ); @@ -96,8 +99,12 @@ const _: () = assert!( ); impl DirWatcher { - /// invalidates any EventIterators + /// invalidates any EventIterators. No-op if the previous read is still pending, which happens + /// after a poll times out. fn prepare(&mut self) -> bun_sys::Result<()> { + if self.read_pending { + return Ok(()); + } let filter = w::FileNotifyChangeFilter::FILE_NAME | w::FileNotifyChangeFilter::DIR_NAME | w::FileNotifyChangeFilter::LAST_WRITE @@ -122,6 +129,7 @@ impl DirWatcher { return Err(bun_sys::Error::from_win32(err, bun_sys::Tag::watch)); } bun_core::scoped_log!(watcher, "read directory changes!"); + self.read_pending = true; Ok(()) } } @@ -313,6 +321,10 @@ impl WindowsWatcher { timeout as w::DWORD, ) }; + // A failed read still dequeues its completion. + if overlapped == &raw mut self.watcher.overlapped { + self.watcher.read_pending = false; + } if rc == 0 { let err = w::Win32Error::get(); // `WAIT_TIMEOUT` (258) — not yet a named const on `bun_sys::windows::Win32Error`. @@ -401,11 +413,6 @@ pub(crate) fn watch_loop_cycle(this: &mut Watcher) -> bun_sys::Result<()> { // NOTE: using a 1ms timeout would be ideal, but that actually makes the thread wait for at least 10ms more than it should // Instead we use a 0ms timeout, which may not do as much coalescing but is more responsive. timeout = Timeout::None; - bun_core::scoped_log!( - watcher, - "number of watched items: {}", - this.watchlist.items_file_path().len() - ); while let Some(event) = iter.next() { // `event.filename` is a `RawSlice` into `this.platform.watcher.buf`, // live for the duration of this iteration (no `prepare()` until the @@ -413,7 +420,7 @@ pub(crate) fn watch_loop_cycle(this: &mut Watcher) -> bun_sys::Result<()> { let filename: &[u16] = event.filename.slice(); let convert_res = strings::copy_utf16_into_utf8(&mut this.platform.buf[base_idx..], filename); - let eventpath_len = base_idx + convert_res.written as usize; + let eventpath_len = base_idx + convert_res.written; bun_core::scoped_log!( watcher, @@ -430,53 +437,43 @@ pub(crate) fn watch_loop_cycle(this: &mut Watcher) -> bun_sys::Result<()> { // to implement and maintain. // - others that i'm not thinking of - let n_items = this.watchlist.items_file_path().len(); - for item_idx in 0..n_items { - // reshaped for borrowck — `rel` is computed in a scoped - // block so the borrows of `this.watchlist` / `this.platform.buf` - // are released before we touch `this.watch_events` or hand the - // whole `&mut Watcher` to `process_watch_event_batch`. - let rel = { + let mut item_idx = usize::MAX; + loop { + { + // Other threads append to the watchlist under this mutex, which can reallocate it. + let _guard = this.mutex.lock_guard(); + // Eviction is a swap-remove, so walk from the end to avoid skipping items. + item_idx = item_idx.min(this.watchlist.len()); let eventpath = &this.platform.buf[..eventpath_len]; - let path = &this.watchlist.items_file_path()[item_idx]; - let rel = is_parent_or_equal(path.as_ref(), eventpath); - bun_core::scoped_log!( - watcher, - "checking path: {} = .{}", - bstr::BStr::new(path.as_ref()), - match rel { - ParentEqual::Parent => "parent", - ParentEqual::Equal => "equal", - ParentEqual::Unrelated => "unrelated", + while item_idx > 0 && event_id < this.watch_events.len() { + item_idx -= 1; + let path = &this.watchlist.items_file_path()[item_idx]; + let rel = is_parent_or_equal(path.as_ref(), eventpath); + bun_core::scoped_log!( + watcher, + "checking path: {} = .{}", + bstr::BStr::new(path.as_ref()), + match rel { + ParentEqual::Parent => "parent", + ParentEqual::Equal => "equal", + ParentEqual::Unrelated => "unrelated", + } + ); + if rel == ParentEqual::Unrelated { + continue; } - ); - rel - }; - // skip unrelated items - if rel == ParentEqual::Unrelated { - continue; + this.watch_events[event_id] = + create_watch_event(&event, item_idx as WatchItemIndex); + event_id += 1; + } } - // if the event is for a parent dir of the item, only emit it if it's a delete or rename - - // Check if we're about to exceed the watch_events array capacity - if event_id >= this.watch_events.len() { - // Process current batch of events - process_watch_event_batch(this, event_id)?; - // passing `this: &mut Watcher` above materialises a fresh Unique - // borrow over the whole `Watcher`, which under Stacked Borrows pops the - // SharedReadOnly tag that `iter.watcher` (a `*const DirWatcher` derived from - // an earlier `&this.platform.watcher`) carries. The next `iter.next()` would - // then dereference a pointer with invalidated provenance — UB that MIRI flags. - // The callee never touches `platform.watcher`, so re-deriving the pointer - // here from the now-current `&mut Watcher` restores valid provenance. - iter.watcher = BackRef::new(&this.platform.watcher); - // Reset event_id to start a new batch - event_id = 0; + if item_idx == 0 { + break; } - - this.watch_events[event_id] = - create_watch_event(&event, item_idx as WatchItemIndex); - event_id += 1; + process_watch_event_batch(this, event_id)?; + // The `&mut Watcher` passed above invalidated `iter.watcher`, so derive it again. + iter.watcher = BackRef::new(&this.platform.watcher); + event_id = 0; } } } diff --git a/src/watcher/lib.rs b/src/watcher/lib.rs index 07ffe348d7a0..6ed9a52e5f31 100644 --- a/src/watcher/lib.rs +++ b/src/watcher/lib.rs @@ -34,6 +34,6 @@ pub use error::{Error, Result}; pub use WatchItemKind as Kind; pub use watcher_impl::{ AnyResolveWatcher, ChangedFilePath, Event, FdOwnership, HashType, MAX_COUNT, Op, PackageJSON, - REQUIRES_FILE_DESCRIPTORS, WATCH_OPEN_FLAGS, WatchEvent, WatchItemColumns, WatchItemKind, - WatchList, Watcher, WatcherContext, + REQUIRES_FILE_DESCRIPTORS, WATCH_OPEN_FLAGS, WATCHES_BY_PATH, WatchEvent, WatchItemColumns, + WatchItemKind, WatchList, Watcher, WatcherContext, }; diff --git a/src/zstd/lib.rs b/src/zstd/lib.rs index f398e40ae2e8..b9fd810c9449 100644 --- a/src/zstd/lib.rs +++ b/src/zstd/lib.rs @@ -182,7 +182,6 @@ pub mod c { param: ZSTD_dParameter, value: c_int, ) -> usize; - pub fn ZSTD_CCtx_reset(cctx: *mut ZSTD_CCtx, reset: ZSTD_ResetDirective) -> usize; pub fn ZSTD_DCtx_reset(dctx: *mut ZSTD_DCtx, reset: ZSTD_ResetDirective) -> usize; pub fn ZSTD_compressStream2( cctx: *mut ZSTD_CCtx, diff --git a/test/bake/app-options.test.ts b/test/bake/app-options.test.ts index d686e9b5eb22..c7cab2036326 100644 --- a/test/bake/app-options.test.ts +++ b/test/bake/app-options.test.ts @@ -179,3 +179,203 @@ test.concurrent("Bun.serve({ app }) reports a route with more than 64 params", a expect(stdout).toBe("started\n"); expect(exitCode).toBe(0); }); + +test.concurrent("Bun.serve({ app }) serves routes when root is a relative path", async () => { + using dir = tempDir("bake-app-relative-root", { + "server.ts": `export function render(req, meta) { return meta.pageModule.default(); }`, + "routes/index.ts": `export default () => new Response("index route");`, + "fixture.ts": ` + const fsr = { root: "routes", style: "nextjs-pages", serverEntryPoint: "./server.ts" }; + const server = Bun.serve({ + port: 0, + development: true, + app: { framework: { fileSystemRouterTypes: [fsr] }, root: "./" }, + fetch: () => new Response("fallback"), + }); + const res = await fetch(server.url); + console.log(res.status + " " + (await res.text())); + await server.stop(true); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + + // The dev server also prints a "Bundled page" line. + expect(stdout.split("\n")).toContain("200 index route"); + expect(exitCode).toBe(0); +}); + +test.concurrent("Bun.serve({ app }) rejects a root longer than the path buffer", async () => { + using dir = tempDir("bake-app-long-root", { + "server.ts": `export function render() { return new Response("ok"); }`, + "fixture.ts": ` + const long = Buffer.alloc(100_000, "a").toString(); + const fsr = { root: "routes", style: "nextjs-pages", serverEntryPoint: "./server.ts" }; + const cases = { + "root": { root: long, framework: { fileSystemRouterTypes: [fsr] } }, + "fileSystemRouterTypes[0].root": { framework: { fileSystemRouterTypes: [{ ...fsr, root: long }] } }, + }; + for (const [name, app] of Object.entries(cases)) { + try { + Bun.serve({ port: 0, development: true, app, fetch: () => new Response() }).stop(true); + console.log(name + " -> no error"); + } catch (e) { + console.log(name + " -> " + e.message); + } + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe("ENAMETOOLONG: Failed to resolve 'fileSystemRouterTypes[0].root' for framework\n"); + expect(stdout).toBe( + "root -> 'app.root' is too long\nfileSystemRouterTypes[0].root -> Framework is missing required files!\n", + ); + expect(exitCode).toBe(0); +}); + +test.concurrent("Bun.serve({ app }) routes on the request target's pathname", async () => { + using dir = tempDir("bake-app-request-target", { + "server.ts": `export function render(req, meta) { return meta.pageModule.default(req, meta); }`, + "routes/index.ts": `export default () => new Response("index route");`, + "routes/echo/[...rest].ts": `export default (req, meta) => new Response("rest=" + meta.params.rest.join());`, + "fixture.ts": ` + const fsr = { root: "routes", style: "nextjs-pages", serverEntryPoint: "./server.ts" }; + const server = Bun.serve({ port: 0, development: true, app: { framework: { fileSystemRouterTypes: [fsr] } } }); + const origin = "http://127.0.0.1:" + server.port; + for (const requestLine of [ + "GET /?a=b", + "GET /echo/https://example.com/a?q=1#hash", + "GET " + origin + "/echo/a/b?q=1", + "GET " + origin, + "GET " + origin + "?next=/echo/a", + "CONNECT 127.0.0.1:" + server.port, + ]) { + const { promise, resolve, reject } = Promise.withResolvers(); + let response = ""; + await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + socket: { + open: socket => void socket.write(requestLine + " HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n"), + data: (socket, chunk) => void (response += chunk), + close: resolve, + error: (socket, error) => reject(error), + }, + }); + await promise; + console.log(response.slice(9, 12), response.split("\\r\\n\\r\\n")[1]); + } + await server.stop(true); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + + expect(stdout.split("\n")).toEqual([ + "200 index route", + "200 rest=https:,example.com,a", + "200 rest=a,b", + "200 index route", + "200 index route", + "404 404 Not Found", + "", + ]); + expect(exitCode).toBe(0); +}); + +// The project root is apps/api. +test.concurrent.each([ + ["inside the project root", "pages", "apps/api/pages"], + ["a sibling directory", "../web/pages", "apps/web/pages"], + ["an ancestor directory", "..", "apps"], + ["a directory next to an ancestor", "../../routes", "routes"], +])("Bun.serve({ app }) serves routes from a router root that is %s", async (_, root, pages) => { + // An ancestor directory also contains the project's own files, which become routes. + const withSyntaxError = root !== ".."; + using dir = tempDir("bake-app-router-root", { + "apps/api/server.ts": `export function render(req, meta) { return meta.pageModule.default(req, meta); }`, + "apps/api/fixture.ts": ` + const fsr = { root: ${JSON.stringify(root)}, style: "nextjs-pages", serverEntryPoint: "./server.ts" }; + const server = Bun.serve({ port: 0, development: true, app: { framework: { fileSystemRouterTypes: [fsr] } } }); + for (const pathname of ["/", "/blog/hello"]) { + const res = await fetch(new URL(pathname, server.url)); + console.log(pathname, res.status, await res.text()); + } + await server.stop(true); + `, + [`${pages}/index.ts`]: `export default () => new Response("index");`, + [`${pages}/blog/[slug].ts`]: `export default (req, meta) => new Response(meta.params.slug);`, + ...(withSyntaxError && { [`${pages}/blog/[oops.ts`]: `export default () => new Response("");` }), + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.ts"], + env: bunEnv, + cwd: String(dir) + "/apps/api", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout, stderr).toBe("/ 200 index\n/blog/hello 200 hello\n"); + if (withSyntaxError) { + // The message is indented to the column of the offending character. + const label = `${root}/blog/[oops.ts`; + const indent = Buffer.alloc(`error: "`.length + label.indexOf("["), " ").toString(); + expect(stderr).toContain(`error: "${label}" is not a valid route\n`); + expect(stderr).toContain(`\n${indent}Missing "]" to match this route parameter\n`); + } + expect(exitCode).toBe(0); +}); + +test.concurrent("Bun.serve({ app }) reports an unsupported app router file", async () => { + using dir = tempDir("bake-app-extra-file", { + "server.ts": `export function render(req, meta) { return meta.pageModule.default(); }`, + "app/page.ts": `export default () => new Response("page");`, + "app/loading.ts": `export default () => new Response("loading");`, + "fixture.ts": ` + const fsr = { root: "app", style: "nextjs-app-ui", serverEntryPoint: "./server.ts" }; + const server = Bun.serve({ port: 0, development: true, app: { framework: { fileSystemRouterTypes: [fsr] } } }); + const res = await fetch(server.url); + console.log(res.status, await res.text()); + await server.stop(true); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toContain('error: "app/loading.ts" is not a valid route\n'); + expect(stderr).toContain('Bun Bake currently does not support "loading" files\n'); + expect(stdout).toBe("200 page\n"); + expect(exitCode).toBe(0); +}); diff --git a/test/bake/deinitialization.test.ts b/test/bake/deinitialization.test.ts index d31f594f4cc2..719fcdc85fca 100644 --- a/test/bake/deinitialization.test.ts +++ b/test/bake/deinitialization.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, isASAN, isWindows, tempDir } from "harness"; import path from "node:path"; test("dev server deinitializes itself", () => { @@ -50,3 +50,139 @@ test("dev server is deinitialized before its arena when listen fails", async () expect(stdout).toBe('{"code":"EADDRINUSE","deinits":1}\n'); expect(exitCode).toBe(0); }); + +// Every dev server registers its server runtime under the key "bake://server-runtime.js", +// so the source map table points to the most recently started server. +// Malloc=1 makes JSC allocate with the system malloc so that ASAN can detect a use-after-free. +test.skipIf(!isASAN)( + "a stopped dev server unregisters its server runtime's source provider", + async () => { + using dir = tempDir("bake-deinit-source-provider", { + "server.ts": ` + export function render(req, meta) { + return meta.pageModule.default(req, meta); + } + export function registerClientReference(value, file, uid) { + return { value, file, uid }; + } + `, + "routes/index.ts": ` + export default function () { + return new Response(new Error("probe").stack); + } + `, + "fixture.ts": ` + import { getDevServerDeinitCount } from "bun:internal-for-testing"; + import path from "node:path"; + + const serverEntryPoint = path.join(import.meta.dir, "server.ts"); + const framework = { + fileSystemRouterTypes: [{ root: "routes", style: "nextjs-pages", serverEntryPoint }], + serverComponents: { + separateSSRGraph: false, + serverRuntimeImportSource: serverEntryPoint, + serverRegisterClientReferenceExport: "registerClientReference", + }, + }; + const start = () => Bun.serve({ port: 0, app: { framework } }); + + async function collect(isDone) { + for (let i = 0; i < 200 && !isDone(); i++) { + Bun.gc(true); + await new Promise(resolve => setTimeout(resolve, 1)); + } + if (!isDone()) throw new Error("dev server was not deinitialized"); + } + + const survivor = start(); + + // The second server's provider replaces the survivor's table entry. + const deinitsBefore = getDevServerDeinitCount(); + const survivorDebugHooks = globalThis.DEBUG; + (function startAndStop() { + start().stop(true); + })(); + // In a debug build, globalThis.DEBUG.ASSERT is a function from the last runtime that ran, + // which would keep the second server's provider alive. + globalThis.DEBUG = survivorDebugHooks; + await collect(() => getDevServerDeinitCount() > deinitsBefore); + // A few more collections sweep the runtime's functions and free the provider. + for (let i = 0; i < 5; i++) { + Bun.gc(true); + await new Promise(resolve => setTimeout(resolve, 1)); + } + + const response = await fetch(survivor.url); + const stack = await response.text(); + survivor.stop(true); + console.log("status:", response.status); + console.log("runtime frame:", stack.includes("bake://server-runtime.js")); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.ts"], + cwd: String(dir), + env: { + ...bunEnv, + Malloc: "1", + // Malloc=1 exposes JSC's process-lifetime allocations to LSAN. + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=0"].filter(Boolean).join(":"), + }, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + + // Remapping the runtime frame in the stack trace reads the table entry. + expect(stdout).toEndWith("status: 200\nruntime frame: true\n"); + expect(exitCode).toBe(0); + }, + 60_000, +); + +test.skipIf(isWindows)( + "dev server closes the descriptor it opens to bundle an edited file again", + async () => { + using dir = tempDir("bake-reload-descriptors", { + "index.html": ``, + "entry.ts": `console.log("v0!");`, + "fixture.ts": ` + import { readdirSync, writeFileSync } from "node:fs"; + import html from "./index.html"; + + const server = Bun.serve({ port: 0, development: true, routes: { "/": html } }); + const text = url => fetch(new URL(url, server.url)).then(res => res.text()); + const openDescriptors = () => readdirSync(process.platform === "linux" ? "/proc/self/fd" : "/dev/fd").length; + async function bundled(marker) { + while (true) { + const script = (await text("/")).match(/\\/_bun\\/client\\/[^"]+\\.js/)?.[0]; + if (script && (await text(script)).includes(marker)) return; + } + } + + await bundled("v0!"); + const before = openDescriptors(); + for (let i = 1; i <= 20; i++) { + writeFileSync("entry.ts", 'console.log("v' + i + '!");'); + await bundled("v" + i + "!"); + } + console.log(openDescriptors() - before < 10 ? "closed" : "left open"); + await server.stop(true); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + + expect(stdout).toEndWith("closed\n"); + expect(exitCode).toBe(0); + }, + 60_000, +); diff --git a/test/bake/dev/bundle.test.ts b/test/bake/dev/bundle.test.ts index 8381429b912e..04d4e00788b8 100644 --- a/test/bake/dev/bundle.test.ts +++ b/test/bake/dev/bundle.test.ts @@ -1,6 +1,9 @@ // Bundle tests are tests concerning bundling bugs that only occur in DevServer. import { expect } from "bun:test"; -import { devTest, emptyHtmlFile, minimalFramework } from "../bake-harness"; +import { MAX_PATH_BYTES } from "harness"; +import fs from "node:fs"; +import path from "node:path"; +import { Dev, devTest, emptyHtmlFile, minimalFramework } from "../bake-harness"; devTest("import identifier doesnt get renamed", { framework: minimalFramework, @@ -99,6 +102,40 @@ devTest("importing a file before it is created", { await c.expectMessage("value: 456"); }, }); +// After a failed resolution, the dev server evicts the specifier's path and its parent from the +// resolver's directory cache. Here one of those two paths ends in a slash. +devTest("recovers from an unresolved import of a path that ends in a slash", { + files: { + "index.html": emptyHtmlFile({ + styles: [], + scripts: ["index.ts"], + }), + "index.ts": ` + import { abc } from './second/'; + console.log('value: ' + abc); + `, + }, + async test(dev) { + await using c = await dev.client("/", { + errors: [`index.ts:1:21: error: Could not resolve: "./second/"`], + }); + + for (const specifier of [ + dev.join("second") + path.sep, + // This path's parent is `/`. + dev.rootDir + path.sep + path.sep + "second", + ]) { + await dev.write("index.ts", `import ${JSON.stringify(specifier)};`, { + errors: [`index.ts:1:8: error: Could not resolve: "${specifier}"`], + }); + } + + await c.expectReload(async () => { + await dev.write("index.ts", `console.log('value: ' + 789);`); + }); + await c.expectMessage("value: 789"); + }, +}); devTest("default export same-scope handling", { files: { "index.html": emptyHtmlFile({ @@ -373,6 +410,29 @@ devTest('a "use client" module with a @jsxRuntime pragma keeps its client refere await dev.fetch("/").equals(references); }, }); +devTest("repairing a missing import in a framework without server components", { + framework: { fileSystemRouterTypes: minimalFramework.fileSystemRouterTypes }, + files: { + "routes/index.ts": ` + import "./does-not-exist"; + export default function () { + return new Response("index"); + } + `, + }, + async test(dev) { + expect((await dev.fetch("/")).status).toBe(500); + await dev.write( + "routes/index.ts", + ` + export default function () { + return new Response("index"); + } + `, + ); + await dev.fetch("/").equals("index"); + }, +}); // Regression test: DirectoryWatchStore.Dep.source_file_path borrows the key // string from IncrementalGraph.bundled_files. When a client-component boundary // is demoted (its "use client" directive is removed) the server graph calls @@ -641,6 +701,199 @@ devTest("a file that is not JavaScript does not carry a use directive", { }); }, }); +// Counts how often each file matching `filter` is loaded. Loads for the client graph are counted separately. +const countLoads = (filter: string) => ` + import { basename } from "node:path"; + export default [ + { + name: "count-loads", + setup(build) { + build.onLoad({ filter: /${filter}/ }, args => { + const loads = (globalThis.loads ??= {}); + const file = basename(args.path) + (args.side === "client" ? " (client)" : ""); + loads[file] = (loads[file] ?? 0) + 1; + }); + }, + }, + ]; +`; +// `#name` resolves to `name-rsc.ts` in a graph with the "react-server" condition, and to `name-ssr.ts` otherwise. +const conditionalImport = (name: string) => ({ + "package.json": JSON.stringify({ + imports: { [`#${name}`]: { "react-server": `./${name}-rsc.ts`, "default": `./${name}-ssr.ts` } }, + }), + [`${name}-rsc.ts`]: `export const which = "rsc";`, + [`${name}-ssr.ts`]: `export const which = "ssr";`, +}); +const oneServerGraph = { ...minimalFramework.serverComponents!, separateSSRGraph: false }; +const separateSSRGraph = { ...minimalFramework.serverComponents!, separateSSRGraph: true }; +// A failed import belongs to the importing file's graph. +// `#cond` shows which graphs bundle the page. +{ + const page = ` + import { which } from "#cond"; + export default function () { + return Response.json({ which, loads: globalThis.loads }); + } + `; + for (const { name, serverComponents, failedImport, which } of [ + { name: "no server components", serverComponents: undefined, failedImport: `import "./missing";`, which: "ssr" }, + { + name: "separate SSR graph", + serverComponents: separateSSRGraph, + failedImport: `import "./missing";`, + which: "rsc", + }, + { + name: "bunBakeGraph attribute without an SSR graph", + serverComponents: oneServerGraph, + failedImport: `import "../other.ts" with { bunBakeGraph: "ssr" };`, + which: "rsc", + }, + ]) { + devTest(`bundles a page once per save after a failed import (${name})`, { + framework: { fileSystemRouterTypes: minimalFramework.fileSystemRouterTypes, serverComponents }, + pluginFile: countLoads("(routes.index|cond-(rsc|ssr))[.]ts$"), + files: { + ...conditionalImport("cond"), + "other.ts": `export default 1;`, + "routes/index.ts": failedImport + page, + }, + async test(dev) { + expect((await dev.fetch("/")).status).toBe(500); + await dev.write("routes/index.ts", page); + expect(await dev.fetch("/").json()).toEqual({ which, loads: { "index.ts": 2, [`cond-${which}.ts`]: 1 } }); + await dev.write("routes/index.ts", page + "// saved again"); + expect(await dev.fetch("/").json()).toEqual({ which, loads: { "index.ts": 3, [`cond-${which}.ts`]: 1 } }); + // The page still depends on the file that its graph resolved. + await dev.write(`cond-${which}.ts`, `export const which = ;`); + expect((await dev.fetch("/")).status).toBe(500); + }, + }); + } +} +// A file must not move to a graph that doesn't bundle it. shared.ts is in the server +// and SSR graphs. ssr-only.ts is only in the SSR graph. +{ + const files = { + ...conditionalImport("only"), + "routes/index.ts": ` + import "../shared"; + import "../Comp"; + export default function () { + return Response.json(globalThis.loads); + } + `, + "Comp.ts": ` + "use client"; + import "./shared"; + import "./ssr-only"; + export const marker = 1; + `, + }; + const shared = (n: number) => `export const shared = ${n};`; + const ssrOnly = (n: number) => `import "#only"; export const ssrOnly = ${n};`; + const oncePerGraph = { "shared.ts": 2, "shared.ts (client)": 1, "ssr-only.ts": 1, "ssr-only.ts (client)": 1 }; + const difference = (before: Record, after: Record) => + Object.fromEntries( + Object.keys(after) + .filter(file => after[file] !== before[file]) + .map(file => [file, after[file] - (before[file] ?? 0)]), + ); + devTest("bundles a file once per graph per save after its import is deleted and restored", { + skip: [ + "win32", // unlinkSync is having weird behavior + ], + framework: { ...minimalFramework, serverComponents: separateSSRGraph }, + pluginFile: countLoads("(shared|ssr-only|only-rsc|only-ssr)[.]ts$"), + files: { + ...files, + "shared.ts": `import "./leaf"; ${shared(1)}`, + "ssr-only.ts": `import "./leaf"; ${ssrOnly(1)}`, + "leaf.ts": `export const leaf = 1;`, + }, + async test(dev) { + await dev.fetch("/"); + await dev.delete("leaf.ts", { errors: null }); + await dev.write("leaf.ts", `export const leaf = 2;`); + const restored = await dev.fetch("/").json(); + expect(restored).toEqual({ + "shared.ts": 6, + "shared.ts (client)": 3, + "ssr-only.ts": 3, + "ssr-only.ts (client)": 3, + "only-ssr.ts": 1, + "only-ssr.ts (client)": 1, + }); + await dev.write("shared.ts", `import "./leaf"; ${shared(2)}`); + await dev.write("ssr-only.ts", `import "./leaf"; ${ssrOnly(2)}`); + expect(difference(restored, await dev.fetch("/").json())).toEqual(oncePerGraph); + }, + }); + devTest("bundles a module with a previously failed import for the SSR graph when a second route imports it", { + framework: { ...minimalFramework, serverComponents: separateSSRGraph }, + files: { + "shared.ts": ` + import "./missing"; + export const shared = "shared"; + `, + "routes/index.ts": ` + import { shared } from "../shared"; + export default function () { + return new Response("index " + shared); + } + `, + "routes/other.ts": ` + import { shared } from "../shared" with { bunBakeGraph: "ssr" }; + export default function () { + return new Response("other " + shared); + } + `, + }, + async test(dev) { + expect((await dev.fetch("/")).status).toBe(500); + await dev.write("shared.ts", `export const shared = "shared";`); + await dev.fetch("/").equals("index shared"); + await dev.fetch("/other").equals("other shared"); + }, + }); +} +devTest("build error in a file imported by a 'use client' component", { + framework: minimalFramework, + files: { + "routes/index.ts": ` + import * as Comp from "../Comp"; + export default function (req, meta) { + return new Response(typeof Comp.Button); + } + `, + "routes/manifest.ts": ` + import { serverManifest } from "bun:bake/server"; + export default function (req, meta) { + return Response.json(Object.keys(serverManifest).sort()); + } + `, + "Comp.ts": ` + "use client"; + export * from "./inner"; + import { save } from "./actions"; + export function Button() { return save; } + `, + "inner.ts": ` + export const Inner = 1; + `, + "actions.ts": ` + export function save() { return 1 +; } + `, + }, + async test(dev) { + expect((await dev.fetch("/")).status).toBe(500); + // Comp.ts is registered even though it can't be evaluated yet. + expect(await dev.fetch("/manifest").json()).toEqual(["Comp.ts#Button", "Comp.ts#Inner"]); + await dev.write("actions.ts", `export function save() { return 1; }`); + await dev.fetch("/").equals("object"); + }, +}); devTest("deinit with a free-list slot in DirectoryWatchStore.dependencies", { files: { "index.html": emptyHtmlFile({ scripts: ["index.ts"] }), @@ -681,6 +934,45 @@ devTest("deinit with a free-list slot in DirectoryWatchStore.dependencies", { // AllocationScope's invalid-free panic. }, }); +// The dev server skips a router type whose root doesn't exist. +devTest("reports a build error when an earlier router type has a missing root", { + framework: { + ...minimalFramework, + fileSystemRouterTypes: ["missing", "routes"].map(root => ({ ...minimalFramework.fileSystemRouterTypes![0], root })), + }, + files: { + "routes/index.ts": "export default () => {", + }, + async test(dev) { + expect((await dev.fetch("/")).status).toBe(500); + await dev.write("routes/index.ts", `export default () => new Response("index");`); + await dev.fetch("/").equals("index"); + }, +}); +// A file in both the server and SSR graphs shares one server-side record. +for (const [first, second] of [ + ["/", "/other"], + ["/other", "/"], +]) { + devTest(`bundles a module for the graph of ${second} after ${first} bundled it for its graph`, { + framework: { ...minimalFramework, serverComponents: separateSSRGraph }, + files: { + "shared.ts": `export const shared = "shared";`, + "routes/index.ts": ` + import { shared } from "../shared"; + export default () => new Response("/ " + shared); + `, + "routes/other.ts": ` + import { shared } from "../shared" with { bunBakeGraph: "ssr" }; + export default () => new Response("/other " + shared); + `, + }, + async test(dev) { + await dev.fetch(first).equals(first + " shared"); + await dev.fetch(second).equals(second + " shared"); + }, + }); +} devTest("importing html file", { files: { "index.html": emptyHtmlFile({ @@ -1276,3 +1568,211 @@ devTest("a route whose import throws on load throws that error again on the next await dev.fetch("/sync-import").equals("sync-import: fixed"); }, }); +// "loop" is a symlink to itself, so kqueue can't open the directory to watch it. +devTest("recovers from importing a file in a directory that can't be opened", { + skip: ["win32"], + files: { + "index.html": emptyHtmlFile({ + styles: [], + scripts: ["index.ts"], + }), + "index.ts": ` + import { abc } from './loop/second'; + console.log('value: ' + abc); + `, + }, + async test(dev) { + fs.symlinkSync("loop", dev.join("loop")); + await using c = await dev.client("/", { + errors: [`index.ts:1:21: error: Could not resolve: "./loop/second"`], + }); + + await c.expectReload(async () => { + await dev.write("index.ts", `console.log('value: ' + 456);`); + }); + + await c.expectMessage("value: 456"); + }, +}); + +// Nested `:fullscreen` rules copy the body once per vendor prefix at each level, which the CSS +// printer rejects. This only fails because the dev server prints and minifies with different targets. +// If that changes, this test needs another stylesheet that fails to print. +devTest("editing an @imported stylesheet recovers from a failed CSS print", { + files: { + "index.html": emptyHtmlFile({ + styles: ["main.css"], + body: "hello", + }), + "main.css": `@import "./child.css";\nbody { margin: 0; }\n`, + "child.css": + Array.from({ length: 30 }, (_, i) => `.x${i}:fullscreen {`).join("\n") + + "\ncolor: red;\n" + + Buffer.alloc(30, "}").toString() + + "\n", + }, + async test(dev) { + await using c = await dev.client("/", { + errors: ["main.css: error: Failed to generate CSS for this file (PrintError)"], + }); + + await c.expectReload(async () => { + await dev.write("child.css", ".visible { color: blue; }\n", { dedent: false, errors: null }); + }); + await c.style(".visible").color.expect.toBe("#00f"); + }, +}); + +devTest("an @imported stylesheet recovers from a parse error", { + files: { + "index.html": emptyHtmlFile({ + styles: ["main.css"], + body: "hello", + }), + "main.css": `@import "./child.css";\nbody { margin: 0; }\n`, + "child.css": `.fine { color: red; }\n`, + }, + async test(dev) { + expect((await dev.fetch("/")).status).toBe(200); + await dev.write("child.css", `.fine {\n color`, { dedent: false }); + expect((await dev.fetch("/")).status).toBe(500); + await dev.write("child.css", `.fine { color: blue; }\n`, { dedent: false }); + await using c = await dev.client("/"); + await c.style(".fine").color.expect.toBe("#00f"); + // Every later save must rebuild main.css too, not only the first one. + for (const color of ["green", "tan", "red"]) { + await dev.write("child.css", `.fine { color: ${color}; }\n`, { dedent: false }); + await c.style(".fine").color.expect.toBe(color); + } + }, +}); + +// Changing index.ts makes the route trace its stylesheets again while main.css is in a failed state. +devTest("dev server survives a css parse error with an edge adjustment", { + files: { + "index.html": emptyHtmlFile({ + styles: ["main.css"], + scripts: ["index.ts"], + body: "hello", + }), + "index.ts": `import.meta.hot.accept();\n`, + "main.css": `@import "./child.css";\nbody { margin: 0; }\n`, + "child.css": `.fine { color: red; }\n`, + "b.css": `.b { color: green; }\n`, + }, + async test(dev) { + await using c = await dev.client("/"); + await c.style(".fine").color.expect.toBe("red"); + { + await using _batch = await dev.batchChanges({ + errors: ["main.css:5:1: error: Unexpected end of input"], + }); + await dev.write("main.css", `@import "./child.css";\nbody {\n color: red;\n background-color\n}\n`, { + dedent: false, + }); + await dev.write("index.ts", `import.meta.hot.accept();\nimport "./b.css";\n`, { dedent: false }); + } + await c.style(".fine").color.expect.toBe("red"); + await c.style(".b").color.expect.toBe("green"); + await dev.write("main.css", `@import "./child.css";\nbody { margin: 0; }\n`, { dedent: false }); + await c.style(".fine").color.expect.toBe("red"); + }, +}); + +devTest("dev server survives a css resolution error with an edge adjustment", { + files: { + "index.html": emptyHtmlFile({ + styles: ["main.css"], + scripts: ["index.ts"], + body: "hello", + }), + "index.ts": `import.meta.hot.accept();\n`, + "main.css": `@import "./child.css";\nbody { margin: 0; }\n`, + "child.css": `.fine { color: red; }\n`, + "b.css": `.b { color: green; }\n`, + }, + async test(dev) { + await using c = await dev.client("/"); + await c.style(".fine").color.expect.toBe("red"); + { + await using _batch = await dev.batchChanges({ + errors: ['main.css:1:1: error: Could not resolve: "./missing.css"'], + }); + await dev.write("main.css", `@import "./missing.css";\nbody { margin: 0; }\n`, { dedent: false }); + await dev.write("index.ts", `import.meta.hot.accept();\nimport "./b.css";\n`, { dedent: false }); + } + await c.style(".fine").color.expect.toBe("red"); + await c.style(".b").color.expect.toBe("green"); + await dev.write("main.css", `@import "./child.css";\nbody { margin: 0; }\n`, { dedent: false }); + await c.style(".fine").color.expect.toBe("red"); + }, +}); + +// The dev server builds its define table from the env map once. Assigning to a proxy variable frees the map's value. +const startupProxy = "http://proxy-at-startup.example:8080/" + Buffer.alloc(120, "a").toString(); +async function clientBundle(dev: Dev) { + const html = await dev.fetch("/").text(); + const scripts = [...html.matchAll(/src="([^"]+\.js)"/g)]; + expect(scripts).toHaveLength(1); + return dev.fetch(scripts[0][1]).text(); +} +devTest("inlined env var survives a runtime process.env write to a proxy variable", { + files: { + "bunfig.toml": ` + [serve.static] + env = "inline" + `, + "index.html": emptyHtmlFile({ scripts: ["index.ts"] }), + "index.ts": `console.log("v1", process.env.HTTPS_PROXY);`, + "bun.app.ts": ` + import html from "./index.html"; + export default { + static: { "/": html }, + fetch(req) { + if (new URL(req.url).pathname === "/set-proxy") { + process.env.HTTPS_PROXY = "http://changed-at-runtime.example:1/"; + return new Response("ok"); + } + return new Response("Not Found", { status: 404 }); + }, + }; + `, + }, + htmlFiles: [], + env: { HTTPS_PROXY: startupProxy }, + async test(dev) { + const before = await clientBundle(dev); + expect(before).toContain('"v1"'); + expect(before).toContain(startupProxy); + + await dev.fetch("/set-proxy").equals("ok"); + + await dev.write("index.ts", `console.log("v2", process.env.HTTPS_PROXY);`); + const after = await clientBundle(dev); + expect(after).toContain('"v2"'); + expect(after).toContain(startupProxy); + }, +}); + +// An unresolved CSS url() is passed to the directory watcher. The HTML scanner joins a rooted src to the project root. +devTest("recovers from an unresolved css url() and html script src longer than the path buffer", { + files: { + "index.html": emptyHtmlFile({ + styles: ["styles.css"], + scripts: ["/" + Buffer.alloc(MAX_PATH_BYTES + 1024, "a").toString() + ".js"], + }), + "styles.css": ` + body { + background-image: url(${Buffer.alloc(MAX_PATH_BYTES + 1024, "a").toString()}); + } + `, + }, + async test(dev) { + expect((await dev.fetch("/")).status).toBe(500); + // The stylesheet is bundled and watched only after the page's imports resolve. + await dev.write("index.html", emptyHtmlFile({ styles: ["styles.css"] }), { errors: null }); + expect((await dev.fetch("/")).status).toBe(500); + await dev.write("styles.css", `body { color: blue; }`); + expect((await dev.fetch("/")).status).toBe(200); + }, +}); diff --git a/test/bake/dev/css.test.ts b/test/bake/dev/css.test.ts index 50fbc625e0d1..52c8735b7e16 100644 --- a/test/bake/dev/css.test.ts +++ b/test/bake/dev/css.test.ts @@ -437,6 +437,65 @@ devTest("css hot update carries the edited stylesheet when another root fails in } }, }); +// The incremental graph's stale bitset grows in steps of 512 files. +const modulesPastFirstBitsetStep = Array.from({ length: 520 }, (_, i) => `m${i}`); +devTest("css file imported from css first, then from a second page's script", { + files: { + "first.html": emptyHtmlFile({ + styles: ["first.css"], + scripts: ["first.ts"], + }), + "second.html": emptyHtmlFile({ + scripts: ["second.ts"], + }), + "first.css": ` + .first { color: red; } + `, + "child.css": ` + .child { color: blue; } + `, + "first.ts": ` + console.log("first"); + import.meta.hot.accept(); + `, + "second.ts": ` + import "./child.css"; + console.log("second"); + `, + ...Object.fromEntries(modulesPastFirstBitsetStep.map(name => [`modules/${name}.ts`, `export default "${name}";`])), + }, + async test(dev) { + await using c1 = await dev.client("/first"); + await c1.expectMessage("first"); + + // A bundle without css pushes the client graph past 512 files, so the bitset + // grows at the end of the bundle and the new bits mean "cached". + await dev.write( + "first.ts", + [ + ...modulesPastFirstBitsetStep.map(name => `import "./modules/${name}";`), + `console.log("first with modules");`, + `import.meta.hot.accept();`, + ].join("\n"), + ); + await c1.expectMessage("first with modules"); + + // "child.css" enters the client graph as a css import, so it doesn't have its own stylesheet. + await dev.write( + "first.css", + ` + @import "./child.css"; + .first { color: red; } + `, + ); + await c1.style(".child").color.expect.toBe("#00f"); + + // The script import must bundle "child.css" as a stylesheet of this page. + await using c2 = await dev.client("/second"); + await c2.expectMessage("second"); + await c2.style(".child").color.expect.toBe("#00f"); + }, +}); devTest("multiple stylesheets importing same dependency", { files: { "first.html": emptyHtmlFile({ diff --git a/test/bake/dev/hot.test.ts b/test/bake/dev/hot.test.ts index 92e158155dea..f0f230bce840 100644 --- a/test/bake/dev/hot.test.ts +++ b/test/bake/dev/hot.test.ts @@ -313,6 +313,55 @@ devTest("import.meta.hot.accept multiple modules", { await c.expectMessageInAnyOrder("Counter updated: 3", "Name updated: Charlie"); }, }); +// The parser rewrites the `import.meta.hot.accept` dependency list's items in place. +devTest("import.meta.hot.accept with a dependency list from a define", { + files: { + "bunfig.toml": ` + [serve.static] + define = { DEPS = '["./counter.ts"]' } + `, + "index.html": emptyHtmlFile({ + scripts: ["index.ts"], + }), + "index.ts": ` + import { count } from "./counter.ts"; + console.log("Initial: " + count + " " + JSON.stringify(DEPS)); + + import.meta.hot.accept(DEPS, (newModules) => { + console.log("Counter updated: " + newModules[0].count); + }); + `, + "counter.ts": ` + export const count = 1; + `, + }, + async test(dev) { + await using c = await dev.client("/"); + await c.expectMessage(`Initial: 1 ["./counter.ts"]`); + + await dev.write( + "counter.ts", + ` + export const count = 2; + `, + ); + await c.expectMessage("Counter updated: 2"); + + // index.ts doesn't accept itself, so the page reloads and the dev server parses the list again. + await c.expectReload(async () => { + await dev.patch("index.ts", { find: "Initial", replace: "Again" }); + }); + await c.expectMessage(`Again: 2 ["./counter.ts"]`); + + await dev.write( + "counter.ts", + ` + export const count = 3; + `, + ); + await c.expectMessage("Counter updated: 3"); + }, +}); devTest("import.meta.hot.data persistence", { files: { "index.html": emptyHtmlFile({ diff --git a/test/bake/dev/html.test.ts b/test/bake/dev/html.test.ts index f76c1c330789..1166efda57a6 100644 --- a/test/bake/dev/html.test.ts +++ b/test/bake/dev/html.test.ts @@ -1,4 +1,5 @@ // HTML tests are tests relating to HTML files themselves. +import { expect } from "bun:test"; import { devTest, emptyHtmlFile } from "../bake-harness"; devTest("html file is watched", { @@ -128,6 +129,37 @@ devTest("image import in JS", { // await dev.fetch(img1).expect404(); }, }); +devTest("html references a json file that is cached as a module", { + files: { + "index.html": ` + + + + `, + "script.ts": ` + import manifest from "./manifest.json"; + console.log(manifest.name); + `, + "manifest.json": `{ "name": "app" }`, + }, + async test(dev) { + await using c = await dev.client("/"); + await c.expectMessage("app"); + + await c.expectReload(async () => { + await dev.patch("index.html", { + find: "", + replace: '', + }); + }); + await c.expectMessage("app"); + + // The dev server can only treat a file as one kind of module, so it serves the page without the link. + const html = await (await dev.fetch("/")).text(); + expect(html).toInclude(" { + await dev.write("index.ts", `console.log("repaired");`); + }); + await c.expectMessage("repaired"); + }, +}); devTest("external links", { files: { "index.html": ` @@ -408,6 +463,23 @@ devTest("error report endpoint blanks stray non-text bytes in reported frames", await dev.fetch("/").expect.toInclude("

Frame Bytes

"); }, }); +devTest("client script route returns 404 when the route bundle index is out of range", { + files: { + "index.html": emptyHtmlFile({ scripts: ["/script.ts"] }), + "script.ts": `console.log("client-script-marker");`, + }, + async test(dev) { + // `/_bun/client/{name}-{index}{generation}.js`, where each hex group encodes a u32's four bytes. + const page = await dev.fetch("/").text(); + const [script, generation] = page.match(/\/_bun\/client\/index-[0-9a-f]{8}([0-9a-f]{8})\.js/) ?? []; + await dev.fetch(script).expect.toInclude("client-script-marker"); + + await dev.fetch(`/_bun/client/index-ffffffff${generation}.js`).expect404(); + await dev.fetch(`/_bun/client/index-feffffff${generation}.js`).expect404(); + await dev.fetch(script).expect.toInclude("client-script-marker"); + }, +}); + devTest("editing a file imported from outside the project root hot-reloads", { // The Windows watcher does not watch files outside the project directory. skip: ["win32"], diff --git a/test/bake/dev/production.test.ts b/test/bake/dev/production.test.ts index 5cb9dfbee67c..6771c0f96261 100644 --- a/test/bake/dev/production.test.ts +++ b/test/bake/dev/production.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { existsSync } from "fs"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import path from "path"; import { minimalFramework, tempDirWithBakeDeps } from "../bake-harness"; @@ -456,6 +456,55 @@ export default function () { expect(exitCode).toBe(1); }); + test("builds a framework without server components and keeps its client entry point", async () => { + using dir = tempDir("bake-production-custom-framework", { + "client.ts": `console.log("client loaded");`, + "server.ts": ` + export function render(req, meta) { + return new Response(String(meta.pageModule.default())); + } + export async function prerender(meta) { + const scripts = meta.modules.map(m => '').join(""); + return { files: { "index.html": "" + meta.pageModule.default() + scripts + "" } }; + } + `, + "routes/index.ts": `export default () => "homepage";`, + "app.ts": ` + const fsr = { root: "routes", style: "nextjs-pages", serverEntryPoint: "./server.ts", clientEntryPoint: "./client.ts" }; + export default { app: { framework: { fileSystemRouterTypes: [fsr] } } }; + `, + }); + + const { exitCode, stderr } = await Bun.$`${bunExe()} build --app ./app.ts` + .cwd(String(dir)) + .env(bunEnv) + .throws(false); + const html = await Bun.file(path.join(String(dir), "dist", "index.html")) + .text() + .catch(() => stderr.toString()); + expect(html).toContain("homepage"); + const script = html.match(/\n`), + Buffer.alloc(70_000 * "

x".length, "

x"), + Buffer.from(`\n`), + ]), + "/script.js": "console.log('Hello World')", + }, + entryPoints: ["/index.html"], + onAfterBundle(api) { + const pieces = api.readFile("out/index.html").split("

x"); + // Check the count first. If elements are missing, the diff below is huge. + expect(pieces.length - 1).toBe(70_000); + expect({ head: pieces[0], between: pieces.slice(1, -1).join(""), tail: pieces.at(-1)!.trim() }).toEqual({ + head: expect.stringMatching(/`, + "main.ts": main(0, "first bundle"), + ...Object.fromEntries(Array.from({ length: count }, (_, i) => [`mods/${i}.ts`, `console.log(${i});`])), + "other/keep": "", + "server.ts": ` + import html from "./index.html"; + const server = Bun.serve({ port: 0, development: true, routes: { "/": html } }); + await Bun.write("url", server.url.href); + console.log("listening"); + `, + }); + const root = String(dir); + await using runner = spawn({ + cmd: [bunExe(), "server.ts"], + env: bunEnv, + cwd: root, + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", + }); + expect(await follow(runner.stdout)("listening")).toBe(true); + const url = readFileSync(join(root, "url"), "utf8"); + const bundle = async () => { + // A failed build's page has no script, and the build fails while main.ts imports the deleted file. + const src = /src="([^"]+)"/.exec(await (await fetch(url)).text())?.[1]; + return src ? await (await fetch(new URL(src, url))).text() : ""; + }; + expect(await bundle()).toContain("first bundle"); + + whileSuspended(runner.pid, () => { + // The pending read completes with only the first change. + writeFileSync(join(root, "other", "first"), ""); + unlinkSync(join(root, "mods", "0.ts")); + for (let i = 1; i < count; i++) writeFileSync(join(root, "mods", `${i}.ts`), `console.log(${i}, "again");`); + }); + + writeFileSync(join(root, "main.ts"), main(1, "second bundle")); + while (!(await bundle()).includes("second bundle")); +}); + +// Other platforms evict the deleted import and stop watching its path, so "second" is never printed. +it.todoIf(!isWindows)("reloads when a deleted import is recreated", async () => { + using dir = tempDir("hot-import-back", { + "main.js": `import "./imported.js";\n`, + "imported.js": `console.log("first");`, + }); + const root = String(dir); + await using runner = spawn({ + cmd: [bunExe(), "--hot", "--no-clear-screen", "main.js"], + env: bunEnv, + cwd: root, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + const stdout = follow(runner.stdout); + const stderr = follow(runner.stderr); + expect(await stdout("first")).toBe(true); + + unlinkSync(join(root, "imported.js")); + expect(await stderr("Cannot find module './imported.js'")).toBe(true); + + writeFileSync(join(root, "imported.js"), `console.log("second");`); + expect(await stdout("second")).toBe(true); +}); + +// The Windows watcher never reads the cached listing. +it.skipIf(isWindows)("survives a directory event for a folder whose cached listing failed to read", async () => { + using dir = tempDir("hot-dir-read-error", { + "trace.log": "", + "src/lib/a.ts": `export {};`, + "src/fence/f.ts": `export {};`, + "src/main.ts": ` + import "./lib/a.ts"; + import "./fence/f.ts"; + import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; + // The watch follows the renamed folder. Listing "lib" now fails with ENOTDIR. + renameSync("lib", "lib.away"); + writeFileSync("lib", ""); + // The watcher logs each batch before handling it, so the first batch is done once the second is logged. + const trace = process.env.BUN_WATCHER_TRACE; + for (let i = 0; i < 2; i++) { + const from = statSync(trace).size; + mkdirSync("fence/" + i); + while (!readFileSync(trace, "latin1").slice(from).includes("/fence/")) await Bun.sleep(1); + } + await import("./lib/b.ts").catch(() => console.log("import failed")); + writeFileSync("lib.away/c.ts", ""); + console.log("ready"); + setInterval(() => {}, 1e6); + `, + }); + const cwd = join(String(dir), "src"); + await using runner = spawn({ + cmd: [bunExe(), "--hot", "--no-clear-screen", "main.ts"], + env: { ...bunEnv, BUN_WATCHER_TRACE: join(String(dir), "trace.log") }, + cwd, + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", + }); + const stdout = follow(runner.stdout); + expect(await stdout("import failed\nready")).toBe(true); + + writeFileSync(join(cwd, "main.ts"), `console.log("reloaded");\nprocess.exit(0);\n`); + expect(await stdout("reloaded")).toBe(true); + expect(await runner.exited).toBe(0); +}); + +// Only inotify reports the changed entry's name, and PATH_MAX is 1024 on macOS. +it.skipIf(!isLinux).each([ + ["a new file whose path is longer than PATH_MAX", Buffer.alloc(247, "n") + ".ts"], + ["an import whose path is PATH_MAX long", Buffer.alloc(245, "n") + ".png"], +])("survives %s", async (_, name) => { + using dir = tempDir("hot-long-path", {}); + // Folder + separator + name is 4097 bytes for the first name and 4096 bytes for the second. + const folderLength = 3846; + let cwd = realpathSync(String(dir)); + while (folderLength - cwd.length > 256) cwd = join(cwd, Buffer.alloc(200, "d").toString()); + cwd = join(cwd, Buffer.alloc(folderLength - cwd.length - 1, "e").toString()); + mkdirSync(cwd, { recursive: true }); + // The absolute path is too long for a syscall, so use a relative path. + const create = `require("node:fs").writeFileSync("${name}", "");`; + const isImport = name.endsWith(".png"); + if (isImport) { + await using setup = spawn({ cmd: [bunExe(), "-e", create], cwd, env: bunEnv }); + expect(await setup.exited).toBe(0); + } + writeFileSync( + join(cwd, "main.ts"), + `${isImport ? `import "./${name}";` : create}\nconsole.log("ready");\nsetInterval(() => {}, 1e6);\n`, + ); + await using runner = spawn({ + cmd: [bunExe(), "--hot", "--no-clear-screen", "main.ts"], + env: bunEnv, + cwd, + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", + }); + const stdout = follow(runner.stdout); + expect(await stdout("ready")).toBe(true); + + writeFileSync(join(cwd, "main.ts"), `console.log("reloaded");\nprocess.exit(0);\n`); + expect(await stdout("reloaded")).toBe(true); + expect(await runner.exited).toBe(0); +}); + +// BUN_DESTRUCT_VM_ON_EXIT frees the VM at exit. The writes make sure the watcher thread is busy at that point. +it.skipIf(!isASAN)("stops the file watcher thread before the VM is freed at exit", async () => { + async function run() { + using dir = tempDir("hot-exit", { + "main.js": ` + import "./dep.js"; + globalThis.loads = (globalThis.loads ?? 0) + 1; + if (globalThis.loads === 3) process.exit(0); + `, + "dep.js": `export default 0;`, + }); + await using runner = spawn({ + cmd: [bunExe(), "--hot", "--no-clear-screen", "main.js"], + env: { ...bunEnv, BUN_DESTRUCT_VM_ON_EXIT: "1" }, + cwd: String(dir), + stdout: "ignore", + stderr: "inherit", + stdin: "ignore", + }); + let exited = false; + runner.exited.then(() => (exited = true)); + for (let i = 0; !exited; i++) { + for (let j = 0; j < 20; j++) writeFileSync(join(String(dir), "dep.js"), `export default ${i};`); + await new Promise(resolve => setImmediate(resolve)); + } + return await runner.exited; + } + expect(await Promise.all([run(), run(), run(), run()])).toEqual([0, 0, 0, 0]); +}); + +// --hot and --watch intern a module's transpiled text in the VM. +it("throws ERR_STRING_TOO_LONG for a module whose transpiled text exceeds the string limit", async () => { + using dir = tempDir("hot-string-limit", { + "big.cjs": `const s = ${JSON.stringify(Buffer.alloc(200 * 1024, "x").toString())};\nmodule.exports = { len: s.length };\n`, + "small.cjs": `module.exports = { ok: 2 };\n`, + "entry.cjs": ` + let big; + try { + big = "loaded:" + require("./big.cjs").len; + } catch (e) { + big = "caught:" + (e?.code ?? e?.name); + } + console.log(JSON.stringify({ big, small: require("./small.cjs").ok })); + process.exit(0); + `, + }); + await using proc = spawn({ + cmd: [bunExe(), "--hot", "--no-clear-screen", "entry.cjs"], + cwd: String(dir), + env: { ...bunEnv, BUN_FEATURE_FLAG_SYNTHETIC_MEMORY_LIMIT: String(64 * 1024) }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: JSON.stringify({ big: "caught:ERR_STRING_TOO_LONG", small: 2 }) + "\n", + stderr: "", + exitCode: 0, + }); +}); diff --git a/test/cli/inspect/inspect.test.ts b/test/cli/inspect/inspect.test.ts index 2469f5756975..0e3e7eb58fb1 100644 --- a/test/cli/inspect/inspect.test.ts +++ b/test/cli/inspect/inspect.test.ts @@ -603,3 +603,71 @@ test("error.stack doesnt lose frames", () => { // We allow it to differ by the existence of as a string. But that's it. expect(no.split("\n").slice(0, -2).join("\n").trim()).toBe(yes.split("\n").slice(0, -2).join("\n").trim()); }); + +test("LifecycleReporter.getModuleGraph reports an empty argv when process.argv isn't an array", async () => { + await using child = spawn({ + cmd: [bunExe(), "--inspect=ws://127.0.0.1:0/", "-e", "setInterval(() => {}, 1000);"], + env: bunEnv, + stdout: "ignore", + stderr: "pipe", + }); + + let stderr = ""; + let url: string | undefined; + const decoder = new TextDecoder(); + for await (const chunk of child.stderr as ReadableStream) { + stderr += decoder.decode(chunk); + url = stderr.match(/(ws:\/\/[^\s]+)/)?.[1]; + if (url) break; + } + if (!url) throw new Error(`Unable to find the inspector URL in:\n${stderr}`); + + type Reply = { result?: any; error?: { message: string } }; + const pending = new Map void>(); + // A crash closes the socket. Resolve instead of rejecting so the failure shows up in the snapshot below. + const closed = Promise.withResolvers(); + const opened = Promise.withResolvers(); + const webSocket = new WebSocket(url); + webSocket.addEventListener("open", () => opened.resolve({})); + webSocket.addEventListener("close", () => closed.resolve({ error: { message: "the inspector closed the socket" } })); + webSocket.addEventListener("error", () => closed.resolve({ error: { message: "the inspector socket failed" } })); + webSocket.addEventListener("message", ({ data }) => { + const reply: Reply & { id: number } = JSON.parse(data.toString()); + pending.get(reply.id)?.(reply); + pending.delete(reply.id); + }); + + let nextId = 1; + function send(method: string, params: object = {}): Promise { + const id = nextId++; + const { promise, resolve } = Promise.withResolvers(); + pending.set(id, resolve); + webSocket.send(JSON.stringify({ id, method, params })); + return Promise.race([promise, closed.promise]); + } + + expect(await Promise.race([opened.promise, closed.promise])).toEqual({}); + + const argv: Record = {}; + for (const expression of ["undefined", "0", `"abc"`, "{}", `["a", "b"]`]) { + const assigned = await send("Runtime.evaluate", { expression: `process.argv = ${expression}` }); + const graph = assigned.error ? assigned : await send("LifecycleReporter.getModuleGraph"); + argv[expression] = graph.error?.message ?? graph.result.argv; + } + + expect(argv).toMatchInlineSnapshot(` + { + "\\"abc\\"": [], + "0": [], + "[\\"a\\", \\"b\\"]": [ + "a", + "b", + ], + "undefined": [], + "{}": [], + } + `); + + expect(child.exitCode).toBe(null); + webSocket.close(); +}); diff --git a/test/cli/install/bun-create.test.ts b/test/cli/install/bun-create.test.ts index 20a5cb61a2e8..4a1c095847b4 100644 --- a/test/cli/install/bun-create.test.ts +++ b/test/cli/install/bun-create.test.ts @@ -2,7 +2,7 @@ import { spawn, spawnSync } from "bun"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { chmodSync, lstatSync, mkdirSync, readdirSync, statSync, symlinkSync } from "fs"; import { exists, stat } from "fs/promises"; -import { bunExe, bunEnv as env, githubTarball, isPosix, tempDir, tls, tmpdirSync } from "harness"; +import { bunExe, bunEnv as env, githubTarball, isASAN, isPosix, tempDir, tls, tmpdirSync } from "harness"; import { once } from "node:events"; import * as nodetls from "node:tls"; import { delimiter, join } from "path"; @@ -941,6 +941,23 @@ describe.concurrent("GitHub template", () => { expect(exitCode).toBe(0); }); + it.skipIf(!isASAN)("doesn't leak memory at exit", async () => { + using github = await serveGithub(createTest); + using dir = tempDir("create-github-leak", {}); + const { out, err, exitCode } = await create( + String(dir), + ["dylan-conway/create-test", "dest", "--no-install", "--no-git"], + { + ...github.env, + ASAN_OPTIONS: [env.ASAN_OPTIONS, "detect_leaks=1"].filter(Boolean).join(":"), + LSAN_OPTIONS: `print_suppressions=0:suppressions=${join(import.meta.dirname, "../../leaksan.supp")}`, + }, + ); + + expect(out).toContain("Success! dylan-conway/create-test loaded into dest"); + expect(exitCode, err).toBe(0); + }); + it.each(["https://github.com/dylan-conway/create-test", "github.com/dylan-conway/create-test"])( "should create and install github template from %s", async repo => { diff --git a/test/cli/install/bun-install-staging.test.ts b/test/cli/install/bun-install-staging.test.ts index 0e3f07f171e2..983d49e458cc 100644 --- a/test/cli/install/bun-install-staging.test.ts +++ b/test/cli/install/bun-install-staging.test.ts @@ -947,6 +947,37 @@ test.concurrent.skipIf(!isLinux)( }, ); +test.concurrent.skipIf(!isLinux).each(["hoisted", "isolated"])( + "%s linker: a project so deep that a package's absolute path exceeds PATH_MAX fails with ENAMETOOLONG", + async linker => { + const scripts = { postinstall: "echo ran > ran.txt" }; + const tgz = await new Bun.Archive( + { "package/package.json": JSON.stringify({ name: "pkg", version: "1.0.0", scripts }) }, + { compress: "gzip" }, + ).bytes(); + using registry = serveRegistry({ pkg: { tgz, scripts } }); + using dir = tempDir("deep-project", {}); + const rootBytes = 4096 + 1 - "/node_modules/pkg".length; + let root = String(dir); + // The last name has to stay under NAME_MAX (255). + while (rootBytes - root.length > 256) root = join(root, Buffer.alloc(200, "d").toString()); + root = join(root, Buffer.alloc(rootBytes - root.length - 1, "d").toString()); + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ name: "app", dependencies: { pkg: "1.0.0" }, trustedDependencies: ["pkg"] }), + ); + writeFileSync( + join(root, "bunfig.toml"), + Bun.TOML.stringify({ install: { registry: registry.url.href, cache: join(String(dir), ".bun-cache"), linker } }), + ); + + const { stderr, exitCode } = await install(root); + expect(stderr).toContain("ENAMETOOLONG"); + expect(exitCode).toBe(1); + }, +); + // The hoisted linker links the packages of the cache on the thread pool, several at a time. All // else about them happens on the main thread, in the order in which it links them itself where // few files can be open. diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 339911c9e570..a2f9ced54311 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -13,6 +13,7 @@ import { isLinux, isWindows, joinP, + MAX_PATH_BYTES, normalizeBunSnapshot, readdirSorted, runBunInstall, @@ -17665,3 +17666,57 @@ describe.concurrent.each(["hoisted", "isolated"])( }); }, ); + +// `overrides` and `resolutions` values are passed to the resolver unchanged. +it.concurrent.each(["resolutions", "overrides"])( + 'fails with ENAMETOOLONG for a file: path in root package.json "%s" that is longer than the path buffer', + async field => { + using dir = tempDir("override-file-dep-too-long", { + "package.json": JSON.stringify({ + name: "my-app", + version: "1.0.0", + dependencies: { dep: "1.0.0" }, + [field]: { dep: "file:./" + Buffer.alloc(MAX_PATH_BYTES + 1000, "a").toString() }, + }), + }); + + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + env, + }); + const [, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(err).toContain("error: ENAMETOOLONG"); + expect(err).toContain("failed to resolve"); + expect(exitCode).toBe(1); + }, +); + +it.concurrent("doesn't install a bun.lock folder package whose path is longer than the path buffer", async () => { + using dir = tempDir("lockfile-folder-too-long", { + "package.json": JSON.stringify({ name: "foo", version: "1.0.0", dependencies: { okpkg: "file:./okpkg" } }), + "okpkg/package.json": JSON.stringify({ name: "okpkg", version: "1.0.0" }), + "bun.lock": JSON.stringify({ + lockfileVersion: 1, + configVersion: 1, + workspaces: { "": { name: "foo", dependencies: { okpkg: "file:./okpkg" } } }, + packages: { okpkg: [`okpkg@file:${Buffer.alloc(MAX_PATH_BYTES + 1000, "a").toString()}`, {}] }, + }), + }); + + await using proc = spawn({ + cmd: [bunExe(), "install", "--linker", "hoisted"], + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + env, + }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(err).toContain("ENAMETOOLONG: folder path for package okpkg is too long"); + expect(out).not.toContain("1 package installed"); + expect(exitCode).toBe(1); +}); diff --git a/test/cli/install/bun-pm-diff.test.ts b/test/cli/install/bun-pm-diff.test.ts index 6275f7750df4..fcf62f324209 100644 --- a/test/cli/install/bun-pm-diff.test.ts +++ b/test/cli/install/bun-pm-diff.test.ts @@ -777,6 +777,12 @@ diffme@1.0.0 → diffme@2.0.0 expect(hits.some(h => h.endsWith(" /@priv/diffme"))).toBe(true); }); + test("treats a name longer than the path buffer as a registry package", async () => { + const { stderr, exitCode } = await diff([Buffer.alloc(5000, "x") + "@1.0.0", "2.0.0"]); + expect(stderr).toContain("404"); + expect(exitCode).toBe(1); + }); + test("errors: unknown package, no matching version; a third argument is a file filter", async () => { const unknown = await diff(["nope-nope@1.0.0", "2.0.0"]); expect(unknown.exitCode).toBe(1); diff --git a/test/cli/install/bun-pm-pkg.test.ts b/test/cli/install/bun-pm-pkg.test.ts index cd887330e530..19cd13a938f4 100644 --- a/test/cli/install/bun-pm-pkg.test.ts +++ b/test/cli/install/bun-pm-pkg.test.ts @@ -1,7 +1,7 @@ import { spawn } from "bun"; import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { mkdirSync, rmSync, writeFileSync } from "fs"; -import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { bunEnv, bunExe, isWindows, MAX_PATH_BYTES, tempDir } from "harness"; import { join } from "path"; async function runPmPkg(args: string[], cwd: string, expectSuccess = true) { @@ -1457,6 +1457,23 @@ describe.concurrent("bun pm pkg", () => { expect((await readPkg(multiIssueDir)).name).toBe("multiple-issues-package"); }); + it("should warn about a bin path longer than the path buffer", async () => { + const binPath = + "./" + + Array(Math.ceil(MAX_PATH_BYTES / 200) + 1) + .fill(Buffer.alloc(200, "a").toString()) + .join("/") + + ".js"; + using longBinDir = tempDir("pm-pkg-long-bin", { + "package.json": JSON.stringify({ name: "LONG-BIN-PACKAGE", version: "1.0.0", bin: { b: binPath } }, null, 2), + }); + + const { error, code } = await runPmPkg(["fix"], longBinDir); + expect(error).toContain(`No bin file found at ${binPath}`); + expect((await readPkg(longBinDir)).name).toBe("long-bin-package"); + expect(code).toBe(0); + }); + it("should not crash on empty bin object", async () => { using emptyBinDir = tempDir("pm-pkg-empty-bin", { "package.json": JSON.stringify({ name: "EMPTY-BIN-PACKAGE", version: "1.0.0", bin: {} }, null, 2), diff --git a/test/cli/install/lockfile-version-2.test.ts b/test/cli/install/lockfile-version-2.test.ts index c1104f4f1284..b01c784b2488 100644 --- a/test/cli/install/lockfile-version-2.test.ts +++ b/test/cli/install/lockfile-version-2.test.ts @@ -1,7 +1,7 @@ import { file, spawn } from "bun"; import { expect, it } from "bun:test"; import { exists } from "fs/promises"; -import { bunExe, bunEnv as env, tempDir } from "harness"; +import { bunExe, bunEnv as env, MAX_PATH_BYTES, tempDir } from "harness"; import { join } from "path"; // These tests cover the text lockfile bump to version 2 and the parse-time @@ -303,6 +303,34 @@ it("unsafe git .bun-tag is rejected only at version 2", async () => { } }); +// The tag is formatted into a cache folder name before `Repository::checkout` validates it. +it.concurrent.each([0, 1, 2])( + "rejects a git .bun-tag longer than the path buffer at version %d", + async lockfileVersion => { + const gitUrl = "git+ssh://git@127.0.0.1:1/example/repo.git#main"; + using dir = tempDir("lockfile-gittag-length", { + "package.json": JSON.stringify({ name: "root", dependencies: { dep: gitUrl } }), + "bun.lock": JSON.stringify({ + lockfileVersion, + configVersion: 1, + workspaces: { "": { name: "root", dependencies: { dep: gitUrl } } }, + packages: { dep: [`dep@${gitUrl}`, {}, Buffer.alloc(MAX_PATH_BYTES + 4, "a").toString()] }, + }), + }); + await using proc = spawn({ + cmd: [bunExe(), "install", "--frozen-lockfile"], + cwd: String(dir), + // Blocks the ssh transport so git can't clone. + env: { ...env, GIT_ALLOW_PROTOCOL: "file" }, + stdout: "ignore", + stderr: "pipe", + }); + const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(err).toContain("Invalid git dependency tag"); + expect(exitCode).toBe(1); + }, +); + // A `github` dependency resolves via the tarball-download path, not // `Repository::checkout`, so its `.bun-tag` is fed into the cache folder name // with no use-site re-validation. The parse-time safety check therefore stays diff --git a/test/cli/lint/hints.test.ts b/test/cli/lint/hints.test.ts index 6f1153fc5ab5..cba6dd2e9971 100644 --- a/test/cli/lint/hints.test.ts +++ b/test/cli/lint/hints.test.ts @@ -58,6 +58,14 @@ export function rename(kind) { "no-useless-assignment 4: Nothing reads it after this.", ]); }); + + test("an assignment in parentheses is not a pattern", async () => { + const f = `export function f(a: number, b: number) {\n (a = b) = a;\n}\n`; + const g = `export function g(a: number, b: number) {\n (a = b) += a;\n}\n`; + const { stdout, exitCode } = await lint({ "a.ts": f + g }, "-f", "unix"); + expect(stdout).toBe(""); + expect(exitCode).toBe(0); + }); }); /** Code and its expected reports. The code uses everything it declares, so nothing else gets reported. */ diff --git a/test/cli/run/as-node.test.ts b/test/cli/run/as-node.test.ts index 34ee77efd693..4d291b398033 100644 --- a/test/cli/run/as-node.test.ts +++ b/test/cli/run/as-node.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { join } from "path"; -import { bunEnv, bunExe, fakeNodeRun, tempDir } from "../../harness"; +import { bunEnv, bunExe, fakeNodeRun, isMacOS, isWindows, tempDir } from "../../harness"; describe("fake node cli", () => { test("the node cli actually works", () => { @@ -31,6 +31,26 @@ describe("fake node cli", () => { }); expect(fakeNodeRun(temp, "run").stdout).toBe("pass"); }); + // A Windows command line is too short for a path longer than the path buffer. + test.skipIf(isWindows)("reports module not found for a relative script path longer than PATH_MAX", () => { + const long = Array(21).fill(Buffer.alloc(200, "a").toString()).join("/") + ".js"; + using temp = tempDir("fake-node-long", {}); + const { exitCode, stdout, stderr } = Bun.spawnSync({ + cmd: [bunExe(), "--bun", "node", long], + cwd: String(temp), + env: { ...bunEnv, NODE_ENV: undefined }, + stdout: "pipe", + stderr: "pipe", + }); + expect(stdout.toString()).toBe(""); + const path = join(String(temp), long); + // On macOS the lookup fails in a syscall, so the error is ENAMETOOLONG. + const expected = isMacOS + ? `error: ENAMETOOLONG while resolving '${path}' from 'bun:main'` + : `error: Module not found '${path}'`; + expect(stderr.toString().split("\n")[0]).toBe(expected); + expect(exitCode).toBe(1); + }); describe("entrypoint file extension picking", () => { // Bun supports JSX and TS, and node doesnt, so our behavior here differs a bit // Hopefully these priorization rules will not break any node apps. diff --git a/test/cli/run/cpu-prof.test.ts b/test/cli/run/cpu-prof.test.ts index 4d5fe51aea7b..75df02a8a199 100644 --- a/test/cli/run/cpu-prof.test.ts +++ b/test/cli/run/cpu-prof.test.ts @@ -444,4 +444,30 @@ describe.concurrent("--cpu-prof", () => { const mdContent = readFileSync(join(String(dir), mdFiles[0]), "utf-8"); expect(mdContent).toContain("# CPU Profile"); }); + + // "%80" decodes to a byte that isn't valid UTF-8. Windows decodes it to U+FFFD. + test.skipIf(isWindows)("--cpu-prof-md prints a source's URL when the URL can't be converted to a path", async () => { + using dir = tempDir("cpu-prof-md-source-url", { + "test.js": ` + const loop = require("node:vm").runInThisContext( + "(function loop() { const end = Date.now() + 300; let x = 0; while (Date.now() < end) { for (let i = 0; i < 1e4; i++) x += Math.sqrt(i); } return x; })", + { filename: "file:///%80.js" }, + ); + loop(); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--cpu-prof-md", "test.js"], + cwd: String(dir), + env: bunEnv, + stdout: "inherit", + stderr: "inherit", + }); + + const exitCode = await proc.exited; + const mdFiles = readdirSync(String(dir)).filter(f => f.endsWith(".md") && f.startsWith("CPU.")); + expect({ exitCode, profiles: mdFiles.length }).toEqual({ exitCode: 0, profiles: 1 }); + expect(readFileSync(join(String(dir), mdFiles[0]), "utf-8")).toContain("| `loop` | `file:///%80.js"); + }); }); diff --git a/test/cli/run/env.test.ts b/test/cli/run/env.test.ts index d2d1468ae41f..4b0f401c9d90 100644 --- a/test/cli/run/env.test.ts +++ b/test/cli/run/env.test.ts @@ -1499,4 +1499,12 @@ describe("JSC option environment variables", () => { expect(stderr).not.toContain("thresholdForJITAfterWarmUp=77"); expect(exitCode).toBe(0); }); + test.concurrent.each(["BUN_JSC_useJIT", "BUN_JSC_useBBQJIT"])( + "rejects %s=0 with BUN_JSC_useWasmIPInt=0 as an invalid option", + async name => { + const { stderr, exitCode } = await dumpOptions({ [name]: "0", BUN_JSC_useWasmIPInt: "0" }); + expect(stderr).toContain("invalid JSC environment variable\n\n BUN_JSC_useWasmIPInt=0"); + expect(exitCode).toBe(1); + }, + ); }); diff --git a/test/cli/run/filter-workspace.test.ts b/test/cli/run/filter-workspace.test.ts index 423eeead4645..031bd4ee5686 100644 --- a/test/cli/run/filter-workspace.test.ts +++ b/test/cli/run/filter-workspace.test.ts @@ -1,8 +1,18 @@ import { spawnSync } from "bun"; import { install_test_helpers } from "bun:internal-for-testing"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isLinux, isWindows, runBunInstall, tempDir, tempDirWithFiles } from "harness"; -import { existsSync, symlinkSync } from "node:fs"; +import { + bunEnv, + bunExe, + isLinux, + isWindows, + makeTreeSync, + MAX_PATH_BYTES, + runBunInstall, + tempDir, + tempDirWithFiles, +} from "harness"; +import { existsSync, mkdirSync, symlinkSync } from "node:fs"; import { setTimeout as sleep } from "node:timers/promises"; import { constants } from "os"; import { join } from "path"; @@ -839,6 +849,12 @@ describe("bun", () => { }); describe("selectors", () => { + // A Windows command line is too short for a path longer than the path buffer. + test.skipIf(isWindows)("rejects a path longer than the path buffer", () => { + const long = "./" + Array(21).fill(Buffer.alloc(200, "a").toString()).join("/"); + runInCwdFailure(graph_root, long, "present", /error: --filter "\.\/a.*" path is too long/); + }); + test("'foo...' runs foo and the workspaces it depends on", () => { runInCwdSuccess({ cwd: graph_root, @@ -1599,3 +1615,60 @@ test.concurrent.each([ expect(stderr).toBe(""); expect(exitCode).toBe(0); }); + +// Windows paths can't be as long as MAX_PATH_BYTES. +describe.concurrent.skipIf(isWindows)("workspace paths longer than the path buffer", () => { + const workspace = { + "package.json": JSON.stringify({ name: "ws", workspaces: ["packages/*"] }), + "packages/ok/package.json": JSON.stringify({ name: "ok", scripts: { present: "echo ok-ran" } }), + }; + + // Returns a directory below `parent` whose absolute path is exactly `length` bytes. + function makeDirectoryOfLength(parent: string, length: number): string { + let dir = parent; + while (Buffer.byteLength(dir) < length) { + // Unless this is the last component, reserve room for "/" and one more byte. + const left = length - Buffer.byteLength(dir) - 1; + const name = Buffer.alloc(left <= 255 ? left : Math.min(255, left - 2), "d").toString(); + dir = join(dir, name); + mkdirSync(dir); + } + expect(Buffer.byteLength(dir)).toBe(length); + return dir; + } + + async function runFilter(cwd: string) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "run", "--filter", "*", "present"], + env: bunEnv, + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + test("reports a member whose package.json path is too long and still runs the other members", async () => { + using base = tempDir("filter-long-member", {}); + const root = makeDirectoryOfLength(String(base), MAX_PATH_BYTES - 196); + makeTreeSync(root, workspace); + // The member directory's path fits in the buffer. Its package.json path is 13 bytes longer and doesn't. + const name = Buffer.alloc(MAX_PATH_BYTES - 6 - Buffer.byteLength(`${root}/packages/`), "x").toString(); + mkdirSync(join(root, "packages", name)); + + const { stdout, stderr, exitCode } = await runFilter(root); + expect(stderr).toContain("ENAMETOOLONG"); + expect(stdout).toContain("ok present: ok-ran"); + expect(exitCode).toBe(0); + }); + + test("finds the workspace root above a cwd whose own package.json path is too long", async () => { + using root = tempDir("filter-long-cwd", workspace); + const cwd = makeDirectoryOfLength(String(root), MAX_PATH_BYTES - "/package.json".length); + + const { stdout, exitCode } = await runFilter(cwd); + expect(stdout).toContain("ok present: ok-ran"); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/cli/run/fixture-crash.js b/test/cli/run/fixture-crash.js index d44ba5bbc61d..d50b5f814077 100644 --- a/test/cli/run/fixture-crash.js +++ b/test/cli/run/fixture-crash.js @@ -12,6 +12,6 @@ if (approach in crash_handler) { crash_handler[approach](); } else { console.error( - "usage: bun fixture-crash.js ", + "usage: bun fixture-crash.js ", ); } diff --git a/test/cli/run/preload-test.test.js b/test/cli/run/preload-test.test.js index 7bf569149458..40a3a7e079bd 100644 --- a/test/cli/run/preload-test.test.js +++ b/test/cli/run/preload-test.test.js @@ -1,7 +1,7 @@ import { spawnSync } from "bun"; import { describe, expect, test } from "bun:test"; -import { mkdirSync, realpathSync } from "fs"; -import { bunEnv, bunExe } from "harness"; +import { mkdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "fs"; +import { bunEnv, bunExe, tempDir } from "harness"; import { tmpdir } from "os"; import { join } from "path"; const preloadModule = ` @@ -222,4 +222,85 @@ plugin({ expect(exitCode).toBe(1); } }); + + test("reports a preloaded node: module that doesn't exist", async () => { + using dir = tempDir("preload-missing-builtin", { "main.js": `console.log("main ran");` }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--preload", "node:does_not_exist", "main.js"], + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + env: bunEnv, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "", + stderr: expect.stringContaining("error: No such built-in module: node:does_not_exist\n"), + exitCode: 1, + }); + }); + + describe("with --hot", () => { + function spawnHot(dir) { + const proc = Bun.spawn({ + cmd: [bunExe(), "--hot", "--no-clear-screen", "--preload", "./first.js", "--preload", "./second.js", "main.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + // Resolves to all the text read so far once it includes `expected` or the stream ends. + const collect = stream => { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let text = ""; + return async expected => { + while (!text.includes(expected)) { + const { done, value } = await reader.read(); + if (done) break; + text += decoder.decode(value, { stream: true }); + } + return text; + }; + }; + return { proc, stdout: collect(proc.stdout), stderr: collect(proc.stderr) }; + } + + test("waits for the preloads to finish when a reload arrives while they load", async () => { + using dir = tempDir("preload-hot", { + "first.js": ` + if (!globalThis.savedOnce) { + globalThis.savedOnce = true; + const fs = require("node:fs"); + fs.writeFileSync(__filename, fs.readFileSync(__filename)); + } + console.log("first ran"); + `, + "second.js": `console.log("second ran");`, + "main.js": `console.log("main ran");`, + }); + const { proc, stdout } = spawnHot(dir); + await using _ = proc; + expect(await stdout("main ran\nmain ran\n")).toStartWith("first ran\nsecond ran\nmain ran\nmain ran\n"); + }); + + test("reports a preload that was deleted before a reload, and the next reload still runs", async () => { + using dir = tempDir("preload-hot", { + // After a preload fails, every reload loads the preloads again. + "first.js": `throw new Error("from first.js");`, + "second.js": `console.log("second ran");`, + "main.js": `console.log("main ran");`, + }); + const { proc, stdout, stderr } = spawnHot(dir); + await using _ = proc; + expect(await stderr("error: from first.js")).toContain("error: from first.js"); + rmSync(join(String(dir), "first.js")); + writeFileSync(join(String(dir), "main.js"), `console.log("main ran");`); + expect(await stderr('preload not found "./first.js"')).toContain('preload not found "./first.js"'); + writeFileSync(join(String(dir), "first.js.new"), `console.log("first ran");`); + renameSync(join(String(dir), "first.js.new"), join(String(dir), "first.js")); + writeFileSync(join(String(dir), "main.js"), `console.log("main ran");`); + expect(await stdout("main ran\n")).toContain("first ran\nsecond ran\nmain ran\n"); + }); + }); }); diff --git a/test/cli/run/run-crash-handler.test.ts b/test/cli/run/run-crash-handler.test.ts index 5657f420ca6b..74764bc4bb5c 100644 --- a/test/cli/run/run-crash-handler.test.ts +++ b/test/cli/run/run-crash-handler.test.ts @@ -1,6 +1,17 @@ import { crash_handler } from "bun:internal-for-testing"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isDebug, isLinux, isPosix, isWindows, mergeWindowEnvs, tempDir } from "harness"; +import { + bunEnv, + bunExe, + isASAN, + isDebug, + isGlibc, + isLinux, + isPosix, + isWindows, + mergeWindowEnvs, + tempDir, +} from "harness"; import { rmSync } from "node:fs"; import { constants as osConstants } from "node:os"; import path from "path"; @@ -130,6 +141,40 @@ describe.if(isPosix)("terminal signal reflects the crash cause", () => { }); }); +// mimalloc provides `operator new` only on Linux without ASAN. +describe.if(isLinux && !isASAN)("operator new that fails to allocate", () => { + test.concurrent("reports out of memory", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + path.join(import.meta.dir, "fixture-crash.js"), + "operatorNew", + "--debug-crash-handler-use-trace-string", + ], + env: noReportEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + expect(stderr).toContain("Bun has run out of memory"); + expect(proc.signalCode).toBe("SIGABRT"); + expect(exitCode).not.toBe(0); + }); + + // `std::stable_sort` retries with half the buffer when this form returns null. + test.concurrent("returns null from new (std::nothrow)", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", `console.log(require("bun:internal-for-testing").crash_handler.operatorNew(true))`], + env: noReportEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + + expect(stdout).toBe("true\n"); + expect(exitCode).toBe(0); + }); +}); + // The report header names the CPU features the crash handler detected. On // x86_64 that detection uses cpuid directly (CPUFeatures.cpp). Every supported // x64 CPU has SSE4.2 and POPCNT, since the baseline build targets Nehalem. @@ -582,6 +627,64 @@ describe.if(isPosix)("process.kill() aimed at the process itself is not reported }); }); +// A preloaded shim fails the syscall. The `ulimit -n` value that makes the loop fail first differs between builds. +const cc = Bun.which("cc") || Bun.which("gcc") || Bun.which("clang"); +describe.skipIf(!isGlibc || !cc)("out of file descriptors while creating the event loop", () => { + const shim = /* c */ ` +#define _GNU_SOURCE +#include +#include +#include +#include + +static int should_fail(const char *name) { + if (strcmp(getenv("FAIL_LOOP_SYSCALL"), name) != 0) return 0; + errno = strcmp(getenv("FAIL_LOOP_ERRNO"), "ENFILE") == 0 ? ENFILE : EMFILE; + return 1; +} + +int epoll_create1(int flags) { + if (should_fail("epoll_create1")) return -1; + return ((int (*)(int)) dlsym(RTLD_NEXT, "epoll_create1"))(flags); +} + +int eventfd(unsigned int initval, int flags) { + if (should_fail("eventfd")) return -1; + return ((int (*)(unsigned int, int)) dlsym(RTLD_NEXT, "eventfd"))(initval, flags); +} +`; + + test.concurrent.each([ + ["epoll_create1", "EMFILE", "bun ran out of file descriptors (ProcessFdQuotaExceeded)"], + ["eventfd", "EMFILE", "bun ran out of file descriptors (ProcessFdQuotaExceeded)"], + ["eventfd", "ENFILE", "Your computer ran out of file descriptors (SystemFdQuotaExceeded)"], + ])("%s failing with %s exits 1 with the file descriptor error", async (syscall, errno, message) => { + using dir = tempDir("loop-init-fd-limit", { + "shim.c": shim, + "app.js": "console.log('entry point ran');\n", + }); + const shimPath = path.join(String(dir), "shim.so"); + const compiled = Bun.spawnSync({ + cmd: [cc!, "-shared", "-fPIC", "-o", shimPath, "shim.c", "-ldl"], + cwd: String(dir), + stderr: "pipe", + }); + expect(compiled.stderr.toString()).toBe(""); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "app.js", "--debug-crash-handler-use-trace-string"], + cwd: String(dir), + env: { ...noReportEnv, LD_PRELOAD: shimPath, FAIL_LOOP_SYSCALL: syscall, FAIL_LOOP_ERRNO: errno }, + stdio: ["ignore", "pipe", "pipe"], + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout, stderr }).toEqual({ stdout: "", stderr: expect.stringContaining(message) }); + expect(proc.signalCode).toBeNull(); + expect(exitCode).toBe(1); + }); +}); + describe("automatic crash reporter", () => { for (const approach of ["panic", "segfault", "outOfMemory"]) { test(`${approach} should report`, async () => { diff --git a/test/cli/run/run-propagate-sigkill.test.ts b/test/cli/run/run-propagate-sigkill.test.ts index 3fac342cf2bd..4c6b6c861ab0 100644 --- a/test/cli/run/run-propagate-sigkill.test.ts +++ b/test/cli/run/run-propagate-sigkill.test.ts @@ -3,9 +3,10 @@ // disposition with `bun.sys.sigaction(sig, …)`. `SIGKILL`/`SIGSTOP` can't // have their disposition changed, so libc returns `EINVAL` there — that // must not reach `std.posix.sigaction`'s `else => unreachable`. -import { expect, test } from "bun:test"; -import { bunEnv, bunExe, isLinux, isPosix, tempDir } from "harness"; +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isGlibc, isLinux, isPosix, tempDir } from "harness"; import { constants } from "os"; +import { join } from "path"; test.skipIf(!isPosix)("bun run propagates SIGKILL from a child without hitting unreachable", async () => { using dir = tempDir("run-sigkill", { @@ -67,3 +68,57 @@ test.skipIf(!isPosix).each(signaled)("bun run reports and re-raises signal %d", expect(stdout).toBe(""); expect(exitCode).toBe(128 + signal); }); + +// The kernel discards a default-action signal that PID 1 of a pid namespace sends to itself. +// A preloaded no-op raise() simulates that. +const cc = Bun.which("cc") || Bun.which("clang") || Bun.which("gcc"); +describe.concurrent.skipIf(!cc || !isGlibc)("exits 128 + signo when the re-raise is discarded", () => { + function project() { + const dir = tempDir("run-discarded-raise", { + "package.json": JSON.stringify({ name: "t", scripts: { go: "kill -TERM $$" } }), + "raise.c": "int raise(int sig) { (void)sig; return 0; }\n", + }); + const preload = join(String(dir), "libraise.so"); + const { exitCode } = Bun.spawnSync({ cmd: [cc!, "-shared", "-fPIC", "-o", preload, "raise.c"], cwd: String(dir) }); + expect(exitCode).toBe(0); + return { dir, env: { ...bunEnv, LD_PRELOAD: preload } }; + } + + test("bun run `) + .join("")}`; + const fixture = { + "bunfig.toml": `[serve.static]\nplugins = ["./plugin.ts"]\n`, + "index.html": htmlPage("./app.ts"), + "app.ts": `console.log("app");`, + "bare.html": htmlPage(), + "other.html": htmlPage("./other.ts"), + "other.ts": `import "./parked.ts";`, + "broken.html": htmlPage("./broken.ts"), + "broken.ts": `const = ;`, + "bad.ts": `let = ;`, + "parked.ts": `export {};`, + "plugin.ts": /*ts*/ ` + import { join } from "node:path"; + export default { + name: "park-bundle", + setup(build) { + build.onResolve({ filter: /parked\\.ts$/ }, async () => { + globalThis.bundleParked.resolve(); + await globalThis.releaseBundle.promise; + return { path: join(import.meta.dir, "parked.ts") }; + }); + }, + }; + `, + "serve.ts": /*ts*/ ` + import { writeFileSync } from "node:fs"; + import index from "./index.html"; + import bare from "./bare.html"; + import other from "./other.html"; + import broken from "./broken.html"; + + globalThis.bundleParked = Promise.withResolvers(); + globalThis.releaseBundle = Promise.withResolvers(); + + using server = Bun.serve({ + port: 0, + development: true, + routes: { "/": index, "/bare": bare, "/other": other, "/broken": broken }, + fetch: () => new Response("barrier"), + }); + const get = path => fetch(new URL(path, server.url)); + const status = path => get(path).then(response => response.status); + + // "route" is the page the tab loaded. "start" starts a bundle. + const scenarios = { + "rebuild that fails the script": { + route: "/", + start: () => writeFileSync("app.ts", 'import "./missing.ts";\\nimport "./parked.ts";\\n'), + }, + "rebuild that fails the only module": { + route: "/bare", + start: () => writeFileSync("bare.html", ${JSON.stringify(htmlPage("./missing.ts", "./parked.ts"))}), + }, + "first bundle of another route": { + route: "/", + start: () => status("/other"), + }, + // "/broken" already failed. A request for it collects every failure reachable from it, but it must + // skip bad.ts's parse failure, which belongs to the in-flight bundle that fails "/". + "rebuild that fails to parse an import, and a request for a failed route": { + route: "/", + before: () => status("/broken"), + start: () => writeFileSync("app.ts", 'import "./bad.ts";\\nimport "./parked.ts";\\n'), + whileParked: () => status("/broken"), + }, + }; + const { route, before, start, whileParked } = scenarios[process.env.BUNDLE_IN_FLIGHT]; + + await before?.(); + // The tab loaded the page but hasn't requested the script yet. + const page = await (await get(route)).text(); + const scriptUrl = new URL(page.match(/src="(\\/_bun\\/client\\/[^"]+)"/)[1], server.url); + + const started = start(); + await globalThis.bundleParked.promise; + // Parse results arrive from another thread. The server has received them by the time it responds to this. + await get("/barrier"); + const otherPage = whileParked?.(); + + const script = fetch(scriptUrl).then(response => response.text()); + // The server has read both requests by the time it responds to this one. + await get("/barrier"); + globalThis.releaseBundle.resolve(); + + const text = await script; + console.log( + JSON.stringify({ + script: text.startsWith("try{location.reload()}") + ? "reloads the page" + : Array.from(text.matchAll(/^ "([^"]+)": \\[/gm), match => match[1]), + pageAfterwards: await status(route), + otherPage: (await started) ?? (await otherPage), + }), + ); + `, + }; + + test.concurrent.each([ + // The script reloads the page, which then shows the error page. + ["rebuild that fails the script", { script: "reloads the page", pageAfterwards: 500 }], + ["rebuild that fails the only module", { script: "reloads the page", pageAfterwards: 500 }], + // The bundle doesn't affect this route. + ["first bundle of another route", { script: ["index.html", "app.ts"], pageAfterwards: 200, otherPage: 200 }], + [ + "rebuild that fails to parse an import, and a request for a failed route", + { script: "reloads the page", pageAfterwards: 500, otherPage: 500 }, + ], + ])("%s", async (scenario, expected) => { + using dir = tempDir("bun-serve-html-script-during-bundle", fixture); + const { stdout, stderr, exitCode } = await runServeFixture(dir, { BUNDLE_IN_FLIGHT: scenario }); + expect({ stdout, exitCode }, stderr).toEqual({ stdout: JSON.stringify(expected), exitCode: 0 }); + }); +}); + +// The rejection ends the bundle, and the next bundle reuses the memory that the first one freed. +test.concurrent("starts the queued bundle after an onLoad plugin's rejection ends a dev server bundle", async () => { + const rounds = [0, 1, 2, 3, 4, 5]; + const page = (script: string) => + `t`; + using dir = tempDir("bun-serve-html-onload-rejects-last", { + "bunfig.toml": `[serve.static]\nplugins = ["./plugin.ts"]\n`, + ...Object.fromEntries( + rounds.flatMap(i => [ + [`rejected${i}.html`, page(`rejected${i}.ts`)], + [`rejected${i}.ts`, `console.log(${i});`], + [`queued${i}.html`, page(`queued${i}.ts`)], + [`queued${i}.ts`, `console.log(${i});`], + ]), + ), + "plugin.ts": /*ts*/ ` + export default { + name: "reject-load", + setup(build) { + build.onLoad({ filter: /rejected\\d\\.ts$/ }, async () => { + globalThis.loadParked.resolve(); + await globalThis.releaseLoad.promise; + throw new Error("onLoad rejects"); + }); + }, + }; + `, + "serve.ts": /*ts*/ ` + const routes = {}; + for (const i of ${JSON.stringify(rounds)}) { + routes["/rejected" + i] = (await import("./rejected" + i + ".html")).default; + routes["/queued" + i] = (await import("./queued" + i + ".html")).default; + } + using server = Bun.serve({ port: 0, development: true, routes, fetch: () => new Response("barrier") }); + const status = path => fetch(new URL(path, server.url)).then(response => response.status); + + const statuses = []; + for (const i of ${JSON.stringify(rounds)}) { + globalThis.loadParked = Promise.withResolvers(); + globalThis.releaseLoad = Promise.withResolvers(); + const rejected = status("/rejected" + i); + await globalThis.loadParked.promise; + const queued = status("/queued" + i); + // The server has read the request for the queued route by the time it responds to this one. + await status("/barrier"); + globalThis.releaseLoad.resolve(); + statuses.push(await rejected, await queued); + } + console.log(statuses.join(" ")); + `, + }); + const { stdout, stderr, exitCode } = await runServeFixture(dir); + expect({ stdout, exitCode }, stderr).toEqual({ stdout: rounds.map(() => "500 200").join(" "), exitCode: 0 }); +}); + +test.concurrent("a route keeps failing when its broken module was imported through an onResolve plugin", async () => { + const page = (script: string) => + `t`; + using dir = tempDir("bun-serve-html-onresolve-failed-import", { + "bunfig.toml": `[serve.static]\nplugins = ["./plugin.ts"]\n`, + "broken.html": page("broken.ts"), + "broken.ts": `import "./missing.ts";`, + "fine.html": page("fine.ts"), + "fine.ts": `console.log("fine");`, + "plugin.ts": /*ts*/ ` + export default { + name: "resolve-nothing", + setup(build) { + build.onResolve({ filter: /\\.ts$/ }, () => undefined); + }, + }; + `, + "serve.ts": /*ts*/ ` + import broken from "./broken.html"; + import fine from "./fine.html"; + + using server = Bun.serve({ port: 0, development: true, routes: { "/broken": broken, "/fine": fine } }); + const status = path => fetch(new URL(path, server.url)).then(response => response.status); + console.log(await status("/broken"), await status("/fine"), await status("/broken")); + `, + }); + const { stdout, stderr, exitCode } = await runServeFixture(dir); + expect({ stdout, exitCode }, stderr).toEqual({ stdout: "500 200 500", exitCode: 0 }); +}); + // process.chdir() leaves the cached top-level directory with a trailing slash, // which the dev server then used as its root. Reporting a bundle failure // relativizes the failing file against that root and hit a debug assertion @@ -1206,6 +1414,111 @@ test.concurrent("dev server started after process.chdir() reports bundle failure expect(exitCode).toBe(0); }); +// A file is marked stale as soon as one of its imports fails to resolve, before the rebuild ends. +// The plugin pauses the rebuild in onResolve. Imports resolve in order, so "./missing.css" has already failed. +test.concurrent("serves a page request during a rebuild that fails a linked stylesheet", async () => { + const page = (title: string) => + `${title}`; + using dir = tempDir("bun-serve-html-page-during-css-rebuild", { + "bunfig.toml": `[serve.static]\nplugins = ["./plugin.ts"]\n`, + "index.html": page("one"), + "index.css": `@import "./child.css";\n.a { color: red; }\n`, + "child.css": `.c { color: blue; }\n`, + "parked.css": `.p { color: green; }\n`, + "app.ts": `console.log("app");`, + "plugin.ts": /*ts*/ ` + import { join } from "node:path"; + export default { + name: "park-bundle", + setup(build) { + build.onResolve({ filter: /parked\\.css$/ }, async () => { + globalThis.bundleParked.resolve(); + await globalThis.releaseBundle.promise; + return { path: join(import.meta.dir, "parked.css") }; + }); + }, + }; + `, + "serve.ts": /*ts*/ ` + import { writeFileSync } from "node:fs"; + import index from "./index.html"; + + globalThis.bundleParked = Promise.withResolvers(); + globalThis.releaseBundle = Promise.withResolvers(); + + using server = Bun.serve({ port: 0, development: true, routes: { "/": index } }); + async function fetchPage() { + const response = await fetch(server.url); + const text = await response.text(); + const links = text.match(/]*>/g) ?? []; + const stylesheets = []; + for (const link of links) { + const href = link.match(/href="([^"]*)"/)[1]; + const css = await fetch(new URL(href, server.url)); + stylesheets.push({ status: css.status, rules: (await css.text()).match(/\\.[a-z] \\{/g) }); + } + return { + status: response.status, + title: text.match(/(.*?)<\\/title>/)?.[1] ?? null, + stylesheets, + }; + } + + const first = await fetchPage(); + + // The HMR socket reports when a rebuild ends. 'u' is a hot update and 'e' is a list of errors. + const url = new URL("/_bun/hmr", server.url); + url.protocol = "ws:"; + const ws = new WebSocket(url); + ws.binaryType = "arraybuffer"; + let awaited = null; + ws.onmessage = ({ data }) => { + if (awaited?.kind === String.fromCharCode(new Uint8Array(data)[0])) awaited.resolve(); + }; + function nextMessage(kind) { + const { promise, resolve } = Promise.withResolvers(); + awaited = { kind, resolve }; + return promise; + } + + // The server replies to "set url" ('n' + route) with 'n' after it has read the subscription ('s' + topics). + const subscribed = nextMessage("n"); + ws.onopen = () => { + ws.send("she"); + ws.send("n/"); + }; + await subscribed; + + // This rebuild drops the rendered page, so the next request renders it again. + const htmlRebuilt = nextMessage("u"); + writeFileSync("index.html", ${JSON.stringify(page("two"))}); + await htmlRebuilt; + + writeFileSync("index.css", '@import "./child.css";\\n@import "./missing.css";\\n@import "./parked.css";\\n.a { color: red; }\\n'); + await globalThis.bundleParked.promise; + const duringRebuild = await fetchPage(); + + const cssRebuildFailed = nextMessage("e"); + globalThis.releaseBundle.resolve(); + await cssRebuildFailed; + const afterRebuild = await fetchPage(); + + ws.close(); + console.log(JSON.stringify({ first, duringRebuild, afterRebuild })); + `, + }); + const { stdout, stderr, exitCode } = await runServeFixture(dir); + expect({ stdout, exitCode }, stderr).toEqual({ + stdout: JSON.stringify({ + first: { status: 200, title: "one", stylesheets: [{ status: 200, rules: [".c {", ".a {"] }] }, + duringRebuild: { status: 200, title: "two", stylesheets: [{ status: 200, rules: [".c {", ".a {"] }] }, + afterRebuild: { status: 500, title: "Bun - Build Failed", stylesheets: [] }, + }), + exitCode: 0, + }); + expect(stderr).toContain(`Could not resolve: "./missing.css"`); +}); + test("wildcard static routes", async () => { await using dir = tempDir("bun-serve-html-error-handling", { "index.html": /*html*/ ` diff --git a/test/js/bun/http/bun-serve-routes.test.ts b/test/js/bun/http/bun-serve-routes.test.ts index f9f1c17e3b64..b704de9915a1 100644 --- a/test/js/bun/http/bun-serve-routes.test.ts +++ b/test/js/bun/http/bun-serve-routes.test.ts @@ -1109,3 +1109,18 @@ describe.concurrent("false route with no fetch handler", () => { await proc.exited; }); }); + +it("request.clone() copies a param whose name is an array index", async () => { + using server = Bun.serve({ + port: 0, + routes: { + "/users/:id": req => { + (req.params as Record<string, string>)[0] = "zero"; + const params = req.clone().params as Record<string, string>; + return Response.json({ keys: Object.keys(params), byIndex: params[0] }); + }, + }, + }); + const res = await fetch(`${server.url}users/42`); + expect(await res.json()).toEqual({ keys: ["0", "id"], byIndex: "zero" }); +}); diff --git a/test/js/bun/http/bun-serve-ssl.test.ts b/test/js/bun/http/bun-serve-ssl.test.ts index a549c0be5b15..9402ed2f0f97 100644 --- a/test/js/bun/http/bun-serve-ssl.test.ts +++ b/test/js/bun/http/bun-serve-ssl.test.ts @@ -156,6 +156,50 @@ describe("Bun.serve SSL validations", () => { } }); +test("server.reload() updates the routes for connections whose SNI matches a serverName", async () => { + const hosts = ["localhost", "sni.example.com", "unmatched.example.com"]; + const bodies = async (path: string) => + Object.fromEntries( + await Promise.all( + hosts.map(async serverName => { + const response = await fetch(`https://127.0.0.1:${server.port}${path}`, { + tls: { rejectUnauthorized: false, serverName }, + keepalive: false, + }); + return [serverName, await response.text()]; + }), + ), + ); + const all = (body: string) => Object.fromEntries(hosts.map(host => [host, body])); + + using server = Bun.serve({ + port: 0, + tls: [ + { key: privateKey, cert: publicKey, serverName: "localhost" }, + { key: privateKey, cert: publicKey, serverName: "sni.example.com" }, + ], + routes: { + "/static-old": new Response("static-old"), + "/fn-old": () => new Response("fn-old"), + }, + fetch: () => new Response("fetch-old"), + }); + expect(await bodies("/static-old")).toEqual(all("static-old")); + expect(await bodies("/fn-old")).toEqual(all("fn-old")); + + server.reload({ + routes: { + "/static-new": new Response("static-new"), + "/fn-new": () => new Response("fn-new"), + }, + fetch: () => new Response("fetch-new"), + }); + expect(await bodies("/static-new")).toEqual(all("static-new")); + expect(await bodies("/fn-new")).toEqual(all("fn-new")); + expect(await bodies("/static-old")).toEqual(all("fetch-new")); + expect(await bodies("/fn-old")).toEqual(all("fetch-new")); +}); + describe("Bun.serve per-serverName client certificate policy", () => { const tlsFixtures = join(import.meta.dir, "..", "..", "node", "tls", "fixtures"); const serverKey = readFileSync(join(tlsFixtures, "agent10-key.pem"), "utf8"); diff --git a/test/js/bun/http/bun-serve-static.test.ts b/test/js/bun/http/bun-serve-static.test.ts index da17c7aa4156..a44680a4d969 100644 --- a/test/js/bun/http/bun-serve-static.test.ts +++ b/test/js/bun/http/bun-serve-static.test.ts @@ -352,3 +352,28 @@ describe("static route preconditions (RFC 9110 §13.2.2)", () => { }); }); }); + +test("HEAD for a static route with an S3 file body reports the same Content-Length as GET", async () => { + const s3 = new Bun.S3Client({ + accessKeyId: "test", + secretAccessKey: "test", + bucket: "my-bucket", + // Neither request contacts S3. + endpoint: "http://127.0.0.1:1", + }); + using server = Bun.serve({ + port: 0, + routes: { "/object": new Response([s3.file("object.txt")]) }, + fetch: () => new Response("nf", { status: 404 }), + }); + + const url = new URL("/object", server.url); + const [head, get] = await Promise.all([fetch(url, { method: "HEAD" }), fetch(url)]); + expect({ + head: { status: head.status, contentLength: head.headers.get("content-length") }, + get: { status: get.status, contentLength: get.headers.get("content-length"), body: await get.text() }, + }).toEqual({ + head: { status: 200, contentLength: "0" }, + get: { status: 200, contentLength: "0", body: "" }, + }); +}); diff --git a/test/js/bun/http/bun-server.test.ts b/test/js/bun/http/bun-server.test.ts index 1cea9c96163d..58f153dc2462 100644 --- a/test/js/bun/http/bun-server.test.ts +++ b/test/js/bun/http/bun-server.test.ts @@ -408,6 +408,27 @@ describe.concurrent("Server", () => { } }); + test("server.fetch(url, { headers }) copies a Headers object", async () => { + using server = Bun.serve({ + port: 0, + fetch(req) { + return new Response(req.headers.get("x-i") ?? "none"); + }, + }); + const url = `http://${server.hostname}:${server.port}/`; + const kept: Headers[] = []; + for (let i = 0; i < 200; i++) { + const headers = new Headers({ "x-i": String(i) }); + const response = await server.fetch(url, { headers }); + expect(await response.text()).toBe(String(i)); + if (i % 2 === 0) kept.push(headers); + if (i % 50 === 0) Bun.gc(true); + } + Bun.gc(true); + // The Requests that server.fetch() created have been collected. + expect(kept.map(h => h.get("x-i"))).toEqual(kept.map((_, i) => String(i * 2))); + }); + test("server should return a body for a OPTIONS Request", async () => { using server = Bun.serve({ port: 0, @@ -3020,17 +3041,17 @@ test.concurrent("should be able to redirect when using empty streams #15320", as }).toEqual({ status: 200, redirected: true, url: `http://localhost:${server.port}/`, text: "Hello, World" }); }); -test("HEAD request for a Response with an S3 file body reports the object size and the server keeps serving", async () => { - // Answering a HEAD request whose Response body is an S3-backed Blob resolves - // the object size with an async S3 stat before writing headers. Run the - // server in a subprocess so a crash on that completion path shows up as a - // non-zero exit code instead of taking down the test runner. +test("HEAD request for new Response(s3file) follows the redirect to the object and the server keeps serving", async () => { + // `new Response(s3file)` is a 302 to a presigned URL. fetch() follows it, so + // the S3 origin answers the HEAD with the object size. Run the server in a + // subprocess so a crash shows up as a non-zero exit code instead of taking + // down the test runner. await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", ` - // Fake S3 origin: answers the stat (HEAD) with a fixed Content-Length. + // Fake S3 origin: answers HEAD with a fixed Content-Length. const s3Origin = Bun.serve({ port: 0, fetch(req) { @@ -3099,6 +3120,54 @@ test("HEAD request for a Response with an S3 file body reports the object size a expect(exitCode).toBe(0); }); +test("HEAD for a Response with an S3-backed Blob body doesn't wait for S3", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + let s3Requests = 0; + const { promise: s3Asked, resolve: onS3Asked } = Promise.withResolvers(); + const { promise: gate, resolve: openGate } = Promise.withResolvers(); + using s3Origin = Bun.serve({ + port: 0, + async fetch() { + s3Requests++; + onS3Asked(); + await gate; + return new Response("0123456789"); + }, + }); + const s3 = new Bun.S3Client({ + accessKeyId: "test", + secretAccessKey: "test", + bucket: "my-bucket", + endpoint: s3Origin.url.href, + }); + using app = Bun.serve({ + port: 0, + fetch: () => fetch(URL.createObjectURL(s3.file("object.txt"))), + }); + const head = fetch(app.url, { method: "HEAD" }); + // If the server waits for S3 before responding, the Response is unreferenced and the GC can collect it. + await Promise.race([s3Asked, head]); + Bun.gc(true); + openGate(); + const res = await head; + console.log(res.status, res.headers.get("content-length"), s3Requests); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe("200 0 0\n"); + expect(exitCode).toBe(0); +}); + // Handler callbacks (fetch/error/websocket.*) are stored on the JS wrapper and // traced by the GC rather than independently rooted. These tests lock in that // reload/stop transitions never leave a window where a handler is collected diff --git a/test/js/bun/http/listener-getsockname.test.ts b/test/js/bun/http/listener-getsockname.test.ts index 1e79695aa46e..9a3db2f1881e 100644 --- a/test/js/bun/http/listener-getsockname.test.ts +++ b/test/js/bun/http/listener-getsockname.test.ts @@ -47,3 +47,23 @@ test("Listener.getsockname throws with no argument", () => { // when called without an object argument. Now it should throw a TypeError. expect(() => (listener as any).getsockname()).toThrow(); }); + +test("Listener.getsockname handles an argument that rejects property writes", () => { + using listener = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + data() {}, + }, + }); + + const frozen = Object.freeze({}); + expect((listener as any).getsockname(frozen)).toBeUndefined(); + expect(Reflect.ownKeys(frozen)).toEqual([]); + + // (module (type $s (struct (field (mut i32)))) (func (export "mk") (result (ref null $s)) struct.new_default $s)) + // prettier-ignore + const bytes = new Uint8Array([0,0x61,0x73,0x6d,1,0,0,0, 1,10,2, 0x5f,1,0x7f,1, 0x60,0,1,0x63,0, 3,2,1,1, 7,6,1,2,0x6d,0x6b,0,0, 10,7,1,5,0,0xfb,1,0,0x0b]); + const struct = (new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports.mk as Function)(); + expect(() => (listener as any).getsockname(struct)).toThrow("Cannot set property for WebAssembly GC object"); +}); diff --git a/test/js/bun/http/serve-direct-readable-stream.test.ts b/test/js/bun/http/serve-direct-readable-stream.test.ts index 74f33443f6c5..b200dbcd6613 100644 --- a/test/js/bun/http/serve-direct-readable-stream.test.ts +++ b/test/js/bun/http/serve-direct-readable-stream.test.ts @@ -2407,3 +2407,44 @@ describe("direct stream edge cases over Bun.serve", () => { expect(await (await fetch(server.url, { method: "POST", body: "again" })).text()).toBe("again"); }); }); + +test("survives server.stop(true) called while a body chunk is converted to a string", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + let server; + const chunk = Object.assign(new String("x"), { + [Symbol.toPrimitive]() { server.stop(true); return "payload"; }, + }); + server = Bun.serve({ + port: 0, + fetch: () => new Response(new ReadableStream({ + async pull(controller) { + controller.enqueue("seed"); + await new Promise(resolve => setImmediate(resolve)); + controller.enqueue(chunk); + controller.close(); + }, + })), + }); + try { + const res = await fetch(server.url); + console.log("status", res.status); + console.log("body", JSON.stringify(await res.text())); + } catch (e) { + console.log("read failed", e.code); + } + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // The connection can reset before or after the client reads the headers. + expect(stdout).toMatch(/^(status 200\n)?read failed ECONNRESET\n$/); + if (exitCode !== 0) expect(stderr).toBe(""); + expect(exitCode).toBe(0); +}); diff --git a/test/js/bun/http/serve-resume-fault-fixture.ts b/test/js/bun/http/serve-resume-fault-fixture.ts new file mode 100644 index 000000000000..73d862a3047d --- /dev/null +++ b/test/js/bun/http/serve-resume-fault-fixture.ts @@ -0,0 +1,75 @@ +// Sends one Bun.serve request whose socket is paused by request-body backpressure. The peer hangs up, so +// the loop removes the fd from the epoll set, and the injected poll_start failure makes the resume fail. +import { socketFaultInjection as fault } from "bun:internal-for-testing"; +import { connect } from "node:net"; + +const [mode, unix] = process.argv.slice(2); +// Above REQUEST_BODY_HIGH_WATER_MARK, so the server pauses the socket. +const BODY_CHUNK = 1024 * 1024 + 4096; + +const faultArmed = Promise.withResolvers<void>(); +const log = (line: string) => console.log(line); + +// fetch() would reuse a connection, and a reused connection doesn't register a poll. +function ping(label: string) { + const { promise, resolve } = Promise.withResolvers<void>(); + let response = ""; + const client = connect({ path: unix }); + client.on("connect", () => client.write("GET /ping HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n")); + client.on("data", chunk => (response += chunk)); + client.on("error", (error: NodeJS.ErrnoException) => log(`${label}: error ${error.code}`)); + client.on("close", () => { + if (response) log(`${label}: ${response.slice(0, response.indexOf("\r\n"))}, ${response.split("\r\n\r\n")[1]}`); + resolve(); + }); + return promise; +} + +const server = Bun.serve({ + unix, + idleTimeout: 0, + maxRequestBodySize: 64 * 1024 * 1024, + async fetch(req) { + if (new URL(req.url).pathname === "/ping") return new Response("pong"); + + req.signal.addEventListener("abort", () => log("abort")); + await faultArmed.promise; + + try { + if (mode === "body-buffered") { + log(`body: resolved ${(await req.arrayBuffer()).byteLength}`); + } else if (mode === "body-stream") { + // With the stream materialized, text() drains it through the ByteStream. + void req.body; + log(`body: resolved ${(await req.text()).length}`); + } + } catch (error: any) { + log(`body: rejected ${error?.name}`); + } + + if (mode === "response-ends") { + return new Response(null, { status: 304 }); + } + return new Response("late"); + }, +}); + +const socket = connect({ path: unix }); +socket.on("error", () => {}); +await new Promise<void>(resolve => socket.once("connect", resolve)); +socket.write(`POST /upload HTTP/1.1\r\nHost: x\r\nContent-Length: ${8 * BODY_CHUNK}\r\n\r\n`); +await new Promise<void>(resolve => socket.write(Buffer.alloc(BODY_CHUNK, 0x61), () => resolve())); +socket.destroy(); +await new Promise<void>(resolve => socket.once("close", resolve)); + +// Makes the loop poll and notice that the socket above hung up. +await ping("before"); + +// If the resume doesn't consume this rule, the ping after the handler fails. +fault.set({ syscall: "poll_start", action: "errno", errno: "ENOMEM", repeat: 1 }); +faultArmed.resolve(); + +while (server.pendingRequests > 0) await new Promise<void>(resolve => setImmediate(resolve)); +await ping("after"); +await server.stop(); +log("done"); diff --git a/test/js/bun/http/serve-syscall-fault.test.ts b/test/js/bun/http/serve-syscall-fault.test.ts index ac1a02895c08..41b0f2218fbe 100644 --- a/test/js/bun/http/serve-syscall-fault.test.ts +++ b/test/js/bun/http/serve-syscall-fault.test.ts @@ -1,6 +1,7 @@ import { socketFaultInjection as fault } from "bun:internal-for-testing"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, tls as certs, isWindows } from "harness"; +import { bunEnv, bunExe, tls as certs, isLinux, isWindows, tempDir } from "harness"; +import { join } from "node:path"; const skip = !fault.available() || isWindows; @@ -153,3 +154,30 @@ describe.skipIf(skip)("Bun.serve under injected syscall faults", () => { expect(proc.exitCode).toBe(0); }); }); + +// The dispatcher removes a paused socket from the epoll set when its peer hangs up, so resuming it +// is a new EPOLL_CTL_ADD that can fail. kqueue and libuv never do that. +describe.skipIf(skip || !isLinux)("Bun.serve: resuming a request socket fails in the kernel", () => { + const ping = "HTTP/1.1 200 OK, pong"; + const bodyFails = [`before: ${ping}`, "abort", "body: rejected AbortError", `after: ${ping}`, "done"]; + + test.each([ + ["body-buffered", bodyFails], + ["body-stream", bodyFails], + ["response-ends", [`before: ${ping}`, `after: ${ping}`, "done"]], + ])("%s", async (mode, expected) => { + using dir = tempDir("serve-resume-fault", {}); + await using proc = Bun.spawn({ + cmd: [bunExe(), join(import.meta.dir, "serve-resume-fault-fixture.ts"), mode, join(String(dir), "s.sock")], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim().split("\n"), stderr: exitCode === 0 ? "" : stderr.slice(-3000) }).toEqual({ + stdout: expected, + stderr: "", + }); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index c27af92a7221..a2a2179e894d 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -18,6 +18,7 @@ import { isPosix, isWindows, libcPathForDlopen, + MAX_PATH_BYTES, runFixtureMaxRSS, tempDir, tls, @@ -495,6 +496,61 @@ it("logs the invalid-response diagnostic when a synchronous fetch handler return expect(stderr).toContain("received '42'"); }); +it("passes the error to error() when inspecting a non-Response return value throws", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + import { inspect } from "node:util"; + const events = []; + const throwsOnInspect = message => ({ [inspect.custom]() { throw new Error(message); } }); + process.on("uncaughtException", err => events.push("uncaughtException: " + err.message)); + async function hit(label, firstResult) { + let calls = 0; + await using server = Bun.serve({ + port: 0, + development: false, + routes: { "/:id": req => (calls++ === 0 ? firstResult(req) : new Response("ok")) }, + error: err => new Response("error(): " + err.message, { status: 500 }), + }); + for (let i = 0; i < 2; i++) { + const res = await fetch(new URL("/1", server.url)); + events.push(label + ": " + res.status + " " + (await res.text())); + } + } + await hit("sync", () => throwsOnInspect("sync inspect")); + await hit("pending promise", async () => { + await new Promise(resolve => setImmediate(resolve)); + return throwsOnInspect("pending promise inspect"); + }); + await hit("request", req => { + req.params.nested = throwsOnInspect("request inspect"); + return req; + }); + console.log(JSON.stringify(events)); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr.match(/^error: Expected a Response object.*$/gm) ?? stderr).toEqual( + new Array(3).fill("error: Expected a Response object"), + ); + expect(stdout.trim()).toBe( + JSON.stringify( + ["sync", "pending promise", "request"].flatMap(label => [ + `${label}: 500 error(): ${label} inspect`, + `${label}: 200 ok`, + ]), + ), + ); + expect(exitCode).toBe(0); +}); + it("request.signal works in trivial case", async () => { var aborty = new AbortController(); var signaler = Promise.withResolvers(); @@ -5593,6 +5649,47 @@ describe("requests pipelined in one read", () => { }); }); +it("survives a handler that import()s a request path longer than the path buffer", async () => { + // Joined to the routes directory, this is longer than the path buffer but shorter than the specifier length that + // import() rejects up front. A request line is too short to overflow a Windows path buffer. + const pathLength = isWindows ? 4090 : MAX_PATH_BYTES - 6; + using dir = tempDir("serve-import-request-path", { + "routes/hello.js": `export default "hello";`, + "server.js": ` + import { join } from "node:path"; + const ROOT = join(import.meta.dir, "routes"); + using server = Bun.serve({ + port: 0, + async fetch(req) { + try { + const route = await import(join(ROOT, new URL(req.url).pathname)); + return new Response(route.default); + } catch { + return new Response("no such route", { status: 404 }); + } + }, + }); + const long = await fetch(new URL("/" + Buffer.alloc(${pathLength}, "a").toString(), server.url)); + const good = await fetch(new URL("/hello.js", server.url)); + console.log(JSON.stringify({ long: long.status, good: [good.status, await good.text()] })); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "server.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: JSON.stringify({ long: 404, good: [200, "hello"] }), + stderr: "", + exitCode: 0, + }); +}); + // node:http reads "close" from Proxy-Connection too, like llhttp. That is not a Bun.serve default. it("Proxy-Connection: close does not end a Bun.serve connection", async () => { using server = serve({ port: 0, fetch: req => new Response(new URL(req.url).pathname) }); diff --git a/test/js/bun/image/image-adversarial.test.ts b/test/js/bun/image/image-adversarial.test.ts index e5ad99cbdcf0..29a9327e8cdc 100644 --- a/test/js/bun/image/image-adversarial.test.ts +++ b/test/js/bun/image/image-adversarial.test.ts @@ -1222,6 +1222,92 @@ describe("concurrent terminals on one Image", () => { expect(out[0]).toBe(0xff); expect(out[1]).toBe(0xd8); // still JPEG }); + + // An input over ~1 KB is borrowed, so the pool thread reads bytes that the JS thread can still + // write. The docs forbid that write. It can corrupt the image, but it must not crash. + const raceFixture = (setup: string) => ` + const bmp = (w, h, c) => { + const b = Buffer.alloc(54 + w * h * 4); + b.write("BM"); + b.writeUInt32LE(b.length, 2); + b.writeUInt32LE(54, 10); + b.writeUInt32LE(40, 14); + b.writeInt32LE(w, 18); + b.writeInt32LE(-h, 22); + b.writeUInt16LE(1, 26); + b.writeUInt16LE(32, 28); + b.fill(Buffer.from([c, c, c, 255]), 54); + return b; + }; + const webp = async (w, h, c) => Buffer.from(await new Bun.Image(bmp(w, h, c)).webp({ lossless: true }).bytes()); + ${setup} + let settled = 0; + for (let round = 0; round < 8; round++) { + const jobs = []; + for (let i = 0; i < 32; i++) jobs.push(new Bun.Image(input).png().bytes().catch(() => {}).then(() => settled++)); + let done = false; + const all = Promise.all(jobs).then(() => (done = true)); + while (!done) { + const until = performance.now() + 4; + do { + for (let k = 0; k < 64; k++) rewrite(); + } while (performance.now() < until); + await new Promise(r => setImmediate(r)); + } + await all; + } + console.log(settled); + `; + test.concurrent.each([ + [ + // libwebp parses the header more than once, so its output size can differ from the size it reported. + "dimensions", + ` + const input = new Uint8Array(2048); + const small = new Uint8Array(2048), large = new Uint8Array(2048); + small.set(await webp(16, 16, 1)); + large.set(await webp(200, 211, 2)); + const rewrite = () => { input.set(large); input.set(small); }; + input.set(large); + `, + ], + [ + // libwebp's demuxer counts the chunks with a tag, then looks one up. + // The filler chunks widen the gap between the two walks. + "ICCP tag", + ` + const chunk = (tag, payload) => { + const h = Buffer.alloc(8); + h.write(tag, 0, "latin1"); + h.writeUInt32LE(payload.length, 4); + return Buffer.concat([h, payload, Buffer.alloc(payload.length & 1)]); + }; + const body = Buffer.concat([ + Buffer.from("WEBP"), + chunk("VP8X", Buffer.from([0x20, 0, 0, 0, 31, 0, 0, 31, 0, 0])), + chunk("ICCP", Buffer.alloc(512, 7)), + Buffer.alloc(4096 * 10, chunk("FILL", Buffer.alloc(2))), + (await webp(32, 32, 9)).subarray(12), + ]); + const head = Buffer.alloc(8); + head.write("RIFF"); + head.writeUInt32LE(body.length, 4); + const input = new Uint8Array(Buffer.concat([head, body])); + const at = Buffer.from(input.buffer).indexOf("ICCP"); + const ICCP = Buffer.from("ICCP"), JUNK = Buffer.from("JUNK"); + const rewrite = () => { input.set(JUNK, at); input.set(ICCP, at); }; + `, + ], + ])("doesn't crash when the %s of a WebP input is rewritten while a pipeline is pending", async (_, setup) => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", raceFixture(setup)], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr: stderr.slice(-3000), exitCode }).toEqual({ stdout: "256\n", stderr: "", exitCode: 0 }); + }); }); // ─── 10. random-byte fuzz (cheap, bounded) ─────────────────────────────────── diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index b9b7163b89e4..bcbd9deef142 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -7,6 +7,7 @@ import { exampleSite, gcTick, isASAN, + isMacOS, isWindows, tempDir, withoutAggressiveGC, @@ -563,6 +564,47 @@ const IS_UV_FS_COPYFILE_DISABLED = expect(exitCode).toBe(0); }); + // The first read that has to wait for a pipe starts the IO thread, which needs its own descriptors. + // TODO: times out on macOS. + it.skipIf(isWindows || isMacOS)( + "Bun.file(fifo).text() rejects with EMFILE when out of descriptors, and works afterwards", + async () => { + using dir = tempDir("bun-file-fifo-emfile", {}); + const fifo = join(String(dir), "fifo"); + expect(Bun.spawnSync({ cmd: ["mkfifo", fifo] }).exitCode).toBe(0); + const script = ` + const fs = require("node:fs"); + const fifo = process.argv[1]; + const writer = fs.openSync(fifo, fs.constants.O_RDWR | fs.constants.O_NONBLOCK); + const held = []; + try { + for (;;) held.push(fs.openSync("/dev/null", "r")); + } catch {} + // Free one descriptor so the read can open the fifo. + fs.closeSync(held.pop()); + console.log(await Bun.file(fifo).text().catch(e => e.code)); + for (const fd of held) fs.closeSync(fd); + const countFds = () => fs.readdirSync(process.platform === "linux" ? "/proc/self/fd" : "/dev/fd").length; + const before = countFds(); + const text = Bun.file(fifo).text(); + // The fifo drops its bytes if the writer closes before the read has opened it. + while (countFds() < before + 2) await Bun.sleep(1); + fs.writeSync(writer, "hello"); + fs.closeSync(writer); + console.log(await text); + `; + await using proc = Bun.spawn({ + cmd: ["sh", "-c", 'ulimit -n 64 && exec "$0" -e "$1" "$2"', bunExe(), script, fifo], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("EMFILE\nhello\n"); + expect(exitCode).toBe(0); + }, + ); + it("Bun.file(0) survives GC", async () => { for (let i = 0; i < 10; i++) { let f = Bun.file(0); diff --git a/test/js/bun/jsc/bun-jsc.test.ts b/test/js/bun/jsc/bun-jsc.test.ts index c2c0222335f8..3516b295ce88 100644 --- a/test/js/bun/jsc/bun-jsc.test.ts +++ b/test/js/bun/jsc/bun-jsc.test.ts @@ -26,7 +26,9 @@ import { totalCompileTime, } from "bun:jsc"; import { describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, isBuildKite, isWindows } from "harness"; +import { bunEnv, bunExe, isBuildKite, isWindows, tempDir } from "harness"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; describe("bun:jsc", () => { function count() { @@ -340,6 +342,16 @@ describe("bun:jsc", () => { }); }); +it("deserialize returns a Date whose getTime() is NaN when the timestamp is a NaN with a payload", () => { + const nan = Buffer.alloc(8); + nan.writeDoubleLE(NaN, 0); + const bytes = Buffer.from(serialize(new Date(NaN))); + const at = bytes.indexOf(nan); + expect(at).toBeGreaterThan(0); + bytes.writeBigUInt64LE(0xfffe000000000007n, at); + expect(deserialize(bytes).getTime()).toBeNaN(); +}); + it("deserialize rejects an object reference index outside the deserialized object pool", async () => { // A payload whose first value is ObjectReferenceTag must have its pool index // validated against the number of objects deserialized so far (zero here), @@ -478,6 +490,26 @@ it("deserialize rejects a RegExp record whose pattern does not parse", async () expect(exitCode).toBe(0); }); +it("getProtectedObjects returns only objects", async () => { + const script = ` + import { getProtectedObjects } from "bun:jsc"; + const list = getProtectedObjects(); + for (const value of list) { + if (Object(value) !== value) throw new Error("getProtectedObjects returned a non-object: " + typeof value); + Object.prototype.toString.call(value); + } + console.log("ok", list.length > 0); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, exitCode }).toEqual({ stdout: "ok true\n", exitCode: 0 }); +}); + it("serialize rejects a CryptoKey created with extractable set to false", async () => { // bun:jsc serialize() (and node:v8 serialize(), which wraps it) hands the raw // structured-clone buffer to the caller, so a key imported with @@ -700,3 +732,37 @@ describe("JsRef::Weak liveness", () => { expect(kept.keep).toBe(true); }); }); + +describe.concurrent("sampling profiler report at exit", () => { + const work = `let x = 0; for (let i = 0; i < 2e6; i++) x += Math.sqrt(i); console.log("done", x > 0);`; + + async function run(script: string, env: Record<string, string> = {}) { + using dir = tempDir("sampling-profiler", {}); + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: { ...bunEnv, ...env }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const profiles = join(String(dir), "missing", "profiles"); + const reports = existsSync(profiles) ? readdirSync(profiles) : []; + return { + stdout, + stderr, + reports: reports.map(name => readFileSync(join(profiles, name), "utf8").includes("Top functions as")), + exitCode, + }; + } + const expected = { stdout: "done true\n", stderr: "", reports: [true], exitCode: 0 }; + + it("is written when startSamplingProfiler is given a missing directory", async () => { + expect(await run(`require("bun:jsc").startSamplingProfiler("missing/profiles"); ${work}`)).toEqual(expected); + }); + + it("is written on process.exit() when BUN_JSC_samplingProfilerPath is a missing directory", async () => { + const env = { BUN_JSC_useSamplingProfiler: "1", BUN_JSC_samplingProfilerPath: "missing/profiles" }; + expect(await run(`${work} process.exit(0);`, env)).toEqual(expected); + }); +}); diff --git a/test/js/bun/md/md-render-callback.test.ts b/test/js/bun/md/md-render-callback.test.ts index ee4502374d71..4f057a86beb3 100644 --- a/test/js/bun/md/md-render-callback.test.ts +++ b/test/js/bun/md/md-render-callback.test.ts @@ -426,4 +426,70 @@ describe("Bun.markdown buffer input", () => { input.buffer.transfer(); expect((input.buffer as ArrayBuffer).detached).toBe(true); }); + + // A pin prevents a detach but not a shrink, and `resize()` unmaps the trimmed pages. + describe("survives a resizable input that shrinks during the call", () => { + const source = "# Hello\n\nworld **bold**\n"; + + function resizableInput() { + const bytes = new TextEncoder().encode(source); + const buffer = new ArrayBuffer(bytes.length, { maxByteLength: bytes.length }); + const view = new Uint8Array(buffer); + view.set(bytes); + return { buffer, view }; + } + + test("html", () => { + const { buffer, view } = resizableInput(); + const options = { + get autolinks() { + buffer.resize(0); + return true; + }, + }; + expect(Markdown.html(view, options)).toBe(Markdown.html(source, { autolinks: true })); + expect(buffer.byteLength).toBe(0); + }); + + test("ansi", () => { + const { buffer, view } = resizableInput(); + const theme = { + get colors() { + buffer.resize(0); + return false; + }, + }; + expect(Markdown.ansi(view, theme)).toBe(Markdown.ansi(source, { colors: false })); + expect(buffer.byteLength).toBe(0); + }); + + test("render", () => { + const { buffer, view } = resizableInput(); + const result = Markdown.render(view, { + heading: (children: string) => { + buffer.resize(0); + return `<h1>${children}</h1>`; + }, + paragraph: (children: string) => `<p>${children}</p>`, + strong: (children: string) => `<b>${children}</b>`, + }); + expect(result).toBe("<h1>Hello</h1><p>world <b>bold</b></p>"); + expect(buffer.byteLength).toBe(0); + }); + + test("react", () => { + const { buffer, view } = resizableInput(); + const options = { + reactVersion: 18, + get autolinks() { + buffer.resize(0); + return true; + }, + }; + expect(Markdown.react(view, undefined, options)).toEqual( + Markdown.react(source, undefined, { reactVersion: 18, autolinks: true }), + ); + expect(buffer.byteLength).toBe(0); + }); + }); }); diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index 063c42d3ab33..841df8b0f2c6 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -13,6 +13,7 @@ import { isLinux, isWindows, libcPathForDlopen, + normalizeBunSnapshot, tempDir, tls, } from "harness"; @@ -2432,6 +2433,83 @@ it("reload() backs out cleanly when a handler getter closes the socket mid-reloa void stderr; }); +it("reload() does nothing when the top-level socket getter closes the socket", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const h = { data() {}, open() {}, close() {}, error() {} }; + const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { ...h } }); + for (const verb of ["end", "terminate"]) { + const sock = await Bun.connect({ hostname: "127.0.0.1", port: listener.port, socket: { ...h } }); + // The handlers are kept alive while a socket callback is dispatched, so wait until it returns. + await new Promise(resolve => setImmediate(resolve)); + console.log(verb, "reload returned", sock.reload({ + get socket() { + sock[verb](); + return h; + }, + })); + } + listener.stop(true); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("end reload returned undefined\nterminate reload returned undefined\n"); + if (exitCode !== 0) expect(stderr).toBe(""); + expect(exitCode).toBe(0); +}); + +it("upgradeTLS() throws when an option getter re-enters upgradeTLS on the same socket", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const h = { data() {}, open() {}, close() {}, error() {}, handshake() {}, drain() {} }; + const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { ...h } }); + const sock = await Bun.connect({ hostname: "127.0.0.1", port: listener.port, socket: { ...h } }); + await new Promise(resolve => setImmediate(resolve)); + let inner; + const opts = { + data: {}, + socket: { ...h }, + get tls() { + inner ??= sock.upgradeTLS({ data: {}, socket: { ...h }, tls: true }); + return true; + }, + }; + let outer; + try { + outer = sock.upgradeTLS(opts); + } catch (e) { + console.log("outer upgradeTLS threw:", e.message); + } + console.log("inner upgradeTLS returned", inner.length, "sockets"); + for (const s of [...(outer ?? []), ...inner, sock]) s.write("x"); + for (const s of [...(outer ?? []), ...inner, sock]) s.terminate(); + listener.stop(true); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe( + "outer upgradeTLS threw: upgradeTLS requires an established socket\n" + "inner upgradeTLS returned 2 sockets\n", + ); + if (exitCode !== 0) expect(stderr).toBe(""); + expect(exitCode).toBe(0); +}); + it("node:net connect() reusing a server-accepted handle keeps the listener's handlers working", async () => { // A Bun.listen()-accepted socket wrapper does not own its handlers — they // live inside the listener. Reusing such a wrapper as the handle for an @@ -6552,3 +6630,81 @@ it("a view of shared memory as a tls option is refused", () => { } } }); + +// expect(promise).resolves ticks the loop while the dispatch that called the handler is still on the stack. +// The outer dispatch reads the closed socket again, so the inner ticks must not free it. +describe.concurrent("a handler that closes its socket and then waits for a promise", () => { + it("doesn't crash the dispatch that called it", async () => { + const source = ` + import { expect, test } from "bun:test"; + + // JS can't observe when the loop finishes the close, so tick the loop from the handler for a short time. + function waitInsideHandler() { + expect(Bun.sleep(20)).resolves.toBeUndefined(); + } + + test("data() closes the accepted socket", async () => { + for (let i = 0; i < 3; i++) { + const dispatched = Promise.withResolvers(); + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + data(socket) { + socket.terminate(); + waitInsideHandler(); + setTimeout(dispatched.resolve, 0); + }, + }, + }); + const peer = await Bun.connect({ hostname: "127.0.0.1", port: server.port, socket: { data() {} } }); + peer.write("x"); + await dispatched.promise; + peer.terminate(); + } + console.log("data() done"); + }); + + test("open() closes the accepted socket and the listener", async () => { + for (let i = 0; i < 3; i++) { + const dispatched = Promise.withResolvers(); + const server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open(socket) { + socket.terminate(); + server.stop(true); + waitInsideHandler(); + setTimeout(dispatched.resolve, 0); + }, + data() {}, + }, + }); + // The server resets the client, so its connect may resolve, reject or (on kqueue) never settle. + Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + socket: { data() {}, error() {}, connectError() {}, close() {} }, + }).catch(() => {}); + await dispatched.promise; + } + console.log("open() done"); + }); + `; + using dir = tempDir("socket-close-then-wait-in-handler", { "fixture.test.ts": source }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "fixture.test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: normalizeBunSnapshot(stdout), exitCode, stderr: exitCode === 0 ? "" : stderr }).toEqual({ + stdout: "bun test <version> (<revision>)\ndata() done\nopen() done", + exitCode: 0, + stderr: "", + }); + }); +}); diff --git a/test/js/bun/net/tcp-server.test.ts b/test/js/bun/net/tcp-server.test.ts index 2202c2e742dd..8078f75e683b 100644 --- a/test/js/bun/net/tcp-server.test.ts +++ b/test/js/bun/net/tcp-server.test.ts @@ -1,6 +1,6 @@ import { connect, listen, SocketHandler, TCPSocketListener } from "bun"; import { describe, expect, it } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, isWindows } from "harness"; import { join } from "node:path"; type Resolve = (value?: unknown) => void; @@ -296,6 +296,62 @@ describe("tcp socket binaryType", () => { } }); +it.skipIf(isWindows)("stop(true) between ticks closes every socket when a close handler ticks the loop", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + import { expect } from "bun:test"; + const events = []; + const { promise: bothOpen, resolve: onBothOpen } = Promise.withResolvers(); + let opened = 0; + const server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open() { + if (++opened === 2) onBothOpen(); + }, + data() {}, + close() { + events.push("close"); + if (events.length === 1) { + // "timer" before "ticked" proves that the wait ticked the loop. + const timer = new Promise(resolve => + setTimeout(() => { + events.push("timer"); + resolve(); + }), + ); + expect(timer).resolves.toBeUndefined(); + events.push("ticked"); + } + }, + }, + }); + for (let i = 0; i < 2; i++) { + await Bun.connect({ hostname: "127.0.0.1", port: server.port, socket: { data() {} } }); + } + await bothOpen; + // An immediate runs between event loop ticks. + setImmediate(() => { + server.stop(true); + events.push("stopped"); + console.log(events.join(",")); + }); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("close,timer,ticked,close,stopped\n"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); +}); + it("should not leak memory", async () => { // The fixture counts live Listener and TCPSocket objects after the sockets // close. The count is heap-wide, and under `bun test --parallel` this diff --git a/test/js/bun/plugin/plugins.test.ts b/test/js/bun/plugin/plugins.test.ts index 6b8fe76d89c6..2ad2d7c605c0 100644 --- a/test/js/bun/plugin/plugins.test.ts +++ b/test/js/bun/plugin/plugins.test.ts @@ -1,7 +1,7 @@ /// <reference types="./plugins" /> import { plugin } from "bun"; import { describe, expect, it } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, isWindows } from "harness"; import { resolve } from "path"; declare global { @@ -734,6 +734,30 @@ it("import(...) without __esModule", async () => { expect(mod).toBe("world"); }); +it("builder methods return the builder only when called as methods", () => { + plugin({ + name: "builder receiver", + setup(builder) { + const options = { filter: /.*/, namespace: "builder-receiver" }; + const virtualModule = () => ({ exports: {}, loader: "object" }) as const; + expect([ + builder.onResolve(options, () => undefined), + builder.onLoad(options, () => undefined), + builder.module("builder-receiver-virtual-module", virtualModule), + ]).toEqual([builder, builder, builder]); + + const { onResolve, onLoad, module: defineModule } = builder; + // JSC passes the scope object as the receiver of a call through a closed-over binding. + const bare = () => [ + onResolve(options, () => undefined), + onLoad(options, () => undefined), + defineModule("builder-receiver-virtual-module-bare", virtualModule), + ]; + expect(bare()).toEqual([undefined, undefined, undefined]); + }, + }); +}); + it("recursion throws stack overflow", () => { expect(() => { require("recursion:recursion"); @@ -1049,6 +1073,71 @@ describe.concurrent("Bun.plugin.clearAll()", () => { }); }); +// A macro runs JS in the middle of transpiling. Afterwards, the printer reads the +// module's names and strings from the source again. +it.concurrent("transpiles the original typed array onLoad contents when a macro overwrites them", async () => { + using dir = tempDir("plugin-contents-macro", { + "macro.ts": ` + export function clobber() { + globalThis.contents.fill(0x20); + globalThis.contents.buffer.transfer(); + return "clobbered"; + } + `, + "index.ts": ` + import { plugin } from "bun"; + import { join } from "node:path"; + + const source = new TextEncoder().encode( + "import { clobber } from " + JSON.stringify(join(import.meta.dir, "macro.ts")) + ' with { type: "macro" };\\n' + + "export const fromTheMacro = clobber();\\n" + + "export const afterTheMacro = 'after the macro';\\n", + ); + + // The view has its own ArrayBuffer so that transfer() detaches exactly these bytes. + function contents() { + globalThis.contents = new Uint8Array(new ArrayBuffer(source.length)); + globalThis.contents.set(source); + return globalThis.contents; + } + + plugin({ + name: "typed array contents", + setup(build) { + build.onResolve({ filter: /.*/, namespace: "bytes" }, ({ path }) => ({ path, namespace: "bytes" })); + build.onLoad({ filter: /plain/, namespace: "bytes" }, () => ({ contents: contents(), loader: "ts" })); + build.onLoad({ filter: /promise/, namespace: "bytes" }, async () => ({ contents: contents(), loader: "ts" })); + build.module("bytes-module", () => ({ contents: contents(), loader: "ts" })); + }, + }); + + const results = {}; + for (const specifier of ["bytes:plain", "bytes:promise", "bytes-module"]) { + const { fromTheMacro, afterTheMacro } = await import(specifier); + results[specifier] = { fromTheMacro, afterTheMacro, byteLength: globalThis.contents.byteLength }; + } + console.log(JSON.stringify(results)); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const transpiled = { fromTheMacro: "clobbered", afterTheMacro: "after the macro", byteLength: 0 }; + // Debug builds log each macro call to stdout first. + expect({ result: stdout.trim().split("\n").at(-1), stderr: stderr.trim(), exitCode }).toEqual({ + result: JSON.stringify({ "bytes:plain": transpiled, "bytes:promise": transpiled, "bytes-module": transpiled }), + stderr: "", + exitCode: 0, + }); +}); + it("object loader: an error thrown by a getter on the exports object rejects the require()", () => { const boom = new Error("boom"); plugin({ @@ -1655,3 +1744,235 @@ describe.concurrent("onResolve", () => { }); }); }); + +describe.concurrent("object loader: keeps the exports object alive as long as its source", () => { + async function run(entry: string) { + using dir = tempDir("plugin-object-loader-source", { + "entry.ts": ` + const exports = () => ({ exports: { v: 1, list: [1, 2, 3] }, loader: "object" }); + Bun.plugin({ name: "virtual", setup(build) { build.module("virtual:x", exports); } }); + ${entry} + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + return { stdout, exitCode }; + } + + // The in-flight import() builds a second module from the same source. + it("survives delete require.cache while an import() is in flight", async () => { + expect( + await run(` + const seen = new Set(); + for (let round = 0; round < 10; round++) { + await import("virtual:x"); + const inFlight = import("virtual:x"); + delete require.cache["virtual:x"]; + Bun.gc(true); + const others = Array.from({ length: 1000 }, (_, i) => ({ other: i, object: [i] })); + seen.add(JSON.stringify(await inFlight)); + others.length = 0; + } + console.log([...seen].join("\\n")); + `), + ).toEqual({ stdout: `{"list":[1,2,3],"v":1}\n`, exitCode: 0 }); + }); + + // The loader builds a module from only one of the two sources. + it("two concurrent import() calls don't leak protected objects", async () => { + expect( + await run(` + const { heapStats } = require("bun:jsc"); + Bun.gc(true); + const before = heapStats().protectedObjectCount; + for (let round = 0; round < 20; round++) { + await Promise.all([import("virtual:x"), import("virtual:x")]); + delete require.cache["virtual:x"]; + } + Bun.gc(true); + console.log("protected:", heapStats().protectedObjectCount - before); + `), + ).toEqual({ stdout: "protected: 0\n", exitCode: 0 }); + }); +}); + +describe("namespace validation", () => { + const neverMatches = /(?!)/; + const rejection = { name: "Error", message: "namespace can only contain letters, numbers, dashes, or underscores" }; + const accepted = { onLoad: null, onResolve: null }; + const rejected = { onLoad: rejection, onResolve: rejection }; + + function thrownBy(setup: (build: import("bun").PluginBuilder) => void) { + try { + plugin({ name: "namespace validation", setup }); + } catch (error: any) { + return { name: error.name, message: error.message }; + } + return null; + } + + function register(namespace: string) { + return { + onLoad: thrownBy(build => build.onLoad({ filter: neverMatches, namespace }, () => ({ contents: "" }))), + onResolve: thrownBy(build => build.onResolve({ filter: neverMatches, namespace }, () => undefined)), + }; + } + + it.each([ + ["abc-DEF_09", "accepted"], + ["@scope/pkg", "accepted"], + ["", "rejected"], + ["a b", "rejected"], + ["a.b", "rejected"], + ["a\0b", "rejected"], + ["héllo", "rejected"], + ["a😀b", "rejected"], + ])("%j is %s", (namespace, verdict) => { + expect(register(namespace)).toEqual(verdict === "accepted" ? accepted : rejected); + }); + + // Not concurrent because the child uses four busy threads. + it("validates correctly while four threads register plugins concurrently", async () => { + using dir = tempDir("plugin-namespace-threads", { + "entry.js": ` + // Long enough that each thread spends most of its time in the namespace check. + const long = Buffer.alloc(16384, "a").toString(); + const namespaces = ["abc", "abc-def", "a/b", "@scope/pkg", "A_Z-0/9", "x"].map(name => long + name); + const rejection = "namespace can only contain letters, numbers, dashes, or underscores"; + const filter = /.*/; + const callback = () => undefined; + + function registerPlugins(progress, thread) { + try { + Bun.plugin({ + name: "thread " + thread, + setup(build) { + // Every thread runs until all four have done 20 rounds, so their work overlaps. + while (Math.min(...progress) < 20) { + for (const namespace of namespaces) { + build.onLoad({ filter, namespace }, callback); + build.onResolve({ filter, namespace }, callback); + } + + let message; + try { + build.onResolve({ filter, namespace: "not valid" }, callback); + } catch (error) { + message = error.message; + } + if (message !== rejection) { + throw new Error("thread " + thread + ': "not valid" was not rejected: ' + message); + } + progress[thread]++; + } + }, + }); + } finally { + // A failed thread must not block the others. + progress[thread] = 2 ** 31 - 1; + } + } + + if (Bun.isMainThread) { + function reply(worker, expected) { + const { promise, resolve, reject } = Promise.withResolvers(); + worker.onmessage = ({ data }) => (data === expected ? resolve() : reject(new Error(data))); + worker.onerror = event => reject(new Error(event.message)); + return promise; + } + + const workers = Array.from({ length: 3 }, () => new Worker(import.meta.url)); + const progress = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * (workers.length + 1))); + await Promise.all(workers.map(worker => reply(worker, "ready"))); + const finished = workers.map(worker => reply(worker, "done")); + workers.forEach((worker, index) => worker.postMessage({ progress, thread: index + 1 })); + registerPlugins(progress, 0); + await Promise.all(finished); + console.log("ok"); + } else { + // { once: true } removes the listener, which lets the worker exit. + addEventListener( + "message", + ({ data: { progress, thread } }) => { + try { + registerPlugins(progress, thread); + postMessage("done"); + } catch (error) { + postMessage(String(error)); + } + }, + { once: true }, + ); + postMessage("ready"); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "ok\n", stderr: "", exitCode: 0 }); + }); +}); + +// The module key's bytes are freed with the module, after a failed load or `delete require.cache[key]`. +it.concurrent("the path passed to onLoad is still valid after the module is freed", async () => { + const source = ` + const kept = { failed: [], failedInNamespace: [], deleted: [] }; + Bun.plugin({ + name: "keeps args.path", + setup(build) { + build.onResolve({ filter: /\\.(failed|deleted)$/ }, ({ path }) => ({ path })); + build.onLoad({ filter: /\\.failed$/ }, ({ path }) => { kept.failed.push(path); throw new Error("not loaded"); }); + build.onLoad({ filter: /\\.deleted$/ }, ({ path }) => { kept.deleted.push(path); return { exports: {}, loader: "object" }; }); + build.onResolve({ filter: /\\.failed-in-namespace$/ }, ({ path }) => ({ path, namespace: "kept" })); + build.onLoad({ filter: /.*/, namespace: "kept" }, ({ path }) => { kept.failedInNamespace.push(path); throw new Error("not loaded"); }); + }, + }); + const key = (i, extension) => "/absent/a-path-that-is-long-enough-to-have-a-buffer-of-its-own/" + i + extension; + for (let i = 0; i < 50; i++) { + try { require(key(i, ".failed")); } catch {} + try { require(key(i, ".failed-in-namespace")); } catch {} + require(key(i, ".deleted")); + delete require.cache[key(i, ".deleted")]; + } + Bun.gc(true); + const others = []; + for (let i = 0; i < 2000; i++) others.push("another string that is given the memory that was freed " + i); + Bun.gc(true); + const changed = (paths, extension) => paths.length + " kept, " + paths.filter((path, i) => path !== key(i, extension)).length + " changed"; + console.log("failed: " + changed(kept.failed, ".failed")); + console.log("failed in a namespace: " + changed(kept.failedInNamespace, ".failed-in-namespace")); + console.log("deleted: " + changed(kept.deleted, ".deleted")); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", source], + env: { + ...bunEnv, + // With the system allocator, ASAN reports a read of the freed bytes. + ...(isWindows ? {} : { Malloc: "1" }), + // symbolize=0: symbolizing a failure report is slower than the test timeout. + // detect_leaks=0: Malloc=1 exposes JSC's never-freed startup allocations to LSAN. + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "symbolize=0", "detect_leaks=0"].filter(Boolean).join(":"), + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "failed: 50 kept, 0 changed\nfailed in a namespace: 50 kept, 0 changed\ndeleted: 50 kept, 0 changed\n", + stderr: "", + exitCode: 0, + }); +}); diff --git a/test/js/bun/repl/repl.test.ts b/test/js/bun/repl/repl.test.ts index 5947a8d30a3f..6f3ecb50cd83 100644 --- a/test/js/bun/repl/repl.test.ts +++ b/test/js/bun/repl/repl.test.ts @@ -449,6 +449,15 @@ describe.concurrent("Bun REPL", () => { expect(exitCode).toBe(0); }); + // Windows path buffers are large enough for this name. + test.skipIf(isWindows)(".load with a filename longer than PATH_MAX shows ENAMETOOLONG", async () => { + const long = "./" + Array(21).fill(Buffer.alloc(200, "a").toString()).join("/") + ".js"; + const { outputs, stderr, exitCode } = await runRepl([`.load ${long}`, "1 + 1", ".exit"]); + expect(outputs).toEqual([expect.stringMatching(/^ENAMETOOLONG: /), "2"]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + test(".load and .save without a filename show their usage", async () => { const { outputs, stderr, exitCode } = await runRepl([".load", ".save", ".exit"]); expect(outputs).toEqual(["Usage: .load <filename>", "Usage: .save <filename>"]); @@ -1864,6 +1873,14 @@ describe.skipIf(isWindows).concurrent("REPL history file permissions", () => { }); }); +test("skips the history file when $HOME is longer than PATH_MAX", async () => { + const home = "/" + Array(21).fill(Buffer.alloc(200, "a").toString()).join("/"); + const { outputs, stderr, exitCode } = await runRepl(["1 + 1", ".exit"], { home }); + expect(outputs).toEqual(["2"]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); +}); + // `bun --interactive` boots the full node:repl + readline + acorn stack; on a // debug+asan build that is ~4–5s per spawn, so the 5s default is too tight. const interactiveTimeout = 20_000; diff --git a/test/js/bun/resolve/esModule-annotation.test.js b/test/js/bun/resolve/esModule-annotation.test.js index 01c5fb3b40a8..833cd287e004 100644 --- a/test/js/bun/resolve/esModule-annotation.test.js +++ b/test/js/bun/resolve/esModule-annotation.test.js @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { tempDir } from "harness"; +import { bunEnv, bunExe, tempDir } from "harness"; import { join } from "path"; import * as WithTypeModuleExportEsModuleAnnotationMissingDefault from "./with-type-module/export-esModule-annotation-empty.cjs"; import * as WithTypeModuleExportEsModuleAnnotationNoDefault from "./with-type-module/export-esModule-annotation-no-default.cjs"; @@ -81,3 +81,43 @@ describe("CJS exports the ESM wrapper cannot enumerate", () => { await expect(import(join(String(dir), "mod.cjs"))).rejects.toThrow("ownKeys trap"); }); }); + +describe("__esModule defined as a getter", () => { + // The ESM wrapper checks for __esModule without running user code, so it ignores a getter + // and the default import is module.exports. + test.concurrent.each([ + ["import", `import def, { named } from "./getter.cjs";`], + ["import()", `const { default: def, named } = await import("./getter.cjs");`], + ])("%s doesn't call the getter", async (_, importGetter) => { + using dir = tempDir("cjs-esmodule-getter", { + "getter.cjs": ` + globalThis.getterCalls = 0; + Object.defineProperty(exports, "__esModule", { + get() { + globalThis.getterCalls++; + return true; + }, + }); + exports.default = "default"; + exports.named = "named"; + `, + "entry.mjs": ` + ${importGetter} + console.log(JSON.stringify({ defaultImport: typeof def, named, getterCalls: globalThis.getterCalls })); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.mjs"], + // Makes every build abort if the lookup calls into JS. By default only builds with assertions + // abort, and release builds silently get undefined. + env: { ...bunEnv, BUN_JSC_crashOnDisallowedVMEntry: "1", BUN_ENABLE_CRASH_REPORTING: "0" }, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout).toBe(`{"defaultImport":"object","named":"named","getterCalls":0}\n`); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/bun/resolve/resolve-error.test.ts b/test/js/bun/resolve/resolve-error.test.ts index 8b85088a555f..7eb5390e0440 100644 --- a/test/js/bun/resolve/resolve-error.test.ts +++ b/test/js/bun/resolve/resolve-error.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, isWindows, normalizeBunSnapshot, tempDir } from "harness"; +import { bunEnv, bunExe, isWindows, MAX_PATH_BYTES, normalizeBunSnapshot, tempDir } from "harness"; import path from "node:path"; describe("ResolveMessage", () => { @@ -272,6 +272,134 @@ describe.concurrent("long import path overflow", () => { }); }); +// The resolver builds every candidate path in a MAX_PATH_BYTES buffer and treats one that's too long as missing. +describe.concurrent("candidate paths longer than a path buffer", () => { + const longName = Buffer.alloc(MAX_PATH_BYTES, "a").toString(); + + // package.json and node_modules/ keep the resolver from auto-installing the bare specifiers. + async function run(files: Record<string, string>) { + using dir = tempDir("resolve-too-long", { + "package.json": `{"name": "test", "version": "0.0.0"}`, + "node_modules/.keep": "", + ...files, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.cjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { result: stdout && JSON.parse(stdout), stderr, exitCode }; + } + + const helpers = ` + const outcome = fn => { try { return "resolved: " + fn(); } catch (e) { return e.code; } }; + `; + + it("throws MODULE_NOT_FOUND for a missing file whose path length is near the limit", async () => { + const lengths = [-5, -4, -1, 0, 1, 300].map(delta => MAX_PATH_BYTES + delta); + expect( + await run({ + "main.cjs": `${helpers} + const cwd = process.cwd(); + const out = {}; + for (const length of ${JSON.stringify(lengths)}) { + const file = cwd + "/" + Buffer.alloc(length - Buffer.byteLength(cwd) - 1, "f").toString(); + out[length] = [outcome(() => require(file)), outcome(() => require.resolve(file))]; + } + console.log(JSON.stringify(out));`, + }), + ).toEqual({ + result: Object.fromEntries(lengths.map(length => [length, ["MODULE_NOT_FOUND", "MODULE_NOT_FOUND"]])), + stderr: "", + exitCode: 0, + }); + }); + + it("throws when package.json main or module is too long", async () => { + expect( + await run({ + "node_modules/relative/package.json": JSON.stringify({ name: "relative", main: `./${longName}` }), + "node_modules/absolute/package.json": JSON.stringify({ name: "absolute", module: `/${longName}` }), + "main.cjs": `${helpers} + import("absolute").then(() => "resolved", e => e.code).then(absolute => { + console.log(JSON.stringify({ relative: outcome(() => require("relative")), absolute })); + });`, + }), + ).toEqual({ + result: { relative: "MODULE_NOT_FOUND", absolute: "ERR_MODULE_NOT_FOUND" }, + stderr: "", + exitCode: 0, + }); + }); + + it("throws MODULE_NOT_FOUND when a require.extensions key is too long", async () => { + expect( + await run({ + "dir/.keep": "", + "main.cjs": `${helpers} + require.extensions[${JSON.stringify("." + longName)}] = () => {}; + console.log(JSON.stringify({ + file: outcome(() => require("./missing")), + directory: outcome(() => require("./dir")), + }));`, + }), + ).toEqual({ + result: { file: "MODULE_NOT_FOUND", directory: "MODULE_NOT_FOUND" }, + stderr: "", + exitCode: 0, + }); + }); + + it("fails the build when the package.json browser field remaps main to a path that's too long", async () => { + expect( + await run({ + "node_modules/m/package.json": JSON.stringify({ + name: "m", + main: "./x.js", + browser: { "./x.js": `./${longName}` }, + }), + "node_modules/m/x.js": "module.exports = 1;", + "entry.js": `import "m";`, + "main.cjs": ` + Bun.build({ entrypoints: ["./entry.js"], target: "browser", throw: false }).then(build => { + console.log(JSON.stringify({ success: build.success, messages: build.logs.map(log => log.message) })); + });`, + }), + ).toEqual({ + result: { success: false, messages: ['Could not resolve: "m". Maybe you need to "bun install"?'] }, + stderr: "", + exitCode: 0, + }); + }); + + it("skips tsconfig.json extends, baseUrl and paths values that are too long", async () => { + // Each use.cjs uses the tsconfig.json next to it. + expect( + await run({ + "extends/tsconfig.json": JSON.stringify({ extends: `./${longName}` }), + "extends/use.cjs": `module.exports = "loaded";`, + "baseUrl/tsconfig.json": JSON.stringify({ compilerOptions: { baseUrl: longName } }), + "baseUrl/use.cjs": `module.exports = require("x");`, + "paths/tsconfig.json": JSON.stringify({ compilerOptions: { baseUrl: ".", paths: { x: [longName] } } }), + "paths/use.cjs": `module.exports = require("x");`, + "main.cjs": `${helpers} + console.log(JSON.stringify({ + extends: outcome(() => require("./extends/use.cjs")), + baseUrl: outcome(() => require("./baseUrl/use.cjs")), + paths: outcome(() => require("./paths/use.cjs")), + }));`, + }), + ).toEqual({ + result: { extends: "resolved: loaded", baseUrl: "MODULE_NOT_FOUND", paths: "MODULE_NOT_FOUND" }, + stderr: "", + exitCode: 0, + }); + }); +}); + // matchTSConfigPaths sliced `path[prefix.len()..path.len() - suffix.len()]` // after only checking starts_with/ends_with. When the prefix and suffix bytes // overlap inside the import path (e.g. key "ab*ba" vs import "aba"), the slice diff --git a/test/js/bun/resolve/resolve.test.ts b/test/js/bun/resolve/resolve.test.ts index f4768a73c60a..56017db2571b 100644 --- a/test/js/bun/resolve/resolve.test.ts +++ b/test/js/bun/resolve/resolve.test.ts @@ -299,6 +299,13 @@ it("import long string should not segfault", async () => { } catch {} }); +it.skipIf(isWindows)("resolves an absolute specifier that is longer than a path buffer until it is normalized", () => { + using dir = tempDir("resolve-long-specifier", { "index.js": "" }); + expect(Bun.resolveSync(`${dir}${Buffer.alloc(5000, "/").toString()}index.js`, String(dir))).toBe( + join(String(dir), "index.js"), + ); +}); + it("import override to node builtin", async () => { // @ts-expect-error expect(await import("#async_hooks")).toBeDefined(); @@ -334,6 +341,15 @@ it.if(isWindows)("directory cache key computation", () => { expect(import(`\\\\\\Test\\doesnotexist.ts\\\\` as any)).rejects.toThrow(); }); +// The failed resolution busts the directory cache for the dirname, which for `C:\\\x` is `C:\\`. +it.if(isWindows)("import() of a missing file at the drive root rejects", async () => { + const drive = process.cwd().slice(0, 2); + for (const separators of [2, 3, 4]) { + await expect(import(drive + "\\".repeat(separators) + "doesnotexist.ts")).rejects.toThrow(); + } + await expect(import(`${drive}///doesnotexist.ts`)).rejects.toThrow(); +}); + describe("NODE_PATH test", () => { const prepareTest = () => { const tempDir = tempDirWithFiles("node_path", { @@ -841,6 +857,81 @@ describe("package.json exports targets longer than the maximum path length", () ); }); +describe("package.json sideEffects entries longer than the maximum path length", () => { + // Parsing a package.json joins every "sideEffects" entry onto the package directory. + const longSegment = Buffer.alloc(100_000, "s").toString(); + + it.concurrent.each([ + ["exact", [`./${longSegment}.js`]], + ["glob", [`./${longSegment}/*.js`]], + ["mixed", ["./index.js", `./${longSegment}.js`, `./${longSegment}/*.js`]], + ])("requires a package whose sideEffects array has an oversized %s entry", async (_kind, sideEffects) => { + using dir = tempDir("resolver-side-effects-long-entry", { + "node_modules/long-side-effects/package.json": JSON.stringify({ + name: "long-side-effects", + main: "index.js", + sideEffects, + }), + "node_modules/long-side-effects/index.js": "module.exports = 3;\n", + "index.js": `console.log(require("long-side-effects"));\n`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "3\n", stderr: "", exitCode: 0 }); + }); +}); + +// The resolver uses more stack per nesting level of an `exports` target than the JSON parser does. +describe("package.json exports targets nested too deeply", () => { + const targets = { + array: Buffer.alloc(40000, "[").toString() + '"./t.js"' + Buffer.alloc(40000, "]").toString(), + conditions: + Array.from({ length: 30000 }, (_, i) => (i % 2 ? '{"node":' : '{"default":')).join("") + + '"./t.js"' + + Buffer.alloc(30000, "}").toString(), + }; + + it.concurrent.each(Object.entries(targets))("nested %s doesn't overflow the stack", async (shape, target) => { + using dir = tempDir(`resolver-exports-deep-${shape}`, { + "package.json": JSON.stringify({ name: "host" }), + "node_modules/test-pkg/package.json": `{"name":"test-pkg","exports":{".":${target}}}`, + "node_modules/test-pkg/t.js": "module.exports = 1;\n", + "index.js": ` + try { + require.resolve("test-pkg"); + console.log("resolved"); + } catch (e) { + console.log("caught " + e.code); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // The target resolves if the stack is large enough. + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: expect.stringMatching(/^(caught MODULE_NOT_FOUND|resolved)\n$/), + stderr: "", + exitCode: 0, + }); + }); +}); + // A package.json `imports` entry whose value is a bare package specifier // (e.g. `"#res": "@myproject/resolver"`) is handed back to package-resolve // for a second pass. Per the Node.js packages spec these are URL-like diff --git a/test/js/bun/s3/s3-moved-store.test.ts b/test/js/bun/s3/s3-moved-store.test.ts new file mode 100644 index 000000000000..721741f04bfd --- /dev/null +++ b/test/js/bun/s3/s3-moved-store.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +// `new Response([file])` moves the native store out of an S3 file. GC then frees the store along with the Response. +const prelude = ` +const key = "own-key-" + Buffer.alloc(200, "k"); +const client = new Bun.S3Client({ accessKeyId: "a", secretAccessKey: "b", bucket: "bucket", endpoint: process.env.S3_ENDPOINT }); +const file = client.file(key); +const move = () => void new Response([file]); +const others = []; +const moveAndFree = () => { + move(); + for (let i = 0; i < 4; i++) Bun.gc(true); + for (let i = 0; i < 8; i++) others.push(client.file("other-" + i + "-" + Buffer.alloc(200, "z"))); +}; +`; + +describe.concurrent("S3 file whose store moves into a Response", () => { + test.each([ + ["stat() rejects after the move", `move(); console.log(await file.stat().catch(e => e.code));`, "ERR_INVALID_THIS"], + [ + "unlink() deletes the file when an option getter moves the store", + `await file.unlink({ get accessKeyId() { move(); return "a"; } }); console.log("deleted");`, + "deleted", + ], + [ + "presign() signs the original key when an option getter moves and frees the store", + `console.log(new URL(file.presign({ get method() { moveAndFree(); return "GET"; } })).pathname === "/bucket/" + key);`, + "true", + ], + ])("%s", async (_, script, expected) => { + using server = Bun.serve({ + port: 0, + fetch: req => new Response(null, { status: req.method === "DELETE" ? 204 : 200 }), + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", prelude + script], + env: { ...bunEnv, S3_ENDPOINT: server.url.href }, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout.trim()).toBe(expected); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/bun/s3/s3-upload-abort.test.ts b/test/js/bun/s3/s3-upload-abort.test.ts index 21dd18bed5d4..4d17dbc93a15 100644 --- a/test/js/bun/s3/s3-upload-abort.test.ts +++ b/test/js/bun/s3/s3-upload-abort.test.ts @@ -220,3 +220,22 @@ test.concurrent("dropped writer with only buffered bytes lets the process exit", exited: 0, }); }); + +// The stream is unlocked when write() checks it. An option getter locks it before the upload starts. +test.concurrent("an upload that rejects because the stream is locked doesn't read from the stream", async () => { + const body = ` + const bytes = new TextEncoder().encode("hello"); + const stream = new ReadableStream({ type: "bytes", pull(c) { c.enqueue(bytes.slice()); c.close(); } }); + let reader; + const result = await client.file("key").write(stream, { + get accessKeyId() { reader ??= stream.getReader({ mode: "byob" }); return "key"; }, + }).then(() => "resolved", e => "rejected: " + e.code); + const { value } = await reader.read(new Uint8Array(16)); + console.log(result, new TextDecoder().decode(value)); + `; + expect(await run({ body, waitFor: `true` })).toEqual({ + stdout: `rejected: ERR_INVALID_STATE hello\n{"create":0,"part":0,"complete":0,"abort":0,"put":0}\n`, + stderr: "", + exited: 0, + }); +}); diff --git a/test/js/bun/s3/s3-upload-terminate.test.ts b/test/js/bun/s3/s3-upload-terminate.test.ts new file mode 100644 index 000000000000..9f29950ed546 --- /dev/null +++ b/test/js/bun/s3/s3-upload-terminate.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +// The S3 client fails new requests synchronously once the VM is shutting down. terminate() has to land while a worker +// is enqueuing a part, so several workers each start several uploads. +const WORKERS = 4; +const UPLOADS_PER_WORKER = 8; +// The minimum S3 part size. Each upload's first chunk is exactly one part. +const PART_SIZE = 5 * 1024 * 1024; + +const worker = /* js */ ` + const { parentPort, workerData } = require("node:worker_threads"); + const client = new Bun.S3Client({ + endpoint: workerData.endpoint, + bucket: "bucket", + accessKeyId: "key", + secretAccessKey: "secret", + region: "us-east-1", + }); + const chunk = new Uint8Array(${PART_SIZE}).fill(97); + for (let i = 0; i < ${UPLOADS_PER_WORKER}; i++) { + const stream = new ReadableStream({ pull(controller) { controller.enqueue(chunk); } }); + client + .write(workerData.key + "-" + i, new Response(stream), { partSize: ${PART_SIZE}, queueSize: 1, retry: 0 }) + .catch(() => {}); + } + parentPort.postMessage("started"); +`; + +const host = /* js */ ` + const { Worker } = require("node:worker_threads"); + const server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + if (req.method === "POST" && url.searchParams.has("uploads")) { + return new Response( + "<InitiateMultipartUploadResult><UploadId>upload-1</UploadId></InitiateMultipartUploadResult>", + { headers: { "content-type": "application/xml" } }, + ); + } + if (req.method === "PUT") { + try { await req.arrayBuffer(); } catch {} + return new Response("", { headers: { ETag: '"etag"' } }); + } + try { await req.text(); } catch {} + return new Response( + '<CompleteMultipartUploadResult><ETag>"etag-1"</ETag></CompleteMultipartUploadResult>', + { headers: { "content-type": "application/xml" } }, + ); + }, + }); + await Promise.all( + Array.from({ length: ${WORKERS} }, (_, i) => { + const w = new Worker(${JSON.stringify(worker)}, { + eval: true, + workerData: { endpoint: server.url.origin, key: "key-" + i }, + }); + return new Promise((resolve, reject) => { + w.once("message", resolve); + w.once("error", reject); + w.once("exit", code => reject(new Error("worker exited with code " + code + " before it started its uploads"))); + }).then(() => w.terminate()); + }), + ).catch(error => { + console.error(error); + process.exit(1); + }); + server.stop(true); + console.log("ok"); +`; + +test("terminate() while a multipart upload enqueues a part doesn't double free the part", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", host], + env: { + ...bunEnv, + HTTP_PROXY: undefined, + HTTPS_PROXY: undefined, + ALL_PROXY: undefined, + http_proxy: undefined, + https_proxy: undefined, + all_proxy: undefined, + // Uploads still open when their worker exits are never freed, and LeakSanitizer would report them. + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=0"].filter(Boolean).join(":"), + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr: stderr.trim() }).toEqual({ stdout: "ok", stderr: "" }); + expect(exitCode).toBe(0); +}); diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts index 16a4e10be530..4093aeb1c6f8 100644 --- a/test/js/bun/shell/bunshell.test.ts +++ b/test/js/bun/shell/bunshell.test.ts @@ -536,6 +536,22 @@ describe("bunshell", () => { expect(await file.text()).toEqual(thisFileText); }); + test("redirect stdin from a ReadableStream throws", async () => { + for (const cmd of ["cat", "echo", "true && cat", `${BUN} -e "process.stdin.pipe(process.stdout)"`]) { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("hello from a stream\n")); + controller.close(); + }, + }); + expect(async () => await $`${{ raw: cmd }} < ${stream}`.env(bunEnv).quiet()).toThrow( + "ReadableStream is not supported as a shell redirect yet", + ); + } + const stream = new Response("hello from a stream\n").body!; + expect(await $`cat < ${await new Response(stream).blob()}`.text()).toBe("hello from a stream\n"); + }); + // TODO This sometimes fails test("redirect stderr", async () => { const buffer = Buffer.alloc(128, 0); @@ -1235,6 +1251,13 @@ booga" const { stdout } = await $`{echo,a,b,c} {d,e,f}`; expect(stdout.toString()).toEqual("a b c d e f\n"); }); + + test("a word with more than 65535 tokens fails", async () => { + const word = "{" + Array(32768).fill("a").join(",") + "}"; + const { stderr, exitCode } = await $`echo ${{ raw: word }}`.quiet().nothrow(); + expect(stderr.toString()).toBe("bun: unexpected token while expanding braces\n"); + expect(exitCode).toBe(1); + }); }); describe("variables", () => { @@ -1584,6 +1607,25 @@ describe("deno_task", () => { TestBuilder.command`echo $(echo 1)`.stdout("1\n").runAsTest("nested echo cmd subst"); TestBuilder.command`echo $(echo 1 && echo 2)`.stdout("1 2\n").runAsTest("nested echo cmd subst with conditional"); // TODO Sleep tests + + describe("drops NUL bytes from the output", () => { + TestBuilder.command`echo "$(echo -e 'a\0b\0c')" $(echo -e 'a\0b c\0d')` + .stdout("abc ab cd\n") + .runAsTest("passed to echo"); + + TestBuilder.command`${BUN} run ./code.ts x $(echo -e '\0') y "$(echo -e '\0')" z` + .ensureTempDir() + .file("code.ts", `console.log(JSON.stringify(process.argv.slice(2)))`) + .stdout(out => expect(JSON.parse(out)).toEqual(["x", "y", "", "z"])) + .runAsTest("skips an unquoted NUL and passes a quoted NUL as an empty argument"); + + TestBuilder.command`${BUN} exec ${`echo hi > "$(echo -e 'f\\0zz')"; touch t1zzz; [[ -f $(echo -e 't1\\0zzz') ]] && echo yes || echo no`}` + .ensureTempDir() + .stdout("yes\n") + .fileEquals("fzz", "hi\n") + .doesNotExist("f") + .runAsTest("used as a redirect target or in a file test"); + }); }); describe("shell variables", async () => { @@ -3405,6 +3447,91 @@ test.skipIf(isWindows)( }, ); +test("a builtin stops writing to a redirect target that was resized to 0", async () => { + const ab = new ArrayBuffer(1 << 20, { maxByteLength: 1 << 21 }); + const buffer = new Uint8Array(ab); + // .then() starts the interpreter. `yes` writes its first batch and queues the next one. + const running = $`yes > ${buffer}` + .env(bunEnv) + .quiet() + .nothrow() + .then(o => o); + await Promise.resolve(); + ab.resize(0); + + const result = await running; + expect(result.stderr.toString()).toBe("yes: ENOSPC\n"); + expect(result.exitCode).toBe(1); +}); + +describe.each(["> ", "2>&1 "])("an external command with a `%s${buf}` redirect", redirect => { + test.skipIf(isWindows)("drops the output that arrives after the target was resized to 0", async () => { + const ab = new ArrayBuffer(1 << 20, { maxByteLength: 1 << 21 }); + const buffer = new Uint8Array(ab); + const gate = Promise.withResolvers<void>(); + const childWaiting = Promise.withResolvers<void>(); + await using server = Bun.serve({ + port: 0, + async fetch() { + childWaiting.resolve(); + await gate.promise; + return new Response("ok"); + }, + }); + // The second write waits for the fetch, so it arrives after the resize. + const childCode = ` + const chunk = Buffer.alloc(1 << 16, 0x41); + process.stdout.write(chunk); + await fetch(${JSON.stringify(String(server.url))}); + process.stdout.write(chunk); + `; + const running = $`${BUN} -e ${childCode} ${{ raw: redirect }}${buffer}` + .env(bunEnv) + .quiet() + .nothrow() + .then(o => o); + + await Promise.race([ + childWaiting.promise, + running.then(r => { + throw new Error(`command settled before the child connected (exit ${r.exitCode}): ${r.stderr}`); + }), + ]); + ab.resize(0); + gate.resolve(); + + const result = await running; + expect(result.stderr.toString()).toBe(""); + expect(result.exitCode).toBe(0); + }); +}); + +test("a builtin stops writing to a redirect target whose WebAssembly.Memory grew", async () => { + // Without fast memory, grow() moves the block. Without the Gigacage, the old block is unmapped. + const child = ` + const mem = new WebAssembly.Memory({ initial: 128, maximum: 132 }); + const target = new Uint8Array(mem.buffer); + const running = Bun.$\`yes > \${target}\`.quiet().nothrow().then(o => o); + await Promise.resolve(); + const wroteBeforeGrow = target[0] === 0x79 && target[1] === 0x0a; + mem.grow(4); + const result = await running; + console.log(JSON.stringify({ wroteBeforeGrow, exitCode: result.exitCode, stderr: result.stderr.toString() })); + `; + await using proc = Bun.spawn({ + cmd: [BUN, "-e", child], + env: { ...bunEnv, BUN_JSC_useWasmFastMemory: "0", Malloc: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr }).toEqual({ + stdout: JSON.stringify({ wroteBeforeGrow: true, exitCode: 1, stderr: "yes: ENOSPC\n" }), + stderr: "", + }); + expect(exitCode).toBe(0); +}); + test("stdin redirect from a Uint8Array sends the bytes captured when the command starts", async () => { // `< ${buf}` snapshots the buffer's contents when the command starts and // streams them to the child's stdin across multiple event-loop turns. @@ -3564,6 +3691,37 @@ test("cd treats interpolated arguments starting with tilde or dash as literal di } }); +test.each([ + ["ASCII", "a\0b"], + ["Latin-1", "caf\xe9\0b"], + ["UTF-16", "\u4e2d\0b"], +])(".cwd() and .env() reject a %s string with a NUL byte", (_, value) => { + const error = (name: string) => ({ + code: "ERR_INVALID_ARG_VALUE", + message: `The shell ${name} must be a string without null bytes. Received ${JSON.stringify(value)}`, + }); + expect(() => $`true`.cwd(value)).toThrow(error("cwd")); + expect(() => $`true`.env({ [value]: "1" })).toThrow(error("env key")); + expect(() => $`true`.env({ X: value })).toThrow(error("env value")); + expect(() => new $.Shell().cwd(value)`true`).toThrow(error("cwd")); +}); + +test(".env() rejects a NUL byte at any offset in an ASCII string and copies clean strings unchanged", async () => { + const env: Record<string, string> = { LATIN1: "caf\xe9", UTF16: "\u4e2d", EMPTY: "" }; + const error = expect.objectContaining({ code: "ERR_INVALID_ARG_VALUE" }); + for (const length of [1, 7, 8, 9, 17, 33, 129, 200]) { + const clean = Buffer.alloc(length, "\x01a~\x7f").latin1Slice(); + env["V" + length] = clean; + for (const at of length > 33 ? [0, 64, length - 9, length - 1] : Array(length).keys()) { + const value = clean.slice(0, at) + "\0" + clean.slice(at + 1); + expect(() => $`true`.env({ X: value })).toThrow(error); + expect(() => $`true`.env({ [value]: "1" })).toThrow(error); + } + } + const raw = `echo "[$${Object.keys(env).join("][$")}]"`; + expect(await $`${{ raw }}`.env(env).text()).toBe(`[${Object.values(env).join("][")}]\n`); +}); + describe("literal digits after an interpolated value", () => { TestBuilder.command`echo ${"a b"}0` .stdout("a b0\n") @@ -3607,3 +3765,62 @@ test.skipIf(isWindows)("external command resolution uses the PATH from the shell expect(exitCode).toBe(0); } }); + +// Windows joins each file operand onto the shell's cwd in one path buffer. POSIX passes operands to the *at() syscalls. +test.if(isWindows)("operands longer than the path buffer fail with ENAMETOOLONG", async () => { + using dir = tempDir("shell-long-operand", { "in.txt": "content\n" }); + const script = ` + import { $ } from "bun"; + $.nothrow(); + $.cwd(process.env.SHELL_CWD); + const long = Buffer.alloc(100_000, "a").toString(); + // Too long as written, but normalizes to a short name inside the cwd. + const collapsing = Buffer.alloc(100_000, "a/../").toString(); + const show = buf => buf.toString().replaceAll(long, "<long>").replaceAll(collapsing, "<collapsing>"); + const run = async promise => { + const { exitCode, stdout, stderr } = await promise.quiet(); + return { exitCode, stdout: show(stdout), stderr: show(stderr) }; + }; + console.log( + JSON.stringify({ + cat: await run($\`cat \${long}\`), + redirect: await run($\`echo hi > \${long}\`), + isFile: await run($\`[[ -f \${long} ]]\`), + rootedCat: await run($\`cat \${"/" + long}\`), + rootedLs: await run($\`ls \${"/" + long}\`), + rootedMvTarget: await run($\`mv in.txt \${"/" + long}\`), + rootedRedirect: await run($\`echo hi > \${"/" + long}\`), + rootedIsDir: await run($\`[[ -d \${"/" + long} ]]\`), + collapsingCat: await run($\`cat \${collapsing + "in.txt"}\`), + collapsingIsFile: await run($\`[[ -f \${collapsing + "in.txt"} ]]\`), + collapsingRedirect: await run($\`echo hi > \${collapsing + "out.txt"}\`), + }), + ); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: { ...bunEnv, SHELL_CWD: String(dir) }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + const failed = (stderr: string) => ({ exitCode: 1, stdout: "", stderr }); + const succeeded = (stdout: string) => ({ exitCode: 0, stdout, stderr: "" }); + expect(JSON.parse(stdout)).toEqual({ + cat: failed("cat: <long>: File name too long\n"), + redirect: failed("bun: File name too long: <long>"), + isFile: failed(""), + rootedCat: failed("cat: /<long>: File name too long\n"), + rootedLs: failed("ls: /<long>: File name too long\n"), + rootedMvTarget: failed("mv: /<long>: File name too long\n"), + rootedRedirect: failed("bun: File name too long: /<long>"), + rootedIsDir: failed(""), + collapsingCat: succeeded("content\n"), + collapsingIsFile: succeeded(""), + collapsingRedirect: succeeded(""), + }); + expect(await Bun.file(join(String(dir), "in.txt")).text()).toBe("content\n"); + expect(await Bun.file(join(String(dir), "out.txt")).text()).toBe("hi\n"); + expect(exitCode).toBe(0); +}); diff --git a/test/js/bun/shell/commands/cat.test.ts b/test/js/bun/shell/commands/cat.test.ts new file mode 100644 index 000000000000..e7781f76abc6 --- /dev/null +++ b/test/js/bun/shell/commands/cat.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; +import { closeSync, openSync } from "node:fs"; + +// On POSIX `cat` is the system binary unless this flag is set. On Windows the builtin is the default. +const builtinEnv = { ...bunEnv, BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS: "1" }; + +// Runs `script` in a child and returns its output, which ends with an `exit=<code>` line unless the child hung or crashed. +async function runScript(script: string, stdin: string | number, quiet: boolean) { + const run = `Bun.$\`\${{ raw: ${JSON.stringify(script)} }}\`.nothrow()`; + const trailer = `process.stdout.write("exit=" + r.exitCode + "\\n");`; + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + quiet + ? `const r = await ${run}.quiet(); process.stdout.write(r.stdout); ${trailer}` + : `const r = await ${run}; ${trailer}`, + ], + env: builtinEnv, + stdin: typeof stdin === "number" ? stdin : "pipe", + stdout: "pipe", + stderr: "pipe", + }); + if (typeof stdin === "string") { + proc.stdin!.write(stdin); + proc.stdin!.end(); + } + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +// Every `cat` reading the same stdin registers with one shared reader. +describe.concurrent("cat (builtin) sharing one stdin reader", () => { + test.each([ + ["cat; echo ---; cat", "hi\n", true, "hi\n---\n"], + // With inherited stdout, a command can also finish in a write callback after the EOF callback returned. + ["cat; echo ---; cat", "hi\n", false, "hi\n---\n"], + ["cat; cat; cat; cat; cat; cat; echo ---", "hi\n", true, "hi\n---\n"], + // The subshell reuses the first cat's node id, so a stale listener would call into a node that isn't a cat. + ["cat; (echo ---; cat)", "hi\n", true, "hi\n---\n"], + ["echo hi | (cat; echo ---; cat)", "", true, "hi\n---\n"], + ] as const)("a later cat reads EOF: %s (stdin %j, quiet %p)", async (script, input, quiet, expected) => { + expect(await runScript(script, input, quiet)).toEqual({ stdout: `${expected}exit=0\n`, stderr: "", exitCode: 0 }); + }); + + test("a later cat also fails after the first cat failed to read", async () => { + // read() fails on a directory. + const fd = openSync(import.meta.dir, "r"); + try { + const script = "cat || echo first-failed; (echo ---; cat) || echo second-failed"; + expect(await runScript(script, fd, true)).toEqual({ + stdout: "first-failed\n---\nsecond-failed\nexit=0\n", + stderr: "", + exitCode: 0, + }); + } finally { + closeSync(fd); + } + }); +}); diff --git a/test/js/bun/shell/commands/cp.test.ts b/test/js/bun/shell/commands/cp.test.ts index 80a28d2c7e2c..c5505bc8a9a0 100644 --- a/test/js/bun/shell/commands/cp.test.ts +++ b/test/js/bun/shell/commands/cp.test.ts @@ -1,7 +1,9 @@ import { $ } from "bun"; import { shellInternals } from "bun:internal-for-testing"; -import { describe, expect } from "bun:test"; -import { tempDirWithFiles } from "harness"; +import { describe, expect, test } from "bun:test"; +import { bunEnv, isMacOS, isWindows, tempDir, tempDirWithFiles } from "harness"; +import { mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { join, sep } from "node:path"; import { bunExe, createTestBuilder } from "../test_builder"; import { sortedShellOutput } from "../util"; const { builtinDisabled } = shellInternals; @@ -173,6 +175,155 @@ describe.if(!builtinDisabled("cp"))("bunshell cp", async () => { }); }); +// Runs in a child process because the cp builtin must be enabled explicitly on POSIX. +test("operands longer than the path buffers fail with ENAMETOOLONG", async () => { + using dir = tempDir("cp-long-operands", { + "f": "source", + "target": {}, + "fixture.ts": /* ts */ ` + import { $ } from "bun"; + import { existsSync, mkdirSync, writeFileSync } from "node:fs"; + import { join } from "node:path"; + + $.nothrow(); + const cwd = process.cwd(); + // Longer than the path buffer on every platform. + const over = Buffer.alloc(100_000, "a").toString(); + const placeholders: [string, string][] = [[over, "<over>"], [cwd, "<cwd>"]]; + + async function cp(...args: string[]) { + const { exitCode, stderr } = await $\`cp \${args}\`.quiet(); + let message = stderr.toString(); + for (const [value, placeholder] of placeholders) message = message.replaceAll(value, placeholder); + return { exitCode, stderr: message }; + } + + const results: Record<string, unknown> = {}; + results.longSource = await cp(over, "target"); + results.longTarget = await cp("f", over); + results.longSourceAmongOthers = { ...(await cp("f", over, "target")), otherCopied: existsSync("target/f") }; + + // Copying into a directory appends the basename. Overflowing that join needs an existing directory + // just below the limit, which Windows can't create. + if (process.platform !== "win32") { + const PATH_MAX = process.platform === "linux" ? 4096 : 1024; + const segment = Buffer.alloc(200, "d").toString(); + let deep = join(cwd, segment); + while (join(deep, segment).length + 2 < PATH_MAX) deep = join(deep, segment); + mkdirSync(deep, { recursive: true }); + placeholders.unshift([deep, "<deep>"]); + + // \`deep/name\` is PATH_MAX bytes, one more than the OS accepts. + const length = PATH_MAX - deep.length - 1; + const file = Buffer.alloc(length, "t").toString(); + const folder = Buffer.alloc(length, "u").toString(); + placeholders.unshift([file, "<file>"], [folder, "<folder>"]); + writeFileSync(file, "one past"); + mkdirSync(folder); + results.intoDeepDir = await cp(file, deep); + results.recursiveIntoDeepDir = await cp("-R", folder, deep); + } + + console.log(JSON.stringify(results)); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.ts"], + env: { ...bunEnv, BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS: "1" }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + + const tooLong = (path: string) => ({ exitCode: 1, stderr: `cp: File name too long: ${p(path)}\n` }); + expect(JSON.parse(stdout)).toEqual({ + longSource: tooLong("<cwd>/<over>"), + longTarget: tooLong("<cwd>/<over>"), + longSourceAmongOthers: { ...tooLong("<cwd>/<over>"), otherCopied: true }, + ...(isWindows ? {} : { intoDeepDir: tooLong("<deep>/<file>"), recursiveIntoDeepDir: tooLong("<deep>/<folder>") }), + }); + expect(exitCode).toBe(0); +}); + +// The child force-enables the builtin, so these also run on POSIX. +describe.concurrent("bunshell cp src/dest validation", () => { + async function cp(cwd: string, command: string) { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const r = await Bun.$\`${command}\`.nothrow().quiet(); process.stdout.write(r.stderr); process.exit(r.exitCode);`, + ], + cwd, + env: { ...bunEnv, BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS: "1" }, + stdout: "pipe", + stderr: "inherit", + }); + const [stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + return { stderr, exitCode }; + } + + const tree = { d: { f: "hi", sub: {} }, e: { g: "there" } }; + const list = (dir: unknown, sub: string) => readdirSync(join(String(dir), sub)).sort(); + + test("cp -R dir dir/sub fails and still copies the other sources", async () => { + using dir = tempDir("cp-self", tree); + expect(await cp(String(dir), "cp -R e d d/sub")).toEqual({ + stderr: `cp: cannot copy a directory, 'd', into itself, 'd/sub${sep}d'\n`, + exitCode: 1, + }); + expect(list(dir, "d/sub")).toEqual(["e"]); + }); + + test("cp -R dir . fails when it would copy dir onto itself", async () => { + using dir = tempDir("cp-self", tree); + expect(await cp(String(dir), "cp -R d .")).toEqual({ + stderr: `cp: d and .${sep}d are identical (not copied)\n`, + exitCode: 1, + }); + expect(list(dir, "d")).toEqual(["f", "sub"]); + }); + + // Directory symlinks need extra privileges on Windows. + test.skipIf(isWindows)("cp -R dir link-to-dir fails", async () => { + using dir = tempDir("cp-self", tree); + symlinkSync("d", join(String(dir), "link")); + expect(await cp(String(dir), "cp -R d link")).toEqual({ + stderr: `cp: cannot copy a directory, 'd', into itself, 'link/d'\n`, + exitCode: 1, + }); + expect(list(dir, "d")).toEqual(["f", "sub"]); + }); + + test("copies to a sibling whose name starts with the source's name", async () => { + using dir = tempDir("cp-self", tree); + expect(await cp(String(dir), "cp -R d d2")).toEqual({ stderr: "", exitCode: 0 }); + expect(list(dir, "d2")).toEqual(["f", "sub"]); + }); + + // The path is too long for macOS (PATH_MAX is 1024), and Windows fails to create it. + test.skipIf(isMacOS || isWindows)("copies a very deep tree", async () => { + const depth = 800; + using dir = tempDir("cp-self", {}); + const levels = [""]; + for (let i = 0; i < depth; i++) levels.push(join(levels[i], "a")); + const at = (root: string, level: number) => join(String(dir), root, levels[level]); + mkdirSync(at("deep", depth), { recursive: true }); + writeFileSync(join(at("deep", depth), "leaf"), "leaf"); + try { + expect(await cp(String(dir), "cp -R deep out")).toEqual({ stderr: "", exitCode: 0 }); + expect(readFileSync(join(at("out", depth), "leaf"), "utf8")).toBe("leaf"); + } finally { + // Delete bottom-up so disposing `dir` doesn't have to walk a tree this deep. + for (const root of ["deep", "out"]) { + for (let i = depth; i >= 0; i--) rmSync(at(root, i), { recursive: true, force: true }); + } + } + }); +}); + function expectSortedOutput(expected: string) { return (stdout: string, tempdir: string) => expect(sortedShellOutput(stdout).join("\n")).toEqual( diff --git a/test/js/bun/shell/commands/mkdir.test.ts b/test/js/bun/shell/commands/mkdir.test.ts index 8fd48977b24e..638344c25648 100644 --- a/test/js/bun/shell/commands/mkdir.test.ts +++ b/test/js/bun/shell/commands/mkdir.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { bunEnv, bunExe, isDebug, isWindows, tempDir } from "harness"; import { join } from "node:path"; // A relative operand was joined onto the cwd in a fixed 4096-byte buffer, so an @@ -75,3 +75,36 @@ test("operands longer than the path buffers are reported, not a crash", async () }); expect(exitCode).toBe(0); }); + +// Each failing operand queues two writes, so 32,768 of them would overflow a 16-bit counter. +// Skipped on debug builds, which need 10 to 20 seconds for that many thread-pool tasks. +test.skipIf(isDebug)( + "mkdir reports all 32,768 failing operands and the commands after it still print their output", + async () => { + using dir = tempDir("mkdir-output-root", {}); + // The shell expands 15 brace groups to 32,768 operands. Each one fails because "nope" doesn't exist. + const failingOperands = "nope/" + Buffer.alloc(15 * 5, "{0,1}").toString(); + const fixture = /* ts */ ` + import { $ } from "bun"; + const result = await $\`mkdir ${failingOperands} || echo AFTER-1; echo AFTER-2 | cat; echo AFTER-3\`.nothrow(); + console.log("EXIT:" + result.exitCode); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const reported = stderr.split("\n").flatMap(line => /nope[\\/](\d+)/.exec(line)?.[1] ?? []); + expect({ + operands: new Set(reported).size, + stdout: stdout.split("\n").filter(Boolean), + }).toEqual({ + operands: 32768, + stdout: ["AFTER-1", "AFTER-2", "AFTER-3", "EXIT:0"], + }); + expect(exitCode).toBe(0); + }, +); diff --git a/test/js/bun/shell/commands/mv-into-itself-fixture.ts b/test/js/bun/shell/commands/mv-into-itself-fixture.ts new file mode 100644 index 000000000000..0e663a8c0d28 --- /dev/null +++ b/test/js/bun/shell/commands/mv-into-itself-fixture.ts @@ -0,0 +1,70 @@ +// Runs in a private mount namespace (see mv.test.ts), so its mounts are gone when this process exits. +// argv[2] is an empty directory. Prints one JSON line per layout: [layout name, result]. +import { $ } from "bun"; +import { mkdirSync, readdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +$.nothrow(); + +const root = process.argv[2]; + +function mount(...args: string[]) { + const { exitCode, stderr } = Bun.spawnSync({ cmd: ["mount", ...args], stderr: "pipe" }); + if (exitCode !== 0) throw new Error(`mount ${args.join(" ")}: ${stderr.toString()}`); +} + +// With 64 inodes, a runaway copy fails with ENOSPC after a few dozen levels. +const tmpfs = (at: string) => mount("-t", "tmpfs", "-o", "nr_inodes=64,size=1m", "tmpfs", at); +const bind = (from: string, at: string) => mount("--bind", from, at); + +function make(cwd: string, dirs: string[], files: string[] = []) { + for (const dir of dirs) mkdirSync(join(cwd, dir), { recursive: true }); + for (const file of files) writeFileSync(join(cwd, file), file); +} + +const layouts: Record<string, (cwd: string) => Promise<unknown>> = { + // `mv * out/` where `out` is a mount point. + async mountPointOperand(cwd) { + make(cwd, ["out"], ["f1", "f2"]); + tmpfs(join(cwd, "out")); + const r = await $`mv f1 f2 out out/`.cwd(cwd).quiet(); + return { stderr: r.stderr.toString(), tree: (readdirSync(cwd, { recursive: true }) as string[]).sort() }; + }, + + // The destination is a new path on another file system mounted inside the source. + async mountInsideNewPath(cwd) { + make(cwd, ["a"]); + tmpfs(join(cwd, "a")); + make(cwd, ["a/sub/mnt"], ["a/sub/top"]); + tmpfs(join(cwd, "a/sub/mnt")); + const r = await $`mv a/sub a/sub/mnt/x`.cwd(cwd).quiet(); + return { stderr: r.stderr.toString(), tree: (readdirSync(join(cwd, "a"), { recursive: true }) as string[]).sort() }; + }, + + // `b` is a bind mount of a directory inside `a/sub`, so `b`'s parents never reach `a/sub`. + // Only walking `a/sub` finds the destination. + async mountOfChild(cwd) { + make(cwd, ["a", "b"]); + tmpfs(join(cwd, "a")); + make(cwd, ["a/sub/inner"], ["a/sub/inner/deep"]); + bind(join(cwd, "a/sub/inner"), join(cwd, "b")); + const r = await $`mv a/sub b`.cwd(cwd).quiet(); + return { stderr: r.stderr.toString() }; + }, + + // A valid move. Both mounts are the same file system, so every entry has the destination's `st_dev`. + async betweenTwoMounts(cwd) { + make(cwd, ["a", "b"]); + tmpfs(join(cwd, "a")); + bind(join(cwd, "a"), join(cwd, "b")); + make(cwd, ["a/sub/deep", "a/x"], ["a/sub/file", "a/sub/deep/file"]); + const r = await $`mv a/sub b/x`.cwd(cwd).quiet(); + return { stderr: r.stderr.toString(), tree: (readdirSync(join(cwd, "a"), { recursive: true }) as string[]).sort() }; + }, +}; + +for (const [name, run] of Object.entries(layouts)) { + const cwd = join(root, name); + mkdirSync(cwd); + console.log(JSON.stringify([name, await run(cwd)])); +} diff --git a/test/js/bun/shell/commands/mv.test.ts b/test/js/bun/shell/commands/mv.test.ts index 4d6d532fada8..13fbf54da339 100644 --- a/test/js/bun/shell/commands/mv.test.ts +++ b/test/js/bun/shell/commands/mv.test.ts @@ -1,6 +1,6 @@ import { $ } from "bun"; import { describe, expect, test } from "bun:test"; -import { isPosix, isRoot } from "harness"; +import { bunEnv, bunExe, isLinux, isPosix, isRoot, isWindows, tempDir } from "harness"; import { accessSync, chmodSync, @@ -66,6 +66,50 @@ describe("mv", async () => { .stderr("mv: a: Not a directory\n") .runAsTest("move dir -> file fails"); + test("fails when a source name is longer than the path buffers", async () => { + const segment = Buffer.alloc(240, "d").toString(); + const deep = join(segment, segment); + using dir = tempDir("mv-long-source", { "target": {}, [segment]: { [segment]: {} } }); + // Longer than PATH_MAX on every POSIX platform. + const overPathMax = Buffer.alloc(5000, "a").toString(); + // Shorter than the Linux path buffer, but `deep/` + this name overflows the 4096-byte join buffer. + const overJoinBuffer = Buffer.alloc(3700, "b").toString(); + + const fixture = /* ts */ ` + import { $ } from "bun"; + $.nothrow(); + const { OVER_PATH_MAX, OVER_JOIN_BUFFER, DEEP } = process.env; + const run = async (...args: string[]) => { + const { exitCode, stderr } = await $\`mv \${args}\`.quiet(); + return { failed: exitCode !== 0, stderr: stderr.toString() }; + }; + console.log( + JSON.stringify({ + intoDir: await run(OVER_PATH_MAX!, "target"), + intoDeepDir: await run(OVER_JOIN_BUFFER!, DEEP!), + }), + ); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: { ...bunEnv, OVER_PATH_MAX: overPathMax, OVER_JOIN_BUFFER: overJoinBuffer, DEEP: deep }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + + // Windows reports a different errno for a name that's too long. + const tooLong = (path: string) => + isWindows ? expect.stringMatching(/^mv: /) : `mv: ${path}: File name too long\n`; + expect(JSON.parse(stdout)).toEqual({ + intoDir: { failed: true, stderr: tooLong(`target/${overPathMax}`) }, + intoDeepDir: { failed: true, stderr: tooLong(`${deep}/${overJoinBuffer}`) }, + }); + expect(exitCode).toBe(0); + }); + // POSIX `mv` must fall back to copy+unlink when `rename()` returns EXDEV // (source and destination on different filesystems). Requires a writable // mount on a different device from the harness temp dir. @@ -232,5 +276,51 @@ describe("mv", async () => { rmSync(dst, { recursive: true, force: true }); } }); + + // The fixture creates its mounts in a private mount namespace, either as root or in a user namespace. + function findUnshare(): string[] | undefined { + const unshare = isLinux ? Bun.which("unshare") : null; + if (!unshare) return undefined; + using probe = tempDir("mv-unshare-probe", { a: {}, b: {} }); + const mounts = 'mount -t tmpfs -o size=1m tmpfs "$1" && mount --bind "$1" "$2"'; + for (const namespaces of ["-Urm", "-m"]) { + const cmd = [unshare, namespaces, "--propagation", "private"]; + const { exitCode } = Bun.spawnSync({ + cmd: [...cmd, "sh", "-c", mounts, "sh", join(String(probe), "a"), join(String(probe), "b")], + stdout: "ignore", + stderr: "ignore", + }); + if (exitCode === 0) return cmd; + } + return undefined; + } + const unshare = findUnshare(); + + // rename(2) only rejects moving a directory into itself when both paths are on the same mount. + test.skipIf(!unshare)("doesn't copy a directory into itself across mounts", async () => { + using dir = tempDir("mv-with-mounts", {}); + await using proc = Bun.spawn({ + cmd: [...unshare!, bunExe(), join(import.meta.dir, "mv-into-itself-fixture.ts"), String(dir)], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect( + Object.fromEntries( + stdout + .split("\n") + .filter(Boolean) + .map(line => JSON.parse(line)), + ), + ).toEqual({ + mountPointOperand: { stderr: "mv: out/out: Invalid argument\n", tree: ["out", "out/f1", "out/f2"] }, + mountInsideNewPath: { stderr: "mv: a/sub: Invalid argument\n", tree: ["sub", "sub/mnt", "sub/top"] }, + mountOfChild: { stderr: "mv: b/sub: Invalid argument\n" }, + betweenTwoMounts: { stderr: "", tree: ["x", "x/sub", "x/sub/deep", "x/sub/deep/file", "x/sub/file"] }, + }); + expect(exitCode).toBe(0); + }); }); }); diff --git a/test/js/bun/shell/parse.test.ts b/test/js/bun/shell/parse.test.ts index 2da7a674bc12..ac77b69d54b6 100644 --- a/test/js/bun/shell/parse.test.ts +++ b/test/js/bun/shell/parse.test.ts @@ -1,4 +1,5 @@ import { shellInternals } from "bun:internal-for-testing"; +import { bunEnv, bunExe } from "harness"; import { createTestBuilder, redirect } from "./util"; const { parse } = shellInternals; const TestBuilder = createTestBuilder(import.meta.path); @@ -1067,4 +1068,30 @@ describe("parse shell invalid input", () => { await TestBuilder.command`echo foo >`.error("Redirection with no file").run(); }); + + test("throws when if clauses are nested too deeply", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const depth = 20000; + const src = "if true; then ".repeat(depth) + "echo x" + "; fi".repeat(depth); + try { + Bun.$\`\${{ raw: src }}\`; + console.log("parsed"); + } catch (e) { + console.log("error:", e.message); + } + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("error: Nesting depth exceeded\n"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); }); diff --git a/test/js/bun/shell/shell-pipe-read-fault.test.ts b/test/js/bun/shell/shell-pipe-read-fault.test.ts index cd0c6fc7316d..594d4e8b150e 100644 --- a/test/js/bun/shell/shell-pipe-read-fault.test.ts +++ b/test/js/bun/shell/shell-pipe-read-fault.test.ts @@ -3,7 +3,7 @@ // surfaces synchronously from inside the spawn call. The command must finish // with the syscall errno as its exit code, not tear state down under the spawn. import { afterAll, beforeAll, expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isLinux, tempDir } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, isLinux, isWindows, tempDir } from "harness"; import { join } from "node:path"; const cc = Bun.which("cc") || Bun.which("gcc") || Bun.which("clang"); @@ -576,3 +576,29 @@ test.concurrent.skipIf(!isLinux || !cc)( }); }, ); + +// JS can't make uv_read_start fail, so this uses the same debug-only fault injection as spawn-pipe-start-error.test.ts. +test.concurrent.skipIf(!isWindows || !isDebug)( + "the command exits with the errno when uv_read_start fails on its stdout and stderr pipes", + async () => { + const fixture = /* js */ ` +import { $ } from "bun"; +const r = await $\`\${process.execPath} -e 1\`.quiet().nothrow(); +// Wait for a second child so the first child's exit callback has run. Its stdio is ignored, so it has no pipe reader. +await Bun.spawn({ cmd: [process.execPath, "-e", "1"], stdio: ["ignore", "ignore", "ignore"] }).exited; +console.log(JSON.stringify({ exitCode: r.exitCode, stderr: r.stderr.toString() })); +`; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: { ...bunEnv, BUN_INTERNAL_FAIL_PIPE_READER_START: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: JSON.stringify({ exitCode: 22, stderr: "" }) + "\n", + stderr: "", + exitCode: 0, + }); + }, +); diff --git a/test/js/bun/sourcemap/external-sourcemap-stack-leak.test.ts b/test/js/bun/sourcemap/external-sourcemap-stack-leak.test.ts index 6f538023933c..dc2272f73589 100644 --- a/test/js/bun/sourcemap/external-sourcemap-stack-leak.test.ts +++ b/test/js/bun/sourcemap/external-sourcemap-stack-leak.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, MAX_PATH_BYTES, tempDir } from "harness"; +import { join } from "node:path"; // Formatting a stack trace hands one ZigStackFrame per frame to // Bun__remapStackFramePositions. When the frame's file has an external source @@ -125,3 +126,32 @@ describe.concurrent("error.stack with an external source map does not leak the r CHILD_TIMEOUT_MS, ); }); + +// A frame's path is the `sources` entry joined onto the file's directory. Without `sourcesContent`, the source preview +// for an uncaught error reads that path from disk. +test("prints the stack trace when a `sources` entry is longer than a path buffer", async () => { + const sourceName = Buffer.alloc(MAX_PATH_BYTES + 104, "x").toString() + ".ts"; + const code = `// @bun\nfunction t() {\n return new Error("LONGSRC");\n}\nthrow t();\n//# sourceMappingURL=entry.js.map\n`; + using dir = tempDir("sourcemap-long-source", { + "entry.js": code, + "entry.js.map": JSON.stringify({ + version: 3, + sources: [sourceName], + sourcesContent: [""], + names: [], + mappings: "AAAA" + ";AACA".repeat(code.split("\n").length - 1), + }), + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe(""); + expect(stderr).toContain("LONGSRC"); + expect(stderr).toContain(`at t (${join(String(dir), sourceName)}:`); + expect(exitCode).toBe(1); +}); diff --git a/test/js/bun/sourcemap/internal-sourcemap-roundtrip.test.ts b/test/js/bun/sourcemap/internal-sourcemap-roundtrip.test.ts index 3eecfb491cd1..93eb4d988a55 100644 --- a/test/js/bun/sourcemap/internal-sourcemap-roundtrip.test.ts +++ b/test/js/bun/sourcemap/internal-sourcemap-roundtrip.test.ts @@ -293,6 +293,102 @@ describe("InternalSourceMap.toVLQ", () => { }); }); +describe("InternalSourceMap with a corrupt body", () => { + // Only the outer header is validated when a blob is read back from disk, so find() and toVLQ() + // must bounds-check every offset and length they read from the body. + // + // Builds 72 mappings, 8 per line. Window 0 has the first 64 and window 1 has the rest. + // The 3-line gap and the second source give window 0 both optional sections + // (gen-line exceptions, src-idx). Window 1 has neither. + function buildTwoWindowVLQ(): string { + let out = ""; + let prev = { col: 0, src: 0, origLine: 0, origCol: 0 }; + for (let line = 0; line < 9; line++) { + for (let k = 0; k < 8; k++) { + const cur = { col: k * 5, src: line < 4 ? 0 : 1, origLine: line * 2, origCol: k * 7 + (k & 1) }; + if (k > 0) out += ","; + out += + encodeVLQ(cur.col - prev.col) + + encodeVLQ(cur.src - prev.src) + + encodeVLQ(cur.origLine - prev.origLine) + + encodeVLQ(cur.origCol - prev.origCol); + prev = cur; + } + out += line === 2 ? ";;;" : ";"; + prev.col = 0; + } + return out; + } + + test.concurrent("find() and toVLQ() don't read out of bounds", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { internalSourceMap } = require("bun:internal-for-testing"); + const pristine = internalSourceMap.fromVLQ(process.env.VLQ); + const total = pristine.length; + const header = new DataView(pristine.buffer); + const streamOffset = header.getUint32(28, true); + console.log("windows " + header.getUint32(24, true)); + // A column past every mapping makes the reader scan to the end of the line. + function probe(blob) { + let found = 0; + for (let line = 0; line < 12; line++) { + if (internalSourceMap.find(blob, line, 0x7fffffff) !== null) found++; + } + return found; + } + console.log("pristine " + probe(pristine)); + + // Corrupt a whole section. Every window is now out of bounds, so every + // lookup returns null. toVLQ() stops at the first unreadable window. For + // stream_80 it has already emitted that window's seed mapping. + const fills = { + stream_offset_last: b => new DataView(b.buffer).setUint32(28, total - 1, true), + sync_ff: b => b.fill(0xff, 32, streamOffset), + stream_ff: b => b.fill(0xff, streamOffset), + stream_80: b => b.fill(0x80, streamOffset), + }; + for (const [name, damage] of Object.entries(fills)) { + const blob = pristine.slice(); + damage(blob); + console.log(name + " " + probe(blob) + " " + JSON.stringify(internalSourceMap.toVLQ(blob))); + } + + // Corrupt one byte at every body offset. The byte may still decode, so + // the lookup's result is unspecified. It only has to return. + let blobs = 0; + for (let at = 32; at < total; at++) { + for (const value of [0x00, 0x80, 0xff]) { + if (pristine[at] === value) continue; + const blob = pristine.slice(); + blob[at] = value; + probe(blob); + blobs++; + } + } + console.log("swept " + (blobs > 500));`, + ], + env: { ...bunEnv, VLQ: buildTwoWindowVLQ() }, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout.split("\n")).toEqual([ + "windows 2", + "pristine 9", + 'stream_offset_last 0 ""', + 'sync_ff 0 ""', + 'stream_ff 0 ""', + 'stream_80 0 "AAAA"', + "swept true", + "", + ]); + expect(exitCode).toBe(0); + }); +}); + describe("InternalSourceMap round-trip", () => { test("large blank-line run and many mappings re-encode byte-identically", () => { // append_vlq_to() reserves mapping_count * 6 bytes upfront and writes diff --git a/test/js/bun/spawn/bun-ipc-inherit.test.ts b/test/js/bun/spawn/bun-ipc-inherit.test.ts index 00a4ca8988c0..20ac5de46797 100644 --- a/test/js/bun/spawn/bun-ipc-inherit.test.ts +++ b/test/js/bun/spawn/bun-ipc-inherit.test.ts @@ -1,6 +1,6 @@ import { spawn } from "bun"; import { expect, it } from "bun:test"; -import { bunExe, isWindows, tmpdirSync } from "harness"; +import { bunEnv, bunExe, isWindows, tmpdirSync } from "harness"; import fs from "node:fs/promises"; import path from "path"; @@ -28,3 +28,17 @@ it.todoIf(isWindows)("spawning a bun package script should inherit the ipc fd", await child.exited; expect(testMessage).toBe("hello"); }); + +it.skipIf(isWindows)("bun exec doesn't pass NODE_CHANNEL_FD to a command that doesn't inherit the ipc fd", async () => { + await using proc = spawn({ + // Print the variables from sh, because bun and node delete them at startup. + cmd: [bunExe(), "exec", `sh -c 'echo "[$NODE_CHANNEL_FD] [$NODE_CHANNEL_SERIALIZATION_MODE]"'`], + env: bunEnv, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + ipc() {}, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "[] []\n", stderr: "", exitCode: 0 }); +}); diff --git a/test/js/bun/spawn/spawn-stdin-readable-stream.test.ts b/test/js/bun/spawn/spawn-stdin-readable-stream.test.ts index 022f29374dda..5e8249419967 100644 --- a/test/js/bun/spawn/spawn-stdin-readable-stream.test.ts +++ b/test/js/bun/spawn/spawn-stdin-readable-stream.test.ts @@ -868,6 +868,138 @@ describe("spawn stdin ReadableStream", () => { }, ); + // Bun.spawn fails if the stdin stream is already locked, and it must not consume or modify that stream. + describe("a locked stdin stream is still readable by the consumer that locked it", () => { + const prelude = ` + const bytes = new TextEncoder().encode("hello"); + const decode = chunk => new TextDecoder().decode(chunk); + // For a locked stream, either Bun.spawn throws or its stdin pump rejects. The last printed line shows which. + const refusals = []; + const threw = error => refusals.push("threw " + error?.code); + process.on("unhandledRejection", error => refusals.push("unhandledRejection " + error?.code)); + process.on("exit", () => console.log(refusals.join(", ") || "not refused")); + const exe = process.execPath; + const cmd = [exe, "-e", ""]; + const count = [exe, "-e", "console.log((await Bun.stdin.bytes()).length)"]; + const pulled = () => new ReadableStream({ type: "bytes", pull(c) { c.enqueue(bytes.slice()); c.close(); } }); + const spawnWithStdin = stream => { + try { Bun.spawn({ cmd, stdin: stream, stdout: "ignore", stderr: "ignore" }); } catch (error) { threw(error); } + }; + `; + // These don't use top-level await, so the child still exits if a read never settles. + const owners: [owner: string, read: string, script: string][] = [ + [ + "a BYOB reader", + "hello", + ` + const stream = pulled(); + const reader = stream.getReader({ mode: "byob" }); + spawnWithStdin(stream); + reader.read(new Uint8Array(16)).then(({ value }) => console.log(decode(value))); + `, + ], + [ + "a default reader", + "hello", + ` + const stream = new ReadableStream({ start(c) { c.enqueue(bytes.slice()); c.close(); } }); + const reader = stream.getReader(); + spawnWithStdin(stream); + reader.read().then(({ value }) => console.log(decode(value))); + `, + ], + [ + // The stream is unlocked when the stdin option is validated. The getter locks it before the pump starts. + "text() called from a later option getter", + "hello", + ` + const stream = pulled(); + let text; + try { + Bun.spawn({ cmd, stdin: stream, get stdout() { text ??= stream.text(); return "ignore"; }, stderr: "ignore" }); + } catch (error) { threw(error); } + text.then(value => console.log(value)); + `, + ], + [ + // The stream is another child's stdout, so attaching it would steal the native source from the reader. + "a reader that a later option getter acquires on a child's stdout", + "hello 0", + ` + const stream = Bun.spawn({ cmd: [exe, "-e", "await Bun.write(Bun.stdout, 'hello')"], stdout: "pipe", stderr: "inherit" }).stdout; + let reader, refused; + try { + refused = Bun.spawn({ cmd: count, stdin: stream, get stdout() { reader ??= stream.getReader(); return "pipe"; }, stderr: "inherit" }); + } catch (error) { threw(error); } + (async () => { + let text = ""; + for (let chunk; !(chunk = await reader.read()).done; ) text += decode(chunk.value); + console.log(text, refused ? (await refused.stdout.text()).trim() : 0); + })(); + `, + ], + ]; + + test.concurrent.each(owners)("locked by %s", async (_owner, read, script) => { + await using proc = spawn({ + cmd: [bunExe(), "-e", prelude + script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const lines = stdout.trim().split("\n"); + expect({ + read: lines.slice(0, -1).join("\n"), + refusal: lines.at(-1), + stderr, + exitCode, + signalCode: proc.signalCode, + }).toEqual({ + read, + refusal: expect.stringMatching(/^(threw|unhandledRejection) ERR_INVALID_STATE$/), + stderr: "", + exitCode: 0, + signalCode: null, + }); + }); + }); + + // Bun.write() locks the process's stdin stream without starting its lazy source. A call that fails on that lock + // must not start the source either. Nothing reads the child's stdout until the child reports, so its write is + // still paused at that point. + test.skipIf(isWindows)( + "process.stdin throws when Bun.write() has locked the stdin stream, and doesn't consume any bytes", + async () => { + using dir = tempDir("spawn-stdin-lazy", { "in": Buffer.alloc(1 << 20, "a").toString() }); + await using proc = spawn({ + cmd: [ + bunExe(), + "-e", + ` + const written = Bun.write(Bun.stdout, Bun.stdin.stream()); + let code; + try { process.stdin.resume(); } catch (error) { code = error.code; } + await Bun.write(Bun.stderr, String(code)); + await written; + `, + ], + env: bunEnv, + stdin: Bun.file(path.join(String(dir), "in")), + stdout: "pipe", + stderr: "pipe", + }); + const report = await proc.stderr.getReader().read(); + const [stdout, exitCode] = await Promise.all([proc.stdout.bytes(), proc.exited]); + expect({ report: new TextDecoder().decode(report.value), length: stdout.length, exitCode }).toEqual({ + report: "ERR_INVALID_STATE", + length: 1 << 20, + exitCode: 0, + }); + }, + ); + test("ReadableStream object type count", async () => { const iterations = isASAN ? // With ASAN, entire process gets killed. Likely an OOM or out of file diff --git a/test/js/bun/spawn/spawn.ipc.test.ts b/test/js/bun/spawn/spawn.ipc.test.ts index fda1c05aeef7..1851c4adbe53 100644 --- a/test/js/bun/spawn/spawn.ipc.test.ts +++ b/test/js/bun/spawn/spawn.ipc.test.ts @@ -1,8 +1,80 @@ import { spawn } from "bun"; import { describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, gcTick, isWindows } from "harness"; +import { bunEnv, bunExe, gcTick, isWindows, tempDir } from "harness"; import path from "path"; +// The receiver rejects a JSON line that's longer than the UTF-8 encoding of a max-length string can be. +// BUN_FEATURE_FLAG_SYNTHETIC_MEMORY_LIMIT lowers the max string length. +const maxStringLength = 64 * 1024; +const lowLimitEnv = { ...bunEnv, BUN_FEATURE_FLAG_SYNTHETIC_MEMORY_LIMIT: String(maxStringLength) }; + +// Sender code that writes 1 MiB of "a" without a newline, which is over the lowered limit. +// It exits with 1 if the receiver accepted all of it. +const flood = ` + const chunk = Buffer.alloc(65536, "a"); + for (let i = 0; i < 16; i++) write(chunk); + process.exit(1); +`; + +/** + * Runs `senderSource` as the child of a process that receives IPC messages under the lowered + * limit. The sender can call `write(bytes)` to put raw bytes on the channel and `send(message)` + * to send one JSON message. It exits with 42 once the receiver closes the channel. + */ +async function runReceiver(serialization: "json" | "advanced", senderSource: string, disconnectAfter = 0) { + using dir = tempDir("ipc-receive-limit", { + "receiver.js": ` + let received = 0; + const sender = Bun.spawn({ + cmd: [process.execPath, "sender.js"], + env: { ...process.env, BUN_FEATURE_FLAG_SYNTHETIC_MEMORY_LIMIT: undefined }, + stdio: ["ignore", "inherit", "inherit"], + serialization: ${JSON.stringify(serialization)}, + ipc(message, subprocess) { + console.log("MESSAGE", typeof message === "string" ? "string of " + message.length : JSON.stringify(message)); + // The sender waits for the channel to close, so disconnect once every expected + // message has arrived. + if (++received === ${disconnectAfter}) subprocess.disconnect(); + }, + }); + console.log("SENDER_EXIT", await sender.exited); + `, + "sender.js": ` + const { writeSync } = require("fs"); + const retry = new Int32Array(new SharedArrayBuffer(4)); + // When the receiver closes the channel, an in-flight write fails with EPIPE, ECONNRESET + // or ENOTCONN (macOS checks the connection first). + const closed = new Set(["EPIPE", "ECONNRESET", "ENOTCONN"]); + function write(bytes) { + for (let offset = 0; offset < bytes.length; ) { + try { + offset += writeSync(3, bytes, offset); + } catch (e) { + if (closed.has(e.code)) process.exit(42); + if (e.code !== "EAGAIN") throw e; + Atomics.wait(retry, 0, 0, 1); + } + } + } + function send(message) { + process.send(message); + } + process.on("disconnect", () => process.exit(42)); + ${senderSource} + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "receiver.js"], + env: lowLimitEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout: stdout.split("\n").filter(Boolean), stderr, exitCode }; +} + describe.each(["advanced", "json"])("ipc mode %s", mode => { it("the subprocess should be defined and the child should send", done => { gcTick(); @@ -157,6 +229,24 @@ describe("ipc mode json", () => { expect(stderr).toBe(""); expect(exitCode).toBe(0); }); + + it.concurrent.skipIf(isWindows)("closes the channel when an unterminated line exceeds the length limit", async () => { + // The complete lines are delivered. The bytes after them are never terminated by a newline. + expect(await runReceiver("json", `write(Buffer.from('{"n":1}\\n{"n":2}\\n'));` + flood)).toEqual({ + stdout: ['MESSAGE {"n":1}', 'MESSAGE {"n":2}', "SENDER_EXIT 42"], + stderr: "IPC message is too long.\n", + exitCode: 0, + }); + }); + + it.concurrent("delivers a message as long as the max string length", async () => { + const sender = `send("\u4e2d".repeat(${maxStringLength - 2})); send({ after: true });`; + expect(await runReceiver("json", sender, 2)).toEqual({ + stdout: [`MESSAGE string of ${maxStringLength - 2}`, 'MESSAGE {"after":true}', "SENDER_EXIT 42"], + stderr: "", + exitCode: 0, + }); + }); }); describe("ipc mode advanced", () => { @@ -322,6 +412,18 @@ describe("ipc mode advanced", () => { expect(exitCode).toBe(0); }, ); + + it.concurrent.skipIf(isWindows)( + "closes the channel as soon as a frame header declares a length that's too long to deserialize", + async () => { + const header = `write(Buffer.from([0x02, 0x01, 0x00, 0x01, 0x00]));`; + expect(await runReceiver("advanced", header + flood)).toEqual({ + stdout: ["SENDER_EXIT 42"], + stderr: "", + exitCode: 0, + }); + }, + ); }); // getIPCInstance error path: on Windows, windowsConfigureClient can open the diff --git a/test/js/bun/spawn/spawn.test.ts b/test/js/bun/spawn/spawn.test.ts index 26fdee11dc89..93ee74557333 100644 --- a/test/js/bun/spawn/spawn.test.ts +++ b/test/js/bun/spawn/spawn.test.ts @@ -2380,3 +2380,93 @@ describe.skipIf(!isPosix)("a spawn while fd 0, 1 or 2 is closed", () => { }); }); }); + +// At startup, bun opens /dev/null on fd 0, 1 or 2 if it's closed. Like node, it doesn't replace an O_PATH descriptor. +describe.skipIf(!isPosix)("startup with a closed or an O_PATH stdio fd", () => { + const O_PATH = 0o10000000; + // Writes a report to fd 3: what fds 0-2 point to after startup, and the result of writing to fds 1 and 2. + const script = ` + const fs = require("node:fs"); + const devNull = fs.statSync("/dev/null").rdev; + const at = fd => { + const stat = fs.fstatSync(fd); + if (stat.isCharacterDevice() && stat.rdev === devNull) return "/dev/null"; + if (stat.isDirectory()) return "directory"; + return stat.isFIFO() || stat.isSocket() ? "pipe" : "other"; + }; + const write = fd => { + try { + return fs.writeSync(fd, "x"); + } catch (e) { + return e.code; + } + }; + console.log("out"); + console.error("err"); + fs.writeSync(3, JSON.stringify({ fds: [at(0), at(1), at(2)], writes: [write(1), write(2)] })); + `; + const after = { opath: "directory", closed: "/dev/null", pipe: "pipe" }; + type Slot = keyof typeof after; + const closed: Slot[][] = [["closed", "closed", "closed"]]; + const opath: Slot[][] = [ + ["pipe", "opath", "pipe"], + ["opath", "closed", "closed"], + ]; + + it.concurrent.each<Slot[]>([...closed, ...(isLinux ? opath : [])])("fd 0 %s, fd 1 %s, fd 2 %s", async (...slots) => { + const directory = slots.includes("opath") ? openSync("/", O_PATH) : undefined; + let reportFd: number | undefined; + try { + // Bun.spawn can't leave a stdio fd closed, so sh closes it and then execs bun. + const closes = slots.flatMap((slot, fd) => (slot === "closed" ? [`${fd}>&-`] : [])).join(" "); + await using proc = spawn({ + cmd: closes ? ["sh", "-c", `exec "$0" "$@" ${closes}`, bunExe(), "-e", script] : [bunExe(), "-e", script], + env: bunEnv, + stdio: [...slots.map(slot => (slot === "opath" ? directory! : "pipe")), "pipe"], + }); + reportFd = proc.stdio[3] as number; + const exitCode = await proc.exited; + const buf = Buffer.alloc(256); + const report = buf.toString("utf8", 0, readSync(reportFd, buf)); + expect({ report: report ? JSON.parse(report) : null, exitCode, signalCode: proc.signalCode }).toEqual({ + report: { + fds: slots.map(slot => after[slot]), + writes: slots.slice(1).map(slot => (slot === "opath" ? "EBADF" : 1)), + }, + exitCode: 0, + signalCode: null, + }); + } finally { + if (directory !== undefined) closeSync(directory); + if (reportFd !== undefined) closeSync(reportFd); + } + }); +}); + +it("doesn't crash when a subprocess pipe is canceled and the event loop runs inside its read callback", async () => { + await using server = Bun.serve({ + port: 0, + fetch: () => new Response("ok"), + }); + // The second chunk arrives while the stream's read is pending. + await using proc = spawn({ + cmd: [bunExe(), "-e", `process.stderr.write("ready\\n"); setInterval(() => process.stderr.write("tick\\n"), 1);`], + env: bunEnv, + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }); + + const decoder = new TextDecoder(); + let stderr = ""; + let chunks = 0; + for await (const chunk of proc.stderr) { + stderr += decoder.decode(chunk, { stream: true }); + if (++chunks >= 2 && stderr.includes("tick")) break; + } + expect(stderr).toStartWith("ready\n"); + + // An expect().resolves that isn't awaited runs the event loop synchronously. + expect(Bun.sleep(1)).resolves.toBeUndefined(); + expect(fetch(server.url).then(r => r.text())).resolves.toBe("ok"); +}); diff --git a/test/js/bun/spawn/spawnSync.test.ts b/test/js/bun/spawn/spawnSync.test.ts index 6129a25c5f9d..9ccf716fae0c 100644 --- a/test/js/bun/spawn/spawnSync.test.ts +++ b/test/js/bun/spawn/spawnSync.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, bunRun, isLinux, isMusl, isPosix, isRoot, isWindows } from "harness"; +import { bunEnv, bunExe, bunRun, isLinux, isMusl, isPosix, isRoot, isWindows, tempDir } from "harness"; import { totalmem } from "os"; import { join } from "path"; describe("spawnSync", () => { @@ -173,6 +173,58 @@ describe.skipIf(!isPosix || totalmem() < 16 * 1024 ** 3)("spawnSync output at th }, 120_000); }); +it("a test timeout doesn't kill the child of a spawnSync running in a Worker", async () => { + // The child is told to stop right after the deadline and then sleeps 200 ms, so a signal sent at the deadline + // would kill it first. + const untilStopped = ` + for (let i = 0; i < 2000 && !require("node:fs").existsSync("stop"); i++) Bun.sleepSync(5); + Bun.sleepSync(200); + `; + using dir = tempDir("spawnsync-worker-test-timeout", { + "timeout.test.ts": ` + import { afterAll, expect, test } from "bun:test"; + import { writeFileSync } from "node:fs"; + + const worker = new Worker(new URL("worker.ts", import.meta.url).href); + afterAll(() => worker.terminate()); + const ended = new Promise(resolve => { + worker.onmessage = event => resolve(event.data); + }); + + test("times out", async () => { + await new Promise(() => {}); + }, 1000); + + test("the worker's child exited on its own", async () => { + writeFileSync("stop", ""); + expect(await ended).toEqual({ exitCode: 0, signalCode: null }); + }); + `, + // With a pipe, spawnSync waits in its own event loop, which checks bun:test's deadlines. + "worker.ts": ` + const { exitCode, signalCode = null } = Bun.spawnSync({ + cmd: [process.execPath, "-e", ${JSON.stringify(untilStopped)}], + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + }); + postMessage({ exitCode, signalCode }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "timeout.test.ts"], + env: bunEnv, + cwd: String(dir), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("(pass) the worker's child exited on its own"); + expect(exitCode).toBe(1); +}); + describe("uid/gid", () => { it("rejects a non-integer uid", () => { expect(() => Bun.spawnSync({ cmd: [bunExe()], env: bunEnv, uid: 1.5 })).toThrow(); diff --git a/test/js/bun/spawn/spawn_waiter_thread.test.ts b/test/js/bun/spawn/spawn_waiter_thread.test.ts index 99c599ea3bdb..e1729ebd6e01 100644 --- a/test/js/bun/spawn/spawn_waiter_thread.test.ts +++ b/test/js/bun/spawn/spawn_waiter_thread.test.ts @@ -101,3 +101,28 @@ test.skipIf(!isLinux)("a child that exits does not interrupt a system call of th expect(stdout).toBe("1\n"); expect(exitCode).toBe(0); }); + +// The first spawn starts the waiter thread, which needs its own file descriptor. +test.skipIf(!isLinux)("`exited` rejects when the waiter thread can't start, and the next spawn retries", async () => { + const script = ` + const fs = require("node:fs"); + const stdio = ["inherit", "inherit", "inherit"]; + const held = []; + try { + for (;;) held.push(fs.openSync("/dev/null", "r")); + } catch {} + console.log(await Bun.spawn({ cmd: ["sleep", "0"], stdio }).exited.catch(e => e.code)); + for (const fd of held) fs.closeSync(fd); + console.log(await Bun.spawn({ cmd: ["sleep", "0"], stdio }).exited); + `; + await using proc = spawn({ + cmd: ["sh", "-c", 'ulimit -n 64 && exec "$0" -e "$1"', bunExe(), script], + env: { ...bunEnv, BUN_FEATURE_FLAG_FORCE_WAITER_THREAD: "1" }, + stdin: "ignore", + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("EMFILE\n0\n"); + expect(exitCode).toBe(0); +}); diff --git a/test/js/bun/sqlite/sqlite.test.js b/test/js/bun/sqlite/sqlite.test.js index aa95022938aa..9a557c761e85 100644 --- a/test/js/bun/sqlite/sqlite.test.js +++ b/test/js/bun/sqlite/sqlite.test.js @@ -6319,6 +6319,107 @@ describe("db.onInsert, db.onUpdate and db.onDelete", () => { }); }); + // The INSERT commits when its statement is reset or finalized. An FTS5 table writes to its shadow tables + // at commit, which calls onInsert. + describe("an INSERT that iterate() only stepped to its first row", () => { + const open = (calls, filename = ":memory:") => { + const db = new Database(filename); + db.run("CREATE VIRTUAL TABLE ft USING fts5(x)"); + db.onInsert = (_, name) => void calls.push(name); + return db; + }; + const abandonInsert = (db, calls) => { + db.prepare("INSERT INTO ft VALUES ('a') RETURNING 1").iterate().next(); + calls.length = 0; + }; + + it("is finished by the next call on the connection after it's garbage collected", () => { + const calls = []; + using db = open(calls); + abandonInsert(db, calls); + Bun.gc(true); + expect(calls).toEqual([]); + expect(db.inTransaction).toBe(false); + expect(calls).toContain("ft_data"); + }); + + it("doesn't call the hook when it's collected along with its Database", () => { + const calls = []; + (() => abandonInsert(open(calls), calls))(); + Bun.gc(true); + expect(calls).toEqual([]); + }); + + it.each([ + ["it's", false], + ["it and a SELECT that was still iterating are", true], + ])("releases the file when %s collected after close(), even if the Database isn't", (_, abandonSelect) => { + using dir = tempDir("sqlite-abandoned-insert", {}); + const file = path.join(String(dir), "x.sqlite"); + const calls = []; + using db = open(calls, file); + if (abandonSelect) (() => void db.prepare("SELECT 1").iterate().next())(); + abandonInsert(db, calls); + db.close(); + Bun.gc(true); + expect(calls).toEqual([]); + using other = new Database(file); + // Throws "database is locked" while the INSERT is still open. + other.run("CREATE TABLE t (a)"); + }); + + it("doesn't interrupt a statement that's still iterating when it's collected after close()", () => { + const calls = []; + using db = open(calls); + db.run("CREATE TABLE n (a)"); + db.run("INSERT INTO n VALUES (1), (2)"); + const rows = db.prepare("SELECT a FROM n").iterate(); + expect(rows.next().value).toEqual({ a: 1 }); + abandonInsert(db, calls); + db.close(); + Bun.gc(true); + expect(calls).toEqual([]); + expect(rows.next().value).toEqual({ a: 2 }); + expect(calls).toContain("ft_data"); + }); + + it("doesn't call the hook when the ModuleGraph that opened its Database is disposed", () => { + const calls = []; + const graph = new Bun.ModuleGraph(); + const db = graph.run(() => open(calls)); + abandonInsert(db, calls); + graph.dispose(); + expect(calls).toEqual([]); + expect(() => db.run("SELECT 1")).toThrow("Database has closed"); + }); + + it("is finished by close(), and the hook can collect other statements", async () => { + const src = /* js */ ` + const { Database } = require("bun:sqlite"); + for (const force of [true, false]) { + const db = new Database(":memory:"); + db.run("CREATE VIRTUAL TABLE ft USING fts5(x)"); + let others = Array.from({ length: 400 }, (_, i) => db.query("SELECT " + i)); + db.query("INSERT INTO ft VALUES ('a') RETURNING 1").iterate().next(); + let calls = 0; + db.onInsert = () => { + calls++; + others = null; + Bun.gc(true); + }; + db.close(force); + console.log(calls); + } + `; + // Malloc=1 lets ASAN catch a use of freed WTF memory. On Windows, WebKit traps at startup when it's set. + const ASAN_OPTIONS = [bunEnv.ASAN_OPTIONS, "symbolize=0", "detect_leaks=0"].filter(Boolean).join(":"); + const env = isWindows ? bunEnv : { ...bunEnv, Malloc: "1", ASAN_OPTIONS }; + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", src], env, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "3\n3\n", stderr: "", exitCode: 0 }); + }); + }); + it("on a closed database: null, and setting one throws", () => { const db = new Database(":memory:"); db.onInsert = () => {}; diff --git a/test/js/bun/test/bun-test.test.ts b/test/js/bun/test/bun-test.test.ts index 0a1cf08999bf..cf400c04d9ca 100644 --- a/test/js/bun/test/bun-test.test.ts +++ b/test/js/bun/test/bun-test.test.ts @@ -83,3 +83,41 @@ test("toBeWithin() with missing or non-number arguments fails the test without c expect(stderr).toContain("4 fail"); expect(exitCode).toBe(1); }); + +test.concurrent("runs the next test after a retried test rejects with a value that throws when inspected", async () => { + using dir = tempDir("test-retry-inspect-throws", { + "retry.test.js": ` + import { test, expect } from "bun:test"; + import { inspect } from "node:util"; + + const doc = { [inspect.custom]() { throw new Error("inspect"); } }; + let attempts = 0; + + test("flaky", async () => { + if (attempts++ === 0) throw { status: 400, doc }; + }, { retry: 2 }); + + test("must fail", () => { + console.log("must fail body ran"); + expect(1).toBe(2); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "retry.test.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ + stdout: stdout.split("\n").slice(1).join("\n"), + summary: stderr.match(/^ *\d+ (?:pass|fail)$/gm)?.map(line => line.trim()), + exitCode, + }).toEqual({ + stdout: "must fail body ran\n", + summary: ["1 pass", "1 fail"], + exitCode: 1, + }); +}); diff --git a/test/js/bun/test/describe.test.ts b/test/js/bun/test/describe.test.ts index ec0a6740de97..773f9b4ff67e 100644 --- a/test/js/bun/test/describe.test.ts +++ b/test/js/bun/test/describe.test.ts @@ -1,6 +1,6 @@ import { spawnSync } from "bun"; import { describe, expect, jest, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, isLinux, tempDir } from "harness"; describe("blocks should handle a number, string, anonymous class, named class, or function for the first arg", () => { const numberMock = jest.fn(); @@ -223,3 +223,39 @@ describe("passing arrow function as args", () => { expect(fullOutput).toInclude("1 fail"); }); }); + +test("deeply nested describe() doesn't overflow the native stack", async () => { + // describe() callbacks run from a queue, so the JS stack limit doesn't stop the nesting. + using test_dir = tempDir(".", { + "describe-deep.test.js": ` + import { describe, test, expect } from "bun:test"; + + let depth = 10000; + function nest() { + if (depth-- <= 0) { + test("leaf", () => { + expect(1).toBe(1); + }); + return; + } + describe("d" + depth, nest); + } + nest(); + `, + }); + const cmd = [bunExe(), "test", "./describe-deep.test.js"]; + await using proc = Bun.spawn({ + // Linux sizes the main thread's stack from `ulimit -s`. + cmd: isLinux ? ["sh", "-c", `ulimit -s 1024 && exec "$@"`, "sh", ...cmd] : cmd, + cwd: String(test_dir), + stdout: "pipe", + stderr: "pipe", + env: bunEnv, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toInclude(" > d2 > d1 > d0 > leaf"); + expect(stderr).toInclude("\n 1 pass"); + expect(stderr).toInclude("\n 0 fail"); + expect(exitCode).toBe(0); +}); diff --git a/test/js/bun/test/expect-extend.test.js b/test/js/bun/test/expect-extend.test.js index 57e30d65893f..60a27a56dc5e 100644 --- a/test/js/bun/test/expect-extend.test.js +++ b/test/js/bun/test/expect-extend.test.js @@ -326,6 +326,21 @@ describe("async support", () => { await expect(async () => await expect(1).toEqual(expect._toCustomB())).toThrow(); // asymmetric use } }); + + it("throws the matcher's rejection error when printing the rejected value throws", () => { + const rejection = new String("rejection"); + rejection.toString = () => { + throw new TypeError("toString throws"); + }; + expect.extend({ + _toCustomA: _expected => Promise.reject(rejection), + }); + + if (isBun) { + // the matcher call throws its own error, not the TypeError from toString() + expect(() => expect(1)._toCustomA()).toThrow("Matcher `_toCustomA` returned a promise that rejected"); + } + }); }); it("should not crash under intensive usage", () => { diff --git a/test/js/bun/test/expect.test.js b/test/js/bun/test/expect.test.js index 9bcb4088a9d3..ef6b6e81ad64 100644 --- a/test/js/bun/test/expect.test.js +++ b/test/js/bun/test/expect.test.js @@ -1094,6 +1094,58 @@ describe("expect()", () => { } }); + if (isBun) { + test(".not.toThrow(Class) reports a thrown string, symbol or BigInt that Symbol.hasInstance accepts", async () => { + const { bunEnv, bunExe } = require("harness"); + const src = ` + import { expect } from "bun:test"; + class Anything { + static [Symbol.hasInstance]() { + return true; + } + } + const report = async run => { + try { + await run(); + console.log("passed"); + } catch (e) { + console.log(e.message.split("\\n").filter(Boolean).slice(1).join(" | ")); + } + }; + const thrower = thrown => () => { + throw thrown; + }; + for (const thrown of ["boom", Symbol("boom"), 10n ** 30n, {}, { message: "an object" }, new TypeError("an error")]) { + await report(() => expect(thrower(thrown)).not.toThrow(Anything)); + } + await report(() => expect(Promise.reject("boom")).rejects.not.toThrow(Anything)); + await report(() => expect(thrower("boom")).not.toThrow(RangeError)); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + env: { ...bunEnv, BUN_JSC_validateExceptionChecks: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.split("\n"), stderr, exitCode, signalCode: proc.signalCode }).toMatchObject({ + stdout: [ + 'Expected constructor: not Anything | Received value: "boom"', + "Expected constructor: not Anything | Received value: Symbol(boom)", + "Expected constructor: not Anything | Received value: 1000000000000000000000000000000n", + "Expected constructor: not Anything | Received value: {}", + 'Expected constructor: not Anything | Received message: "an object"', + 'Expected constructor: not Anything | Received message: "an error"', + 'Expected constructor: not Anything | Received value: "boom"', + "passed", + "", + ], + exitCode: 0, + signalCode: null, + }); + }); + } + test("deepEquals derived strings and strings", () => { let a = new String("hello"); let b = "hello"; diff --git a/test/js/bun/test/expect/toHaveReturnedWith.test.ts b/test/js/bun/test/expect/toHaveReturnedWith.test.ts index d98ba0aad8ac..87f6e3d37b31 100644 --- a/test/js/bun/test/expect/toHaveReturnedWith.test.ts +++ b/test/js/bun/test/expect/toHaveReturnedWith.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, jest, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; // Example functions for testing toHaveReturnedWith export function add(a: number, b: number): number { @@ -232,6 +233,50 @@ describe("toHaveReturnedWith Examples", () => { expect(fn).toHaveReturnedWith(42); }).toThrow(); }); + + test.concurrent("failure message lists the compared return values after mock.results is emptied", async () => { + const script = ` + import { expect, mock } from "bun:test"; + + const m = mock(i => ({ tag: "ret-" + i, payload: [i] })); + for (let i = 0; i < 50; i++) m(i); + + let reads = 0; + const expected = { + get tag() { + if (++reads === 45) { + m.mock.results.length = 0; + Bun.gc(true); + } + return "no match"; + }, + payload: [], + }; + + let message = ""; + try { + expect(m).toHaveReturnedWith(expected); + } catch (e) { + message = e.message; + } + console.log(message); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const tags = Array.from(stdout.matchAll(/tag: "(ret-\d+)"/g), match => match[1]); + expect({ tags, stderr, exitCode }).toEqual({ + tags: Array.from({ length: 45 }, (_, i) => `ret-${i}`), + stderr: "", + exitCode: 0, + }); + }); }); describe("Common Mistakes and How to Avoid Them", () => { diff --git a/test/js/bun/test/failure-skip.test.ts b/test/js/bun/test/failure-skip.test.ts index ab67b3127d8e..11d08347b722 100644 --- a/test/js/bun/test/failure-skip.test.ts +++ b/test/js/bun/test/failure-skip.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, tempDir } from "harness"; async function testFailureSkip(failurePoints: string[]): Promise<string> { const result = await Bun.spawn({ @@ -71,3 +71,122 @@ describe("failure-skip", async () => { ); }); }); + +describe("an error that throws while the runner prints it", () => { + const prelude = ` + import { afterAll, afterEach, beforeEach, expect, test } from "bun:test"; + const log = marker => console.log("%%<" + marker + ">%%"); + class ErrorWithThrowingMessage extends Error { + get message() { + throw new TypeError("getter throws"); + } + } + `; + + async function run(files: Record<string, string>, flags: string[] = []) { + using dir = tempDir("failure-skip-pending-exception", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", ...flags, ...Object.keys(files).map(name => "./" + name)], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const stderr = rawStderr.replaceAll("\r\n", "\n"); + return { + markers: [...stdout.matchAll(/%%<([^>]+)>%%/g)].map(([, marker]) => marker).join(","), + verdicts: [...stderr.matchAll(/\((pass|fail)\) (.+?)(?: \[[^\]]+\])?$/gm)].map( + ([, verdict, name]) => `${verdict} ${name}`, + ), + summary: [...stderr.matchAll(/^ *(\d+ (?:pass|fail|errors?))$/gm)].map(([, count]) => count).join(", "), + exitCode, + }; + } + + test.concurrent("later hooks and tests still run", async () => { + expect( + await run({ + "hooks.test.js": + prelude + + ` + beforeEach(() => log("beforeeach")); + afterEach(() => log("aftereach")); + afterAll(() => log("afterall")); + test("throws", () => { + log("throws"); + throw new ErrorWithThrowingMessage(); + }); + test("fails", () => { + log("fails"); + expect(1).toBe(2); + }); + `, + }), + ).toEqual({ + markers: "beforeeach,throws,aftereach,beforeeach,fails,aftereach,afterall", + verdicts: ["fail throws", "fail fails"], + summary: "0 pass, 2 fail", + exitCode: 1, + }); + }); + + test.concurrent("later tests still run when printing keeps throwing new errors", async () => { + expect( + await run({ + "again.test.js": + prelude + + ` + class ErrorWithSelfSimilarMessage extends Error { + get message() { + throw new ErrorWithSelfSimilarMessage(); + } + } + test("throws", () => { + log("throws"); + throw new ErrorWithSelfSimilarMessage(); + }); + test("fails", () => { + log("fails"); + expect(1).toBe(2); + }); + `, + }), + ).toEqual({ + markers: "throws,fails", + verdicts: ["fail throws", "fail fails"], + summary: "0 pass, 2 fail", + exitCode: 1, + }); + }); + + test.concurrent.each([[[]], [["--isolate"]]])("the next file still runs %j", async flags => { + expect( + await run( + { + "a.test.js": + prelude + + ` + test("last callback of the file throws", () => { + log("throws"); + throw new ErrorWithThrowingMessage(); + }); + `, + "b.test.js": ` + import { expect, test } from "bun:test"; + test("in the next file", () => { + console.log("%%<next-file>%%"); + expect(1).toBe(2); + }); + `, + }, + flags, + ), + ).toEqual({ + markers: "throws,next-file", + verdicts: ["fail last callback of the file throws", "fail in the next file"], + summary: "0 pass, 2 fail", + exitCode: 1, + }); + }); +}); diff --git a/test/js/bun/test/fake-timers/fake-timers.test.ts b/test/js/bun/test/fake-timers/fake-timers.test.ts index 88621589768e..33b295b62cce 100644 --- a/test/js/bun/test/fake-timers/fake-timers.test.ts +++ b/test/js/bun/test/fake-timers/fake-timers.test.ts @@ -2,7 +2,7 @@ import { RedisClient, SQL } from "bun"; import { timerInternals } from "bun:internal-for-testing"; import { heapStats } from "bun:jsc"; import { setSystemTime } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, isDebug, isLinux, tempDir } from "harness"; import { spawnSync as childProcessSpawnSync } from "node:child_process"; import { afterEach, describe, expect, test, vi } from "vitest"; @@ -195,6 +195,46 @@ describe("getTimerCount", () => { test("throws error if fake timers not active", () => { expect(() => vi.getTimerCount()).toThrow("Fake timers are not active"); }); + + // Only Linux applies `ulimit -s` to the main thread's stack. + test.skipIf(!isLinux)("walks a deep timer heap without overflowing the stack", async () => { + // Debug builds need 0.1 ms to create a timer, and their bigger frames overflow sooner. + const N = isDebug ? 5_000 : 10_000; + const script = ` + import { jest } from "bun:test"; + jest.useFakeTimers(); + // Decreasing deadlines link the timers into a single chain of child nodes. + // Bun.sleep() timers fire without calling into JS. + const sleeps = []; + for (let i = 0; i < ${N}; i++) sleeps.push(Bun.sleep(${N} - i)); + let pending, left; + // At the deepest JS frame, native code has about 128 KB of stack to work with. + (function dive() { + try { + dive(); + } catch { + pending ??= jest.getTimerCount(); + left ??= jest.runOnlyPendingTimers().getTimerCount(); + } + })(); + jest.useRealTimers(); + await Promise.all(sleeps); + console.log("pending", pending, "left", left); + `; + await using proc = Bun.spawn({ + // JavaScriptCore caps JS frames at 5 MB of stack. With a smaller stack, + // the recursion above reaches the real end of the stack. + cmd: ["sh", "-c", 'ulimit -s 4096 && exec "$0" -e "$1"', bunExe(), script], + // The environment also lives on that stack, and JavaScriptCore doesn't account for it. + env: Object.fromEntries(Object.entries(bunEnv).filter(([name]) => /^BUN_|SAN_OPTIONS$/.test(name))), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe(`pending ${N} left 0\n`); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); }); describe("clearAllTimers", () => { test("clears all pending timers", () => { @@ -740,6 +780,100 @@ describe("Date.now() mocking", () => { }); }); +describe("timer controls called from inside a timer callback", () => { + const T0 = 1_000_000; + + test("runOnlyPendingTimers() in a setInterval callback doesn't move the clock backwards", () => { + vi.useFakeTimers({ now: T0 }); + const seen: [string, number, number][] = []; + let n = 0; + const interval = setInterval(() => { + n++; + seen.push(["interval " + n, Date.now() - T0, performance.now()]); + vi.runOnlyPendingTimers(); + }, 1); + setTimeout(() => seen.push(["timeout", Date.now() - T0, performance.now()]), 10); + vi.advanceTimersByTime(10); + // Tick 1 fires the 10 ms timeout. Tick 2 was due at 2, so it fires late at 10. + expect(seen).toEqual([ + ["interval 1", 1, 1], + ["timeout", 10, 10], + ["interval 2", 10, 10], + ]); + expect(Date.now() - T0).toBe(10); + clearInterval(interval); + vi.useRealTimers(); + }); + + test("a nested advanceTimersByTime() in a setTimeout callback doesn't move the clock backwards", () => { + vi.useFakeTimers({ now: T0 }); + setTimeout(() => vi.advanceTimersByTime(100), 5); + vi.advanceTimersByTime(10); + expect(Date.now() - T0).toBe(105); + expect(performance.now()).toBe(105); + vi.useRealTimers(); + }); +}); + +test("useRealTimers() called from a timer callback isn't undone when the advance completes", () => { + const realBefore = performance.now(); + vi.useFakeTimers({ now: 0 }); + setTimeout(() => vi.useRealTimers(), 10); + vi.advanceTimersByTime(100); + expect(vi.isFakeTimers()).toBe(false); + expect(Date.now()).toBeGreaterThan(1e12); + expect(performance.now()).toBeGreaterThanOrEqual(realBefore); +}); + +test("workers with fake timers don't share a clock", async () => { + // Each worker sets its own system time, runs its pending timers and then checks Date.now(). + const workerSrc = /* js */ ` + const { jest } = Bun.jest(__filename); + const { parentPort, workerData } = require("worker_threads"); + const { now, target } = workerData; + const mismatches = []; + for (let r = 0; r < 400; r++) { + jest.useFakeTimers({ now }); + let n = 0; + setTimeout(() => n++, 1000); + const iv = setInterval(() => { if (n++ > 3) clearInterval(iv); }, 100); + jest.setSystemTime(target); + jest.runOnlyPendingTimers(); + const after = Date.now(); + if (after !== target + 1000) mismatches.push({ round: r, after, expected: target + 1000 }); + jest.useRealTimers(); + } + parentPort.postMessage(mismatches); + `; + const mainSrc = /* js */ ` + const { Worker } = require("worker_threads"); + const results = {}; + const configs = [{ now: 0, target: 1e12 }, { now: 2 ** 40, target: 0 }]; + let done = 0; + for (const workerData of configs) { + const w = new Worker(${JSON.stringify(workerSrc)}, { eval: true, workerData }); + w.on("error", err => { console.error(err); process.exit(1); }); + w.on("message", mismatches => { + results[workerData.now] = mismatches; + if (++done === configs.length) { + console.log(JSON.stringify(results)); + process.exit(0); + } + }); + } + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", mainSrc], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ "0": [], [2 ** 40]: [] }); + expect(exitCode).toBe(0); +}); + describe("performance.now() mocking", () => { test("performance.now() should be mocked when fake timers are active", () => { vi.useFakeTimers(); diff --git a/test/js/bun/test/mock-fn.test.js b/test/js/bun/test/mock-fn.test.js index 8773618a30a1..2379730d7403 100644 --- a/test/js/bun/test/mock-fn.test.js +++ b/test/js/bun/test/mock-fn.test.js @@ -1100,6 +1100,26 @@ describe("spyOn", () => { }); } + test("spyOn throws on a WebAssembly GC object", () => { + // (type $s (struct (field (mut i32)))) (type $a (array (mut i32))) + // (func (export "s") (result (ref null $s)) struct.new_default $s) + // (func (export "a") (result (ref null $a)) i32.const 2 array.new_default $a) + // prettier-ignore + const bytes = new Uint8Array([ + 0, 0x61, 0x73, 0x6d, 1, 0, 0, 0, + 1, 18, 4, 0x5f, 1, 0x7f, 1, 0x5e, 0x7f, 1, 0x60, 0, 1, 0x63, 0, 0x60, 0, 1, 0x63, 1, + 3, 3, 2, 2, 3, + 7, 9, 2, 1, 0x73, 0, 0, 1, 0x61, 0, 1, + 10, 15, 2, 5, 0, 0xfb, 1, 0, 0x0b, 7, 0, 0x41, 2, 0xfb, 7, 1, 0x0b, + ]); + const { s, a } = new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports; + for (const target of [s(), a()]) { + for (const key of ["x", Symbol("x"), 0]) { + expect(() => spyOn(target, key)).toThrow("spyOn(target, prop) does not support WebAssembly GC objects"); + } + } + }); + test("spyOn twice works", () => { var obj = { original() { diff --git a/test/js/bun/test/mock/mock-module.test.ts b/test/js/bun/test/mock/mock-module.test.ts index aabd736b575f..accf088cfc61 100644 --- a/test/js/bun/test/mock/mock-module.test.ts +++ b/test/js/bun/test/mock/mock-module.test.ts @@ -6,7 +6,7 @@ // - Write test for import {foo} from "./foo"; export {foo} import { expect, mock, spyOn, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import { join } from "node:path"; import { default as defaultValue, fn, iCallFn, rexported, rexportedAs, variable } from "./mock-module-fixture"; import * as spyFixture from "./spymodule-fixture"; @@ -345,6 +345,33 @@ test("a factory promise that resolves to a non-object is rejected like the synch expect(ns.default).toBe("real-default"); }); +// For a module that isn't loaded yet, mock.module() only registers the factory. import() runs it. +test.concurrent.each([ + ["undefined", `async () => { await 1; }`], + ["a number", `async () => { await 1; return 42; }`], +])("import() of a module that isn't loaded yet rejects when the factory promise resolves to %s", async (_, factory) => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `import { mock } from "bun:test"; + mock.module("mm-async-non-object", ${factory}); + try { + await import("mm-async-non-object"); + console.log("imported"); + } catch (e) { + console.log("rejected:", e?.message); + }`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("rejected: mock(module, fn) requires a function that returns an object\n"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); +}); + test("a later mock.module for the same module is not overwritten when an earlier pending factory settles", async () => { using dir = tempDir("mock-module-superseded", { "a.ts": `export function a() { return "real-a"; }`, @@ -540,3 +567,41 @@ test.concurrent("mock.module() of a module whose import() is still loading its d expect(stderr).toContain(" 1 pass"); expect(exitCode).toBe(0); }); + +// In a URL, the "%de" in "50%deposit.ts" escapes the byte 0xDE, which isn't valid UTF-8. Windows decodes it to U+FFFD. +test.concurrent.skipIf(isWindows)("supports module names with a % that isn't a valid UTF-8 escape", async () => { + using dir = tempDir("mock-module-percent", { + "real%deposit.ts": `export default "the real file";`, + "percent.test.ts": ` + import { expect, mock, test } from "bun:test"; + + test("mocks a relative name that doesn't resolve", async () => { + mock.module("./50%deposit.ts", () => ({ default: "the mock" })); + expect((await import("./50%deposit.ts")).default).toBe("the mock"); + expect(require("./50%deposit.ts").default).toBe("the mock"); + }); + + test("mock.module() of a file: URL throws", () => { + expect(() => mock.module("file:///50%deposit.ts", () => ({}))).toThrow(new TypeError('Invalid "file:" URL')); + }); + + test("loads a real file with a % in its name", async () => { + expect((await import("./real%deposit.ts")).default).toBe("the real file"); + }); + + test("import() of a file: URL rejects", async () => { + await expect(import("file:///50%deposit.ts")).rejects.toThrow("Cannot find module"); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "./percent.test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain(" 4 pass\n 0 fail"); + expect(exitCode).toBe(0); +}); diff --git a/test/js/bun/test/snapshot-tests/snapshots/__snapshots__/more.test.ts.snap b/test/js/bun/test/snapshot-tests/snapshots/__snapshots__/more.test.ts.snap index f4907cae4925..9647740f52ce 100644 --- a/test/js/bun/test/snapshot-tests/snapshots/__snapshots__/more.test.ts.snap +++ b/test/js/bun/test/snapshot-tests/snapshots/__snapshots__/more.test.ts.snap @@ -116,6 +116,20 @@ exports[`d0 snapshot serialize edgecases 29`] = `"\`hello snapshot4 \\\`\\\`"`; exports[`d0 snapshot serialize edgecases 30`] = `"\\\`hello snapshot5 \\\`\\\`"`; +exports[`d0 snapshot serialize edgecases: String with 16-bit storage 1`] = ` +String { + "0": "日", + "1": "本", +} +`; + +exports[`d0 snapshot serialize edgecases: String with ASCII text in 16-bit storage 1`] = ` +String { + "0": "a", + "1": "b", +} +`; + exports[`d0 snapshot serialize edgecases: one 1`] = `1`; exports[`d0 snapshot serialize edgecases: one 2`] = `3`; diff --git a/test/js/bun/test/snapshot-tests/snapshots/more.test.ts b/test/js/bun/test/snapshot-tests/snapshots/more.test.ts index 74b1a457e608..6ea67ee31bdc 100644 --- a/test/js/bun/test/snapshot-tests/snapshots/more.test.ts +++ b/test/js/bun/test/snapshot-tests/snapshots/more.test.ts @@ -20,6 +20,10 @@ describe("d0", () => { apshot`).toMatchSnapshot(); expect(new String()).toMatchSnapshot(); expect(new String("")).toMatchSnapshot(); + expect(new String("日本")).toMatchSnapshot("String with 16-bit storage"); + expect(new String(new TextDecoder("utf-16le").decode(new Uint16Array([0x61, 0x62])))).toMatchSnapshot( + "String with ASCII text in 16-bit storage", + ); expect({ a: { b: 1 } }).toEqual({ a: { b: 1 } }); expect("\\\nexport with test name\n\n").toMatchSnapshot(); diff --git a/test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts b/test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts index bef562452a65..0581bde3171a 100644 --- a/test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts +++ b/test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts @@ -1,7 +1,8 @@ import { $ } from "bun"; import { describe, expect, it, test } from "bun:test"; -import { readFileSync, writeFileSync } from "fs"; -import { bunEnv, bunExe, DirectoryTree, isDebug, tempDir, tempDirWithFiles } from "harness"; +import { mkdirSync, readFileSync, writeFileSync } from "fs"; +import { bunEnv, bunExe, DirectoryTree, isDebug, isWindows, MAX_PATH_BYTES, tempDir, tempDirWithFiles } from "harness"; +import { join } from "path"; function test1000000(arg1: any, arg218718132: any) {} @@ -958,3 +959,35 @@ test("write snapshot from filter", async () => { expect(await Bun.file(dir + "/mytests/snap2.test.ts").text()).toBe(sver("b", true)); expect(await Bun.file(dir + "/mytests/more/testing.test.ts").text()).toBe(sver("TEST", true)); }); + +// Windows cannot create directories as deep as its path buffer is long. +test.skipIf(isWindows)("snapshot file path longer than PATH_MAX fails the test instead of crashing", async () => { + using dir = tempDir("snapshot-long-path", {}); + let deep = String(dir); + const component = Buffer.alloc(100, "a").toString(); + while (deep.length + 1 + component.length <= MAX_PATH_BYTES - 116) { + deep = join(deep, component); + mkdirSync(deep); + } + // `<deep>/<sub>/snapx.test.ts` is 16 bytes under PATH_MAX, so + // `<deep>/<sub>/__snapshots__/snapx.test.ts.snap` is over it. + const sub = Buffer.alloc(MAX_PATH_BYTES - 16 - deep.length - 1 - "/snapx.test.ts".length, "b").toString(); + mkdirSync(join(deep, sub)); + writeFileSync( + join(deep, sub, "snapx.test.ts"), + `import { test, expect } from "bun:test";\ntest("snap", () => { expect(1).toMatchSnapshot(); });\n`, + ); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", `./${sub}/snapx.test.ts`], + env: bunEnv, + cwd: deep, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain("Failed to open snapshot file for test file: "); + expect(stderr).toContain("snapx.test.ts"); + expect(stdout + stderr).toContain("1 fail"); + expect(exitCode).toBe(1); +}); diff --git a/test/js/bun/test/test-timers.test.ts b/test/js/bun/test/test-timers.test.ts index a6e6ef4267d8..6055654298b9 100644 --- a/test/js/bun/test/test-timers.test.ts +++ b/test/js/bun/test/test-timers.test.ts @@ -1,3 +1,4 @@ +import { describe, expect, jest, setSystemTime, test } from "bun:test"; import { bunEnv, bunExe } from "harness"; import { once } from "node:events"; import http from "node:http"; @@ -80,6 +81,43 @@ test("setSystemTime accepts pre-epoch and epoch times and resets with no argumen } }); +test("chainable timer functions return their receiver only when called as a method", () => { + // When a closed-over or module binding is called bare, JSC passes the scope object as `this`. + const { + useFakeTimers, + advanceTimersByTime, + advanceTimersToNextTimer, + runOnlyPendingTimers, + runAllTimers, + clearAllTimers, + useRealTimers, + } = jest; + const bare = () => [ + useFakeTimers(), + advanceTimersByTime(0), + advanceTimersToNextTimer(), + runOnlyPendingTimers(), + runAllTimers(), + clearAllTimers(), + setSystemTime(), + useRealTimers(), + ]; + try { + expect([ + jest.useFakeTimers(), + jest.advanceTimersByTime(0), + jest.advanceTimersToNextTimer(), + jest.runOnlyPendingTimers(), + jest.runAllTimers(), + jest.clearAllTimers(), + jest.setSystemTime(), + ]).toEqual(new Array(7).fill(jest)); + expect(bare()).toEqual(new Array(8).fill(undefined)); + } finally { + expect(jest.useRealTimers()).toBe(jest); + } +}); + test.each(["'x'", "Symbol()", "1n"])("useFakeTimers does not crash when globalThis.setTimeout is %s", async value => { await using proc = Bun.spawn({ cmd: [ @@ -100,6 +138,27 @@ test.each(["'x'", "Symbol()", "1n"])("useFakeTimers does not crash when globalTh expect(proc.signalCode).toBeNull(); }); +test("useFakeTimers() skips the clock marker when setTimeout is frozen or a WebAssembly GC object", () => { + // (module (type $s (struct (field (mut i32)))) (func (export "mk") (result (ref null $s)) struct.new_default $s)) + // prettier-ignore + const bytes = new Uint8Array([0,0x61,0x73,0x6d,1,0,0,0, 1,10,2, 0x5f,1,0x7f,1, 0x60,0,1,0x63,0, 3,2,1,1, 7,6,1,2,0x6d,0x6b,0,0, 10,7,1,5,0,0xfb,1,0,0x0b]); + const struct = (new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports.mk as Function)(); + const original = globalThis.setTimeout; + try { + for (const value of [Object.freeze(() => {}), struct]) { + globalThis.setTimeout = value; + jest.useFakeTimers(); + expect(jest.isFakeTimers()).toBe(true); + expect(Reflect.ownKeys(value)).not.toContain("clock"); + globalThis.setTimeout = original; + jest.useRealTimers(); + } + } finally { + globalThis.setTimeout = original; + jest.useRealTimers(); + } +}); + test("real timer heap is ticked against the real clock under useFakeTimers", async () => { await using proc = Bun.spawn({ cmd: [bunExe(), "test", path.join(import.meta.dir, "test-timers-gc-spin-fixture.ts")], diff --git a/test/js/bun/transpiler/source-too-large.test.ts b/test/js/bun/transpiler/source-too-large.test.ts index a0c6565b056e..bd5a29022244 100644 --- a/test/js/bun/transpiler/source-too-large.test.ts +++ b/test/js/bun/transpiler/source-too-large.test.ts @@ -1,7 +1,8 @@ // Every parser records source positions as an i32 byte offset, so a source of // 2**31 bytes or more used to abort the process once the parser reached a // position past i32::MAX: `panic: int cast: TryFromIntError(PosOverflow)` from -// the JS lexer, TOML and YAML, and a failed length assertion in the XML indexer. +// the JS lexer, TOML, YAML and JSON5, and a failed length assertion in the XML +// indexer. // `Bun.TOML.parse` and friends already rejected such inputs at the API boundary; // the parsers themselves did not, and they are what `bun build`, `bun run`, // `import` and `Bun.Transpiler` feed. They now reject the source by length @@ -35,7 +36,7 @@ test("Bun.Transpiler reports the limit for every loader that records positions", input.set(Buffer.from(${JSON.stringify(FIRST_LINE)})); const transpiler = new Bun.Transpiler(); const results = {}; - for (const loader of ["js", "ts", "toml", "yaml", "xml", "json", "jsonc"]) { + for (const loader of ["js", "ts", "toml", "yaml", "xml", "json", "jsonc", "json5"]) { try { transpiler.transformSync(input, loader); results[loader] = "no error"; @@ -56,6 +57,7 @@ test("Bun.Transpiler reports the limit for every loader that records positions", xml: "BuildMessage: XML document is too large to parse (2 GiB maximum)", json: "BuildMessage: JSON document is too large to parse (2 GiB maximum)", jsonc: "BuildMessage: JSON document is too large to parse (2 GiB maximum)", + json5: "BuildMessage: JSON5 document is too large to parse (2 GiB maximum)", }, null, 2, diff --git a/test/js/bun/udp/udp_socket.test.ts b/test/js/bun/udp/udp_socket.test.ts index 769b1a5c5c8d..bd0c66a071e2 100644 --- a/test/js/bun/udp/udp_socket.test.ts +++ b/test/js/bun/udp/udp_socket.test.ts @@ -266,6 +266,19 @@ describe("udpSocket()", () => { expect(() => udpSocket({ port })).toThrow('Expected "port" to be an integer between 0 and 65535'); }); + test.each([ + ["nodebuffer", "buffer"], + ["uint16array", "uint16array"], + ["Float32Array", "float32array"], + ] as const)("binaryType %p reads back as %p", async (binaryType, reported) => { + const socket = await udpSocket({ binaryType: binaryType as any, socket: {} }); + try { + expect(socket.binaryType).toBe(reported); + } finally { + socket.close(); + } + }); + test("send/sendMany reject out-of-range ports", async () => { const server = await udpSocket({ port: 0, hostname: "127.0.0.1" }); try { diff --git a/test/js/bun/util/BunObject.test.ts b/test/js/bun/util/BunObject.test.ts index 246c2d9f628e..2d7affeb3b86 100644 --- a/test/js/bun/util/BunObject.test.ts +++ b/test/js/bun/util/BunObject.test.ts @@ -35,6 +35,30 @@ test("await import('bun')", async () => { expect(BunESM.default).toBe(Bun); }); +test("{ ...Bun } and Object.entries(Bun) pass exception check validation", async () => { + // Release builds ignore the option, so they only check that the copy is complete. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const spread = { ...Bun }; + const entries = Object.entries(Bun); + const keys = Object.keys(Bun); + const mismatched = keys.filter(key => !(key in spread) || spread[key] !== Bun[key]); + console.log(JSON.stringify({ keys: keys.length > 0, entries: entries.length === keys.length, mismatched }));`, + ], + env: { ...bunEnv, BUN_JSC_validateExceptionChecks: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: JSON.stringify({ keys: true, entries: true, mismatched: [] }), + stderr: "", + exitCode: 0, + }); +}); + test("a lazy property whose builtin fails to load throws from the read", async () => { // The shell builtin ($) and the sql module body (sql, SQL, postgres) call Symbol(), so // breaking it makes each builder throw. The read must throw that error (debug builds used to diff --git a/test/js/bun/util/arraybuffersink.test.ts b/test/js/bun/util/arraybuffersink.test.ts index 1c03ada5c71d..7342fc2e54ec 100644 --- a/test/js/bun/util/arraybuffersink.test.ts +++ b/test/js/bun/util/arraybuffersink.test.ts @@ -183,4 +183,30 @@ describe("ArrayBufferSink", () => { if (exitCode !== 0) expect(stderr).toBe(""); expect(exitCode).toBe(0); }); + + it("write() throws when converting the chunk to a string closes the sink", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + for (const ret of ["payload", "pay\u4f60"]) { + const sink = new Bun.ArrayBufferSink(); + sink.start({ highWaterMark: 64 }); + sink.write("seed"); + const chunk = Object.assign(new String("x"), { [Symbol.toPrimitive]() { sink.close(); return ret; } }); + try { console.log(sink.write(chunk)); } + catch (e) { console.log(/already been closed/.test(e.message)); } + } + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("true\ntrue\n"); + if (exitCode !== 0) expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); }); diff --git a/test/js/bun/util/bun-file.test.ts b/test/js/bun/util/bun-file.test.ts index 5fb85b6459d9..129d83df99da 100644 --- a/test/js/bun/util/bun-file.test.ts +++ b/test/js/bun/util/bun-file.test.ts @@ -155,3 +155,46 @@ test("Bun.file().json() with UTF-8 BOM does not free an interior pointer", async }); expect(exitCode).toBe(0); }); + +test("Bun.file() copies a Uint8Array path", async () => { + using dir = tempDir("bun-file-buffer-path", { "a.txt": "a", "b.txt": "b" }); + const path = new TextEncoder().encode(join(String(dir), "a.txt")); + const file = Bun.file(path); + path[path.length - "a.txt".length] = "b".charCodeAt(0); + expect({ name: file.name, text: await file.text() }).toEqual({ name: join(String(dir), "a.txt"), text: "a" }); +}); + +test("a Uint8Array path can still be transferred after Bun.file()", async () => { + using dir = tempDir("bun-file-buffer-path", { "a.txt": "a" }); + const path = new TextEncoder().encode(join(String(dir), "a.txt")); + const file = Bun.file(path); + path.buffer.transfer(); + expect({ byteLength: path.byteLength, text: await file.text() }).toEqual({ byteLength: 0, text: "a" }); +}); + +test.concurrent.each([ + "Bun.file(path.slice())", + "s3.file(path.slice())", + 'await Bun.write(path.slice(), new Blob(["x"]))', +])("%s doesn't keep the Uint8Array path alive", async call => { + using dir = tempDir("bun-file-buffer-path", {}); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const path = new TextEncoder().encode(require("path").join(process.argv[1], "x")); + const s3 = new Bun.S3Client({ accessKeyId: "a", secretAccessKey: "b", bucket: "c" }); + for (let i = 0; i < 200; i++) ${call}; + Bun.gc(true); + console.log(require("bun:jsc").heapStats().objectTypeCounts.Uint8Array, path.length > 0);`, + String(dir), + ], + env: bunEnv, + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + const [live, pathIsAlive] = stdout.trim().split(" "); + expect(pathIsAlive).toBe("true"); + expect(Number(live)).toBeWithin(1, 50); + expect(exitCode).toBe(0); +}); diff --git a/test/js/bun/util/error-message-string-length-limit.test.ts b/test/js/bun/util/error-message-string-length-limit.test.ts index c92e115dcc5d..2bce5b06277e 100644 --- a/test/js/bun/util/error-message-string-length-limit.test.ts +++ b/test/js/bun/util/error-message-string-length-limit.test.ts @@ -76,3 +76,44 @@ test.skipIf(totalmem() < 10 * 1024 ** 3)( }, 30_000, ); + +test("a native error whose message exceeds the string length limit is thrown with a fallback message", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + import { setSyntheticAllocationLimitForTesting } from "bun:internal-for-testing"; + import { expect } from "bun:test"; + setSyntheticAllocationLimitForTesting(1024 * 1024); + const long = Buffer.alloc(1024 * 1024 - 16, "a").toString(); + const cases = { + "Bun.listen": () => Bun.listen({ unix: long, socket: { data() {} } }), + "Bun.JSONC.parse": () => Bun.JSONC.parse(long), + "expect": () => expect(1, long).toBe(2), + }; + for (const [name, run] of Object.entries(cases)) { + try { + run(); + console.log(name + ": did not throw"); + } catch (e) { + console.log(name + ": " + e.name + ": " + e.message); + } + } + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim().split("\n"), stderr, exitCode }).toEqual({ + stdout: [ + "Bun.listen: Error: The error message exceeds the maximum string length", + "Bun.JSONC.parse: SyntaxError: The error message exceeds the maximum string length", + "expect: Error: The error message exceeds the maximum string length", + ], + stderr: "", + exitCode: 0, + }); +}); diff --git a/test/js/bun/util/filesink.test.ts b/test/js/bun/util/filesink.test.ts index 3e9c9ae4317f..720ceb319527 100644 --- a/test/js/bun/util/filesink.test.ts +++ b/test/js/bun/util/filesink.test.ts @@ -167,8 +167,10 @@ describe("FileSink", () => { } }); +import { spawn, type SpawnOptions } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; +import type { Readable } from "node:stream"; import util from "node:util"; it("end doesn't close when backed by a file descriptor", async () => { @@ -598,7 +600,7 @@ it("start() with a path/fd getter that closes the writer throws instead of crash expect(exitCode).toBe(0); }); -it.skipIf(!isPosix)("writing after end() fails during flush does not crash", async () => { +it("writing after end() fails during flush does not crash", async () => { const dir = tmpdirSync(); const target = join(dir, "ro.txt"); fs.writeFileSync(target, ""); @@ -1472,3 +1474,158 @@ it.skipIf(!isPosix)("a write() that fails while another is pending rejects the p if (readFdOpen) fs.closeSync(readFd); } }); + +describe.concurrent("start() on an open writer", () => { + // `closed()` checks that every handle to a file in `dir` has been closed. Windows won't rename + // a directory while a file inside it is open, and on Linux it scans /proc/self/fd. + const prelude = ` + const fs = require("node:fs"); + const { join } = require("node:path"); + const dir = join(process.argv[1], "files"); + fs.mkdirSync(dir); + function openFilesInDir() { + if (process.platform !== "linux") return 0; + return fs.readdirSync("/proc/self/fd").filter(fd => { + try { + return fs.readlinkSync("/proc/self/fd/" + fd).includes(dir); + } catch { + return false; + } + }).length; + } + async function closed() { + const deadline = Date.now() + 10_000; + for (;;) { + try { + fs.renameSync(dir, dir + ".closed"); + break; + } catch (e) { + if (e.code !== "EPERM" || Date.now() > deadline) return "rename: " + e.code; + await Bun.sleep(1); + } + } + while (openFilesInDir() > 0 && Date.now() < deadline) await Bun.sleep(1); + return openFilesInDir() === 0 ? "all" : "open: " + openFilesInDir(); + } + function read(path) { + return fs.readFileSync(path, "utf8"); + } + `; + + async function run(script: string) { + using dir = tempDir("filesink-restart", {}); + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", prelude + script, String(dir)], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toMatchObject({ stderr: "", exitCode: 0 }); + return JSON.parse(stdout); + } + + it("start({ path }) writes to the new path and closes the old file", async () => { + const result = await run(` + const initial = join(dir, "initial.txt"); + const target = join(dir, "target.txt"); + const writer = Bun.file(initial).writer(); + writer.start({ path: target }); + writer.write("short"); + await writer.end(); + const out = { target: read(target), initial: read(initial) }; + console.log(JSON.stringify({ ...out, closed: await closed() })); + `); + expect(result).toEqual({ target: "short", initial: "", closed: "all" }); + }); + + it("start({ path }) settles a queued write and flushes it to the new path", async () => { + // On Windows the first chunk is in flight and the second waits in the writer's queue. + const result = await run(` + const a = join(dir, "a.txt"); + const b = join(dir, "b.txt"); + const writer = Bun.file(a).writer(); + writer.write("first"); + const queued = writer.write("second"); + writer.start({ path: b }); + const settled = typeof (await queued) === "number"; + writer.write("third"); + await writer.end(); + const deadline = Date.now() + 10_000; + while (read(a) + read(b) !== "firstsecondthird" && Date.now() < deadline) await Bun.sleep(1); + const out = { settled, combined: read(a) + read(b), bEndsWithQueued: read(b).endsWith("secondthird") }; + console.log(JSON.stringify({ ...out, closed: await closed() })); + `); + expect(result).toEqual({ settled: true, combined: "firstsecondthird", bEndsWithQueued: true, closed: "all" }); + }); + + it.skipIf(!isWindows)("start({ path }) while a pipe write is in flight throws EBUSY and keeps the pipe", async () => { + using dir = tempDir("filesink-restart-pipe", {}); + const script = ` + const writer = Bun.file(3).writer(); + writer.write("x"); + let code = null; + try { + writer.start({ path: ${JSON.stringify(join(String(dir), "target.txt"))} }); + } catch (e) { + code = e.code; + } + writer.write("y"); + await writer.end(); + console.log(JSON.stringify({ code })); + `; + const options: SpawnOptions = { stdio: ["ignore", "pipe", "pipe", "pipe"], env: bunEnv as NodeJS.ProcessEnv }; + const child = spawn(bunExe(), ["-e", script], options); + let stdout = ""; + let stderr = ""; + let pipe = ""; + child.stdout!.on("data", d => (stdout += d)); + child.stderr!.on("data", d => (stderr += d)); + (child.stdio[3] as Readable).on("data", d => (pipe += d)); + const { promise: closed, resolve, reject } = Promise.withResolvers<number | null>(); + child.on("close", resolve); + child.on("error", reject); + const exitCode = await closed; + expect({ stdout: stdout.trim(), stderr, pipe, exitCode }).toEqual({ + stdout: '{"code":"EBUSY"}', + stderr: "", + pipe: "xy", + exitCode: 0, + }); + }); +}); + +it("a writer's FileSink class can't be constructed from JS", async () => { + using dir = tempDir("filesink-constructor", {}); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` +const writer = Bun.file("out.txt").writer(); +const FileSink = writer.constructor; +for (const make of [() => new FileSink(), () => FileSink(), () => Reflect.construct(FileSink, [], class {})]) { + try { + make().write("x"); + console.log("returned"); + } catch (e) { + console.log(e.code); + } +} +writer.write("written"); +await writer.end(); +console.log(await Bun.file("out.txt").text()); +`, + ], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim().split("\n"), stderr, exitCode }).toEqual({ + stdout: ["ERR_ILLEGAL_CONSTRUCTOR", "ERR_ILLEGAL_CONSTRUCTOR", "ERR_ILLEGAL_CONSTRUCTOR", "written"], + stderr: "", + exitCode: 0, + }); +}); diff --git a/test/js/bun/util/filesystem_router.test.ts b/test/js/bun/util/filesystem_router.test.ts index 4827d1404efd..2e22b5eddfcd 100644 --- a/test/js/bun/util/filesystem_router.test.ts +++ b/test/js/bun/util/filesystem_router.test.ts @@ -1,4 +1,5 @@ import { FileSystemRouter } from "bun"; +import { heapStats } from "bun:jsc"; import { expect, it } from "bun:test"; import fs, { mkdirSync, rmSync } from "fs"; import { bunEnv, bunExe, isASAN, isMacOS, isWindows, normalizeBunSnapshot, tempDir, tmpdirSync } from "harness"; @@ -231,6 +232,15 @@ it("should support catch-all routes", () => { } }); +it.each([ + [["[team]/settings.tsx", "[team]/[section]/[item].tsx"], { team: "bob", section: "settings", item: "profile" }], + [["[a]/settings.tsx", "[z]/[b]/[c].tsx"], { z: "bob", b: "settings", c: "profile" }], +])("params from a route that didn't match aren't included in the result: %j", (files, params) => { + const { dir } = make(files); + const router = new Bun.FileSystemRouter({ dir, style: "nextjs" }); + expect(router.match("/bob/settings/profile")!.params).toEqual(params); +}); + it("should support index routes", () => { // set up the test const { dir } = make(["index.tsx", "posts/[id].tsx", "posts.tsx", "posts/hey.tsx"]); @@ -479,6 +489,27 @@ it("reload() works with new dirs/files", () => { expect(router.match("/test/test2")!.name).toBe("/test/test2"); }); +it("the constructor and reload() don't create allocator heaps", () => { + const { dir } = make(["index.tsx", "posts/[id].tsx"]); + const heapsCreatedBy = (fn: () => void) => { + const before = heapStats().mimalloc.heaps.total; + for (let i = 0; i < 50; i++) fn(); + return heapStats().mimalloc.heaps.total - before; + }; + + for (const fileExtensions of [undefined, [".tsx", ".ts"]]) { + const create = () => new FileSystemRouter({ dir, style: "nextjs", fileExtensions }); + // The first router warms the resolver's directory cache. + const router = create(); + router.reload(); + + // The allocator counts another thread's heaps late, so a few can show up here. + expect(heapsCreatedBy(create)).toBeLessThan(10); + expect(heapsCreatedBy(() => router.reload())).toBeLessThan(10); + expect(Object.keys(router.routes).sort()).toEqual(["/", "/posts/[id]"]); + } +}); + it(".query works with dynamic routes, including params", () => { // set up the test const { dir } = make(["posts/[id].tsx"]); @@ -542,6 +573,13 @@ it("dir should be validated", async () => { }).toThrow("FileSystemRouterOptions.dir must be a string"); }); +it("rejects a dir with a null byte", () => { + const { dir } = make(["index.tsx"]); + expect(() => new FileSystemRouter({ style: "nextjs", dir: `${dir}\0suffix` })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_VALUE" }), + ); +}); + it("origin should be validated", async () => { const { dir } = make(["posts.tsx"]); @@ -1219,3 +1257,58 @@ it("an absolute dir with '..' in it names routes like its normalized spelling", exitCode: 0, }); }); + +it("throws ERR_STRING_TOO_LONG for an origin or assetPrefix over the string limit", async () => { + using dir = tempDir("fsr-string-limit", { "index.tsx": "export default 1;" }); + const code = /* js */ ` + const dir = process.argv[1]; + const LIMIT = 64 * 1024; + const pad = (prefix, len) => prefix + Buffer.alloc(len - prefix.length, "x").toString(); + const attempt = (key, value) => { + try { + new Bun.FileSystemRouter({ dir, style: "nextjs", [key]: value }); + return "ok"; + } catch (e) { + return e?.code ?? e?.name; + } + }; + console.log( + JSON.stringify({ + originAtLimit: attempt("origin", pad("https://a.example/", LIMIT)), + originPastLimit: attempt("origin", pad("https://b.example/", LIMIT + 1)), + originPastLimitAgain: attempt("origin", pad("https://b.example/", LIMIT + 1)), + assetPrefixPastLimit: attempt("assetPrefix", pad("/static/", LIMIT + 1)), + }), + ); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", code, String(dir)], + env: { ...bunEnv, BUN_FEATURE_FLAG_SYNTHETIC_MEMORY_LIMIT: String(64 * 1024) }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: JSON.parse(stdout || "null"), stderr, exitCode }).toEqual({ + stdout: { + originAtLimit: "ok", + originPastLimit: "ERR_STRING_TOO_LONG", + originPastLimitAgain: "ERR_STRING_TOO_LONG", + assetPrefixPastLimit: "ERR_STRING_TOO_LONG", + }, + stderr: "", + exitCode: 0, + }); +}); + +it(".query and .params support names that look like array indices", () => { + const { dir } = make(["posts.tsx", "[0].tsx"]); + const router = new Bun.FileSystemRouter({ dir, style: "nextjs" }); + + const query = router.match("/posts?0=a&0=b&1=c&x=y")!.query; + expect(query).toEqual({ 0: ["a", "b"], 1: "c", x: "y" }); + expect(query[0]).toEqual(["a", "b"]); + + const params = router.match("/hello")!.params; + expect(params).toEqual({ 0: "hello" }); + expect(params[0]).toBe("hello"); +}); diff --git a/test/js/bun/util/heap-snapshot.test.ts b/test/js/bun/util/heap-snapshot.test.ts index 8cf3f6046536..f7dfe6d7f24f 100644 --- a/test/js/bun/util/heap-snapshot.test.ts +++ b/test/js/bun/util/heap-snapshot.test.ts @@ -224,3 +224,25 @@ describe("CommonJS Module cached slots are visible in heap snapshots", () => { expect(exitCode).toBe(0); }); }); + +it("console.takeHeapSnapshot() can be called repeatedly", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + for (let i = 0; i < 3; i++) { + console.takeHeapSnapshot(); + Bun.gc(true); + } + console.error("OK"); + `, + ], + env: { ...bunEnv, BUN_JSC_validateExceptionChecks: "1" }, + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toBe("OK\n"); + expect(exitCode).toBe(0); +}); diff --git a/test/js/bun/util/inspect-error.test.js b/test/js/bun/util/inspect-error.test.js index 41537100f000..d42114e9a8a1 100644 --- a/test/js/bun/util/inspect-error.test.js +++ b/test/js/bun/util/inspect-error.test.js @@ -510,3 +510,89 @@ describe.concurrent("AggregateError whose errors cannot be walked", () => { expect(exitCode).toBe(1); }); }); + +describe("an error with a circular reference to itself", () => { + // Drops the source preview, stack frames, blank lines and the version line. + function rendered(text) { + return text + .split("\n") + .filter(line => !/^\s*\d+ \| /.test(line) && !/^\s*\^\s*$/.test(line) && !/^\s+at /.test(line)) + .filter(line => line.trim() !== "" && !line.startsWith("Bun v")); + } + + test("prints [Circular] under the key that closes the cycle", () => { + const self = new Error("x"); + self.self = self; + expect(rendered(Bun.inspect(self))).toEqual(["error: x", " self: [Circular],"]); + + const a = new Error("a"); + a.cause = new Error("b", { cause: a }); + expect(rendered(Bun.inspect(a))).toEqual(["error: a", "error: b", " cause: [Circular],"]); + + const outer = new Error("a"); + const inner = new Error("b"); + outer.x = [inner]; + inner.y = inner; + expect(rendered(Bun.inspect(outer))).toEqual(["error: a", " x: [", "error: b", " y: [Circular],", "],"]); + }); + + test("is printed once when thrown", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", 'const e = new Error("cyc"); e.cause = e; e.errors = [e]; throw e;'], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect({ lines: rendered(stderr), exitCode }).toEqual({ + lines: ["error: cyc", " cause: [Circular],", " errors: [", " [Circular]", "],"], + exitCode: 1, + }); + }); + + test("an error that appears twice without a cycle is printed in full each time", () => { + const e = new Error("twice"); + e.meta = {}; + const lines = rendered(Bun.inspect([e, { a: e, b: { c: e } }])); + expect(lines.filter(line => line.trim() === "error: twice").length).toBe(3); + expect(lines.filter(line => line.includes("[Circular]"))).toEqual([]); + }); +}); + +// `Malloc=1` lets ASAN catch a read past the end of the source. +test.concurrent("prints the source preview for an error positioned at the end of the source text", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", `(0, eval)("xyz");`], + env: { ...bunEnv, NO_COLOR: "1", Malloc: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, excerpt: stderr.split("\n").slice(0, 3) }).toEqual({ + stdout: "", + excerpt: ["1 | xyz", " ^", "ReferenceError: xyz is not defined"], + }); + expect(exitCode).toBe(1); +}); + +test.concurrent("reports the right position for a multi-line `new` expression in a 16-bit source", async () => { + const source = `/* 中 */ +function code() { + throw new (class Boom extends Error { + constructor(message) { + super(message); + } + })("boom"); +} +code();`; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", `(0, eval)(${JSON.stringify(source)});`], + env: { ...bunEnv, NO_COLOR: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe(""); + expect(stderr).toMatch(/error: boom\n +at code \(.*:3:9\)\n/); + expect(exitCode).toBe(1); +}); diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index 3549700bb920..f41ca41d67a1 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -531,6 +531,38 @@ it("Bun.inspect.custom exists", () => { expect(Bun.inspect.custom).toBe(util.inspect.custom); }); +it("native inspect.custom functions return undefined when called without a receiver", () => { + const classes = [ + ByteLengthQueuingStrategy, + CountQueuingStrategy, + CryptoKey, + PerformanceEntry, + ReadableByteStreamController, + ReadableStream, + ReadableStreamBYOBReader, + ReadableStreamBYOBRequest, + ReadableStreamDefaultController, + ReadableStreamDefaultReader, + TransformStream, + TransformStreamDefaultController, + URL, + URLSearchParams, + WritableStream, + WritableStreamDefaultController, + WritableStreamDefaultWriter, + ]; + const results = classes.map(klass => { + const inspectCustom = klass.prototype[util.inspect.custom]; + // When a closed-over binding is called bare, JSC passes the scope object as `this`. + const bare = () => inspectCustom(-1, {}); + // util.inspect falls back to its default formatting when inspect.custom returns its receiver unchanged. + const ordinaryReceiver = {}; + const roundTripped = inspectCustom.call(ordinaryReceiver, -1, {}) === ordinaryReceiver; + return [klass.name, typeof inspectCustom, bare(), roundTripped]; + }); + expect(results).toEqual(classes.map(klass => [klass.name, "function", undefined, true])); +}); + describe("Functions with names", () => { const closures = [ () => function f() {}, @@ -1322,3 +1354,141 @@ it("object property enumeration scales linearly with property count", () => { expect(l.ms / 30000 / (s.ms / 3000)).toBeLessThan(3); }); }); + +// Each case runs in a fresh child so the formatter hasn't loaded util.inspect before the failing call. +describe.concurrent("Bun.inspect when loading util.inspect for a custom inspect function throws", () => { + async function inspectInChild(code) { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const obj = { [Bun.inspect.custom]() { return "custom"; } }; + const styled = { [Bun.inspect.custom](depth, options) { return options.stylize("x", "number"); } }; + ${code} + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + it("throws while a global the module needs is replaced, and works once it's restored", async () => { + const result = await inspectInChild(` + const RealSymbol = Symbol; + globalThis.Symbol = 0; + try { + console.log(Bun.inspect(styled, { colors: true })); + } catch { + console.log("threw"); + } + globalThis.Symbol = RealSymbol; + console.log(Bun.inspect(obj)); + console.log(Bun.inspect(styled, { colors: true }) === "\\u001b[33mx\\u001b[39m"); + Bun.gc(true); + console.log(Bun.inspect(styled, { colors: true }) === "\\u001b[33mx\\u001b[39m"); + `); + expect(result).toEqual({ stdout: "threw\ncustom\ntrue\ntrue\n", stderr: "", exitCode: 0 }); + }); + + it("throws the RangeError at the stack limit, and works afterwards", async () => { + const result = await inspectInChild(` + let deep; + function recurse() { + try { + recurse(); + } catch { + try { + deep = Bun.inspect(obj); + } catch (e) { + deep = e; + } + } + } + recurse(); + console.log(String(deep)); + console.log(Bun.inspect(obj)); + `); + expect(result).toEqual({ + stdout: "RangeError: Maximum call stack size exceeded.\ncustom\n", + stderr: "", + exitCode: 0, + }); + }); + + it("passes the real inspect function while util.inspect is replaced", async () => { + const result = await inspectInChild(` + const util = require("node:util"); + const realInspect = util.inspect; + util.inspect = 42; + const receives = { [Bun.inspect.custom](depth, options, inspect) { return String(inspect === realInspect); } }; + console.log(Bun.inspect(receives)); + `); + expect(result).toEqual({ stdout: "true\n", stderr: "", exitCode: 0 }); + }); +}); + +// Runs in a subprocess because a native stack overflow would kill the test runner. +it.concurrent.each([ + [ + "circular jsx", + `const children = { $$typeof: Symbol.for("react.element"), type: "div", key: null }; + children.props = { children }; + console.log(Bun.inspect(children)); + const key = { $$typeof: Symbol.for("react.element"), type: "div", props: {} }; + key.key = key; + console.log(Bun.inspect(key));`, + "<div>\n [Circular]\n</div>\n<div key=[Circular] />", + ], + [ + "a deep Proxy chain", + `let proxy = { ok: 1 }; + for (let i = 0; i < 100000; i++) proxy = new Proxy(proxy, {}); + console.log(Bun.inspect(proxy));`, + "{\n ok: 1,\n}", + ], + [ + "deep jsx", + `let element = { $$typeof: Symbol.for("react.element"), type: "div", props: {}, key: null }; + for (let i = 0; i < 100000; i++) + element = { $$typeof: Symbol.for("react.element"), type: "div", props: { children: element }, key: null }; + try { Bun.inspect(element); } catch (e) { console.log(e.name); }`, + "RangeError", + ], +])("Bun.inspect of %s doesn't overflow the stack", async (_, script, expected) => { + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.trim()).toBe(expected); + expect(exitCode).toBe(0); +}); + +// A substring rope's characters live in its base string, and only the rope references the base. +// Malloc=1 makes bmalloc use the system heap so ASAN can catch a read of freed characters. +it.skipIf(!isASAN)("prints a substring-rope jsx type after a prop's inspect.custom resolves the rope", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const type = Buffer.from("_div-abcdefghijklmnopqrstuvwxyz").toString().slice(1); + const custom = { + [Bun.inspect.custom]() { + // Using the rope as a property key atomizes it, which copies its characters and drops the base. + ({})[type]; + Bun.gc(true); + return "x"; + }, + }; + const props = { custom, children: "text" }; + console.log(Bun.inspect({ $$typeof: Symbol.for("react.element"), type, key: null, props }));`, + ], + env: { ...bunEnv, Malloc: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toEndWith("</div-abcdefghijklmnopqrstuvwxyz>\n"); + expect(exitCode).toBe(0); +}); diff --git a/test/js/bun/util/open-in-editor-gc.test.ts b/test/js/bun/util/open-in-editor-gc.test.ts index 6e5dc67bea91..e0dd145d6fcb 100644 --- a/test/js/bun/util/open-in-editor-gc.test.ts +++ b/test/js/bun/util/open-in-editor-gc.test.ts @@ -236,3 +236,44 @@ test.skipIf(!isLinux)("Bun.openInEditor does not break GC signal handling", asyn await Promise.all(runs); }); + +test.skipIf(!isLinux)("signal handlers installed before a burst of Bun.openInEditor calls still work", async () => { + const sleep = ["/usr/bin/sleep", "/bin/sleep"].find(p => existsSync(p)); + expect(sleep).toBeDefined(); + + using dir = tempDir("open-in-editor-signal-table", { + "run.js": ` + let fired = false; + process.on("SIGUSR2", () => { fired = true; }); + + let spawned = 0; + for (let i = 0; i < 64; i++) { + try { Bun.openInEditor("0.1", { editor: ${JSON.stringify(sleep)} }); spawned++; } catch {} + } + if (spawned === 0) { console.log("no editor threads spawned"); process.exit(2); } + + // The listener's handler is restored once the last editor thread unregisters. + const deadline = Date.now() + 3000; + while (!fired && Date.now() < deadline) { + await Bun.sleep(20); + process.kill(process.pid, "SIGUSR2"); + } + console.log(fired ? "ok" : "handler lost"); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "run.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout.trim()).toBe("ok"); + expect(proc.signalCode).toBeNull(); + expect(exitCode).toBe(0); +}); diff --git a/test/js/bun/util/password.test.ts b/test/js/bun/util/password.test.ts index a38c2a83619f..7e4a3ccd07cd 100644 --- a/test/js/bun/util/password.test.ts +++ b/test/js/bun/util/password.test.ts @@ -1,6 +1,6 @@ import { password } from "bun"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isDebug } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, isLinux } from "harness"; const placeholder = "hey"; @@ -542,6 +542,74 @@ test("verify rejects encoded argon2 hashes with cost parameters above the suppor await expect(password.verify("correct horse", junkMemory)).rejects.toThrow("InvalidEncoding"); }); +// argon2 allocates memoryCost KiB up front. verify() reads the cost from the encoded hash. +describe("argon2 when memoryCost can't be allocated", () => { + const script = (memoryCost: number) => /* js */ ` + const { password } = Bun; + const describeError = e => ({ name: e.name, code: e.code, message: e.message }); + const options = { algorithm: "argon2id", memoryCost: ${memoryCost}, timeCost: 1 }; + const small = { algorithm: "argon2id", memoryCost: 8, timeCost: 1 }; + const encoded = password.hashSync("hunter2", small).replace("$m=8,", "$m=${memoryCost},"); + const results = {}; + try { + password.hashSync("hunter2", options); + results.hashSync = "resolved"; + } catch (e) { + results.hashSync = describeError(e); + } + results.hash = await password.hash("hunter2", options).then(() => "resolved", describeError); + try { + results.verifySync = password.verifySync("hunter2", encoded); + } catch (e) { + results.verifySync = describeError(e); + } + results.verify = await password.verify("hunter2", encoded).then(v => v, describeError); + results.afterwards = await password.verify("hunter2", await password.hash("hunter2", small)); + console.log(JSON.stringify(results)); + `; + const outOfMemory = (verb: string) => ({ + name: "Error", + code: "PASSWORD_OUT_OF_MEMORY", + message: `Password ${verb} failed with error "OutOfMemory"`, + }); + const expected = { + hashSync: outOfMemory("hashing"), + hash: outOfMemory("hashing"), + verifySync: outOfMemory("verification"), + verify: outOfMemory("verification"), + afterwards: true, + }; + + async function runChild(cmd: string[], env: NodeJS.Dict<string>) { + await using proc = Bun.spawn({ cmd, env, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // stderr isn't checked because ASAN prints a warning for every allocation it rejects. + return { result: JSON.parse(stdout.trim() || JSON.stringify({ stdout, stderr, exitCode })), exitCode }; + } + + test.skipIf(!isASAN)("throws PASSWORD_OUT_OF_MEMORY (ASAN allocation cap)", async () => { + const { result, exitCode } = await runChild([bunExe(), "-e", script(65536)], { + ...bunEnv, + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "allocator_may_return_null=1", "max_allocation_size_mb=32"] + .filter(Boolean) + .join(":"), + }); + expect(result).toEqual(expected); + expect(exitCode).toBe(0); + }); + + test.skipIf(isASAN || !isLinux)("throws PASSWORD_OUT_OF_MEMORY (address-space limit)", async () => { + // 4 GiB is the largest cost verify() accepts. ASAN builds can't start under this limit. + const gib = 1024 * 1024; // in KiB, the unit of both ulimit -v and memoryCost + const { result, exitCode } = await runChild( + ["/bin/sh", "-c", `ulimit -v ${4 * gib} && exec "$0" "$@"`, bunExe(), "-e", script(4 * gib)], + bunEnv, + ); + expect(result).toEqual(expected); + expect(exitCode).toBe(0); + }); +}); + test("verifySync reads the password buffer only after every argument has been coerced", () => { const hashed = password.hashSync("correct horse", { algorithm: "argon2id", memoryCost: 8, timeCost: 1 }); const passwordBytes = new TextEncoder().encode("correct horse"); diff --git a/test/js/bun/util/reportError.test.ts b/test/js/bun/util/reportError.test.ts index 3075af55f04f..d27fd16c9897 100644 --- a/test/js/bun/util/reportError.test.ts +++ b/test/js/bun/util/reportError.test.ts @@ -1,6 +1,6 @@ import { spawnSync } from "bun"; import { expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, normalizeBunSnapshot } from "harness"; import { join } from "path"; test("reportError", () => { @@ -122,3 +122,50 @@ test("native error printer handles lone surrogates in message and stack frame na expect(proc.signalCode).toBeNull(); expect(exitCode).toBe(1); }); + +test.concurrent.each([ + ["Proxy chain", "let a = {}; for (let i = 0; i < 150_000; i++) a = new Proxy(a, {}); throw a;"], + [ + "chain of errors in an own property", + "let e = new Error('root'); for (let i = 0; i < 20_000; i++) { const next = new Error('wrap'); next.extra = e; e = next; } throw e;", + ], +])("native error printer stops at the stack limit for an uncaught %s", async (_, src) => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + env: bunEnv, + stdout: "ignore", + stderr: "ignore", + }); + await proc.exited; + expect(proc.signalCode).toBeNull(); + expect(proc.exitCode).toBe(1); +}); + +const throwsWhenPrinted = `Object.assign(new String("q"), { toString() { throw 1; }, [Symbol.toPrimitive]() { throw 1; } })`; + +test.concurrent.each([ + { + name: "reportError(value)", + script: `reportError(${throwsWhenPrinted}); console.log("after");`, + expected: { stdout: "after\n", stderr: "error\n\nBun v<bun-version>", exitCode: 1 }, + }, + { + name: "unhandled rejection", + script: `Promise.reject(${throwsWhenPrinted});`, + expected: { stdout: "", stderr: "error\n\nBun v<bun-version>", exitCode: 1 }, + }, + { + name: "AggregateError member", + script: `console.log(Bun.inspect(new AggregateError([${throwsWhenPrinted}, new Error("second member")], "agg")).includes("second member"));`, + expected: { stdout: "true\n", stderr: "", exitCode: 0 }, + }, +])("error printer doesn't leave an exception pending when the value throws: $name", async ({ script, expected }) => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr: normalizeBunSnapshot(stderr), exitCode }).toEqual(expected); +}); diff --git a/test/js/bun/util/sliceAnsi.test.ts b/test/js/bun/util/sliceAnsi.test.ts index 2b16d227040f..c800458624df 100644 --- a/test/js/bun/util/sliceAnsi.test.ts +++ b/test/js/bun/util/sliceAnsi.test.ts @@ -1673,4 +1673,55 @@ describe("Bun.sliceAnsi", () => { }); }); }); + + // The slice reopens the active styles and the hyperlink, closes them at the end and adds the ellipsis, + // so the result can be longer than the input. + test("throws a RangeError when the result exceeds the string length limit", async () => { + const outOfMemory = "RangeError: Out of memory"; + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const red = "\\x1b[31m"; + const ellipsis = "\\u2026" + "\\u200b".repeat(10); // width 1, 11 characters + const cases = { + sgrAtLimit: [red + Buffer.alloc(65527, "a").toString(), 1], + sgrPastLimit: [red + Buffer.alloc(65528, "a").toString(), 1], + wideSgrPastLimit: [red + "\\u3042".repeat(65528), 2], + ellipsisBothAtLimit: [Buffer.alloc(65518, "a").toString(), 1, 65517, ellipsis], + ellipsisBothPastLimit: [Buffer.alloc(65519, "a").toString(), 1, 65518, ellipsis], + linkAtLimit: ["\\x1b]8;;" + Buffer.alloc(65523, "u").toString() + "\\x07ab", 1], + linkPastLimit: ["\\x1b]8;;" + Buffer.alloc(65524, "u").toString() + "\\x07ab", 1], + shortSliceOfLongInput: [red + Buffer.alloc(70000, "a").toString(), 0, 4, ellipsis], + }; + const results = {}; + for (const [name, args] of Object.entries(cases)) { + try { + results[name] = Bun.sliceAnsi(...args).length; + } catch (e) { + results[name] = e.name + ": " + e.message; + } + } + console.log(JSON.stringify(results));`, + ], + env: { ...bunEnv, BUN_FEATURE_FLAG_SYNTHETIC_MEMORY_LIMIT: String(64 * 1024) }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: JSON.parse(stdout || "null"), stderr, exitCode }).toEqual({ + stdout: { + sgrAtLimit: 65536, + sgrPastLimit: outOfMemory, + wideSgrPastLimit: outOfMemory, + ellipsisBothAtLimit: 65536, + ellipsisBothPastLimit: outOfMemory, + linkAtLimit: 65536, + linkPastLimit: outOfMemory, + shortSliceOfLongInput: 5 + 3 + 11 + 5, + }, + stderr: "", + exitCode: 0, + }); + }); }); diff --git a/test/js/bun/util/utf8-conversion-limit.test.ts b/test/js/bun/util/utf8-conversion-limit.test.ts index aca28f77f3e9..714fe4a1072e 100644 --- a/test/js/bun/util/utf8-conversion-limit.test.ts +++ b/test/js/bun/util/utf8-conversion-limit.test.ts @@ -19,7 +19,7 @@ import { decodeURIComponentSIMD } from "bun:internal-for-testing"; import { Database } from "bun:sqlite"; import { expect, test } from "bun:test"; -import { bunEnv, bunExe, tls } from "harness"; +import { bunEnv, bunExe, isDebug, isWindows, tls } from "harness"; import crypto from "node:crypto"; import Module from "node:module"; import { totalmem } from "node:os"; @@ -42,6 +42,10 @@ const fixture = ` "hkdfSync salt": text => crypto.hkdfSync("sha256", "key", text, "info", 8), "hkdfSync info": text => crypto.hkdfSync("sha256", "key", "salt", text, 8), "hkdf salt": text => crypto.hkdf("sha256", "key", text, "info", 8, () => {}), + "createHash": text => crypto.createHash(text), + "getCipherInfo": text => crypto.getCipherInfo(text), + "createECDH": text => crypto.createECDH(text), + "generateKeyPairSync namedCurve": text => crypto.generateKeyPairSync("ec", { namedCurve: text }), "require.resolve.paths": text => require.resolve.paths(text), "Module._resolveLookupPaths": text => Module._resolveLookupPaths(text, { paths: ["/node_modules"] }), "Database#run": text => db.run(text), @@ -50,6 +54,17 @@ const fixture = ` "decodeURIComponentSIMD": text => decodeURIComponentSIMD(text), // The value of a cookie is converted only when the header has a "%" in it. "new Bun.CookieMap": text => new Bun.CookieMap("a=%41" + text), + ...(${isWindows} ? {} : { + "process.initgroups user": text => process.initgroups(text, 0), + // The message includes the whole name, so only print the code. + "process.setuid": text => { + try { + process.setuid(text); + } catch (e) { + return e.code; + } + }, + }), }; let text = "\\u00e9".repeat(2 ** 30); for (const [name, run] of Object.entries(cases)) { @@ -70,6 +85,9 @@ const fixture = ` const tooBigForSQLite = e.message === "string or blob too big"; console.log("ASCII parameter: " + (tooBigForSQLite ? "SQLite got the string" : e.name + ": " + e.message)); } + + // Debug builds need 14 seconds to hash 1 GiB. + if (!${isDebug}) console.log("console.countReset: returned " + console.countReset("q".repeat(2 ** 30))); `; // The length is what is under test, so the child holds a string of 1 GiB, and a @@ -97,6 +115,10 @@ test.skipIf(totalmem() < 8 * 1024 ** 3)( "hkdfSync salt: RangeError: Out of memory", "hkdfSync info: RangeError: Out of memory", "hkdf salt: RangeError: Out of memory", + "createHash: Error: Digest method not supported", + "getCipherInfo: returned undefined", + "createECDH: TypeError: Invalid EC curve name", + "generateKeyPairSync namedCurve: TypeError: Invalid EC curve name", "require.resolve.paths: returned an array", "Module._resolveLookupPaths: returned an array", "Database#run: RangeError: Out of memory", @@ -104,7 +126,11 @@ test.skipIf(totalmem() < 8 * 1024 ** 3)( "Statement#get parameter: RangeError: Out of memory", "decodeURIComponentSIMD: RangeError: Out of memory", "new Bun.CookieMap: RangeError: Out of memory", + ...(isWindows + ? [] + : ["process.initgroups user: RangeError: Out of memory", "process.setuid: returned ERR_UNKNOWN_CREDENTIAL"]), "ASCII parameter: SQLite got the string", + ...(isDebug ? [] : ["console.countReset: returned undefined"]), ], stderr: "", exitCode: 0, diff --git a/test/js/bun/util/wrapAnsi.test.ts b/test/js/bun/util/wrapAnsi.test.ts index c53aaa0561ba..07f22b36d546 100644 --- a/test/js/bun/util/wrapAnsi.test.ts +++ b/test/js/bun/util/wrapAnsi.test.ts @@ -1,3 +1,4 @@ +import { setSyntheticAllocationLimitForTesting } from "bun:internal-for-testing"; import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe } from "harness"; @@ -1119,4 +1120,16 @@ describe("Bun.wrapAnsi", () => { expect(exitCode).toBe(0); }); }); + + test("throws when the output exceeds the string length limit", () => { + // Each of the 64 rows reopens the hyperlink. + const input = `\x1b]8;;http://example.com/${Buffer.alloc(100_000, "a")}\x07` + Buffer.alloc(64 * 2, "a "); + const originalLimit = setSyntheticAllocationLimitForTesting(1024 * 1024); + try { + expect(() => Bun.wrapAnsi(input, 1).length).toThrow("Out of memory"); + expect(() => Bun.wrapAnsi(input + "\u2603", 1).length).toThrow("Out of memory"); + } finally { + setSyntheticAllocationLimitForTesting(originalLimit); + } + }); }); diff --git a/test/js/bun/websocket/websocket-server-fail-close-codes.test.ts b/test/js/bun/websocket/websocket-server-fail-close-codes.test.ts index f220261162df..b710994f0401 100644 --- a/test/js/bun/websocket/websocket-server-fail-close-codes.test.ts +++ b/test/js/bun/websocket/websocket-server-fail-close-codes.test.ts @@ -2,8 +2,11 @@ // with 1002 (protocol error), 1007 (invalid data) or 1009 (too big) and the // error message as the reason. The close handler gets 1006 with the same // message. A peer can't send 1006, so it always means the server aborted. -import { describe, expect, test } from "bun:test"; -import { tls as cert } from "harness"; +import { socketFaultInjection as fault } from "bun:internal-for-testing"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tls as cert, isASAN } from "harness"; +import path from "node:path"; +import { deflateRawSync } from "node:zlib"; import { closePayload, encodeFrame, @@ -240,3 +243,180 @@ describe.concurrent.each(["ws", "wss"] as const)("%s: failing the connection", s }); }); }); + +// ASAN's per-allocation cap makes each allocation failure deterministic. `Malloc=1` routes WebKit's +// allocations through the same capped allocator, which the text case needs. +describe.skipIf(!isASAN)("when a receive buffer can't grow", () => { + const MiB = 1024 * 1024; + const CAP_MB = 4; + const diagnostic = "Received message that does not fit in memory"; + const deflate = { "Sec-WebSocket-Extensions": "permessage-deflate" }; + const deflated = (size: number) => deflateRawSync(Buffer.alloc(size, 7)); + const tooBig = Buffer.alloc(CAP_MB * MiB + 2 * MiB); + // Fills a buffer to exactly the cap, so appending one more byte has to grow it past the cap. + const full = Buffer.alloc(CAP_MB * MiB); + + type Mode = "ws" | "wss" | "ws-package"; + let proc: Bun.Subprocess<"ignore", "pipe", "pipe">; + let lines: AsyncGenerator<any>; + let stderr: Promise<string>; + let ports: Record<Mode, number>; + + async function* jsonLines(stream: ReadableStream<Uint8Array>) { + let pending = ""; + for await (const chunk of stream.pipeThrough(new TextDecoderStream())) { + pending += chunk; + for (let end; (end = pending.indexOf("\n")) !== -1; pending = pending.slice(end + 1)) { + yield JSON.parse(pending.slice(0, end)); + } + } + } + + // Returns the fixture's next output line. It prints one per close handler call and one per `ws` 'error' event. + async function nextEvent() { + const { value, done } = await lines.next(); + if (done) throw new Error(`the server exited:\n${await stderr}`); + return value; + } + + const connect = (mode: Mode, headers?: Record<string, string>) => + RawClient.upgrade(ports[mode], { headers, secure: mode === "wss" }); + + // All rows share one server process, because it has to keep serving after it rejects a message. + beforeAll(async () => { + proc = Bun.spawn({ + cmd: [bunExe(), path.join(import.meta.dir, "websocket-server-oom-fixture.ts")], + env: { + ...bunEnv, + Malloc: "1", + WS_OOM_TLS: JSON.stringify(cert), + // With `Malloc=1`, LeakSanitizer would report WebKit's never-freed startup allocations. + ASAN_OPTIONS: [ + bunEnv.ASAN_OPTIONS, + "allocator_may_return_null=1", + `max_allocation_size_mb=${CAP_MB}`, + "detect_leaks=0", + ] + .filter(Boolean) + .join(":"), + }, + stdout: "pipe", + // ASAN prints a warning for every failed allocation. stderr is drained and only shown if the server dies. + stderr: "pipe", + }); + stderr = proc.stderr.text(); + lines = jsonLines(proc.stdout); + ports = await nextEvent(); + }, 30_000); + afterAll(() => proc?.kill()); + + const rejected: [name: string, headers: Record<string, string> | undefined, send: (client: RawClient) => void][] = [ + // Fails to grow the fragment buffer. + ["a message larger than the cap", undefined, client => client.binary(tooBig)], + [ + "a fragmented message larger than the cap", + undefined, + client => { + client.binary(Buffer.alloc(3 * MiB), { fin: false }); + client.send(OP.continuation, Buffer.alloc(3 * MiB)); + }, + ], + // The fragment buffer also stores inflate's 9 bytes of padding and control frames split across reads. + ["a compressed message with no room left for its padding", deflate, client => client.binary(full, { rsv1: true })], + [ + "a split control frame after a fragment that fills the buffer", + undefined, + client => { + client.binary(full, { fin: false }); + client.write(client.frame(OP.ping, Buffer.alloc(100)).subarray(0, 50)); + }, + ], + // Fails to grow the inflate buffer, which all connections on the loop share. + [ + "a compressed message that inflates past the cap", + deflate, + client => client.binary(deflated(tooBig.length), { rsv1: true }), + ], + [ + "a compressed message whose last inflated chunk doesn't fit", + deflate, + client => client.binary(deflated(full.length + 1024), { rsv1: true }), + ], + // Fails to allocate the message handler's copy. One non-ASCII character makes the string 16-bit. + [ + "a text message whose UTF-16 copy is over the cap", + undefined, + client => client.text("\u00e9" + Buffer.alloc(3 * MiB - 2, "a").toString("latin1")), + ], + ]; + + // Checks that the server still accepts connections and the shared inflate buffer still works. + async function expectStillServing(scheme: "ws" | "wss") { + using client = await connect(scheme, deflate); + client.binary(Buffer.alloc(2 * MiB)); + client.binary(deflated(2 * MiB), { rsv1: true }); + client.close(1000); + expect(await client.remainingFrames()).toEqual([`text "got ${2 * MiB}"`, `text "got ${2 * MiB}"`, "close 1000"]); + expect(await nextEvent()).toEqual({ close: 1000, reason: "" }); + } + + describe.each(["ws", "wss"] as const)("%s", scheme => { + test.each(rejected)("%s fails that connection with 1011", async (_name, headers, send) => { + using client = await connect(scheme, headers); + send(client); + expect(await client.remainingFrames()).toEqual([`close 1011 ${JSON.stringify(diagnostic)}`]); + expect(await nextEvent()).toEqual({ close: 1006, reason: diagnostic }); + await expectStillServing(scheme); + }); + }); + + // Appending 1 MiB to 2.5 MiB first tries to double to 5 MiB, which fails, then falls back to exactly 3.5 MiB. + test("delivers a message when only an exact-size allocation succeeds", async () => { + using client = await connect("ws"); + client.binary(Buffer.alloc(2.5 * MiB), { fin: false }); + client.send(OP.continuation, Buffer.alloc(MiB)); + client.close(1000); + expect(await client.remainingFrames()).toEqual([`text "got ${3.5 * MiB}"`, "close 1000"]); + expect(await nextEvent()).toEqual({ close: 1000, reason: "" }); + }); + + // A Close frame would be queued behind the 3 MiB, in a buffer that would have to grow for it. + test.skipIf(!fault.available())("closes the connection without a Close frame when data is still unsent", async () => { + using client = await connect("ws"); + client.text("stall"); + client.binary(tooBig); + expect(await client.remainingFrames()).toEqual([]); + expect(await nextEvent()).toEqual({ close: 1006, reason: diagnostic }); + await expectStillServing("ws"); + }); + + // The `ws` package's server sockets use the same parser, but report the failure as an 'error' event. + describe("the ws package", () => { + const error = { error: "RangeError", message: "Out of memory", code: "ERR_MEMORY_ALLOCATION_FAILED" }; + + async function expectStillServing() { + using client = await connect("ws-package"); + client.text("hi"); + client.close(1000); + expect(await client.remainingFrames()).toEqual(['text "got 2"', "close 1000"]); + expect(await nextEvent()).toEqual({ close: 1000 }); + } + + test("emits 'error' and sends 1011", async () => { + using client = await connect("ws-package"); + client.binary(tooBig); + expect(await client.remainingFrames()).toEqual(["close 1011"]); + expect([await nextEvent(), await nextEvent()]).toEqual([error, { close: 1006 }]); + await expectStillServing(); + }); + + test.skipIf(!fault.available())("also emits 'error' when data is still unsent", async () => { + using client = await connect("ws-package"); + client.text("stall"); + client.binary(tooBig); + expect(await client.remainingFrames()).toEqual([]); + expect([await nextEvent(), await nextEvent()]).toEqual([error, { close: 1006 }]); + await expectStillServing(); + }); + }); +}); diff --git a/test/js/bun/websocket/websocket-server-oom-fixture.ts b/test/js/bun/websocket/websocket-server-oom-fixture.ts new file mode 100644 index 000000000000..8d847b7e4216 --- /dev/null +++ b/test/js/bun/websocket/websocket-server-oom-fixture.ts @@ -0,0 +1,66 @@ +// Servers for websocket-server-fail-close-codes.test.ts, which caps every allocation in this process. +// Prints the ports on the first line of stdout, then one JSON event per line. +import { socketFaultInjection as fault } from "bun:internal-for-testing"; +import { once } from "node:events"; +import type { AddressInfo } from "node:net"; +import { WebSocketServer } from "ws"; + +const emit = (event: object) => console.log(JSON.stringify(event)); + +// Queues 3 MiB that can't be sent. Every write is blocked until a socket closes. +let stalled = false; +function stall(send: (data: Buffer) => void) { + stalled = true; + fault.set({ syscall: "send", action: "zero", repeat: -1 }); + fault.set({ syscall: "writev", action: "zero", repeat: -1 }); + send(Buffer.alloc(3 * 1024 * 1024)); +} +function unstall() { + if (stalled) fault.clear(); + stalled = false; +} + +function serve(tls?: object) { + return Bun.serve({ + port: 0, + hostname: "127.0.0.1", + tls, + fetch(req, server) { + if (server.upgrade(req)) return; + return new Response("upgrade failed", { status: 400 }); + }, + websocket: { + perMessageDeflate: true, + message(ws, message) { + if (message === "stall") return stall(bytes => ws.send(bytes)); + ws.send(`got ${message.length}`); + }, + close(ws, code, reason) { + unstall(); + emit({ close: code, reason }); + }, + }, + }); +} + +const wsPackage = new WebSocketServer({ port: 0, host: "127.0.0.1" }); +wsPackage.on("connection", ws => { + ws.on("message", (data: Buffer, isBinary) => { + if (!isBinary && data.toString() === "stall") return stall(bytes => ws.send(bytes)); + ws.send(`got ${data.length}`); + }); + ws.on("error", (error: NodeJS.ErrnoException) => { + emit({ error: error.name, message: error.message, code: error.code }); + }); + ws.on("close", code => { + unstall(); + emit({ close: code }); + }); +}); +await once(wsPackage, "listening"); + +emit({ + ws: serve().port, + wss: serve(JSON.parse(process.env.WS_OOM_TLS!)).port, + "ws-package": (wsPackage.address() as AddressInfo).port, +}); diff --git a/test/js/bun/webview/fake-chrome-fixture.ts b/test/js/bun/webview/fake-chrome-fixture.ts index 781553d6ae35..0e4e9f1955d7 100644 --- a/test/js/bun/webview/fake-chrome-fixture.ts +++ b/test/js/bun/webview/fake-chrome-fixture.ts @@ -34,6 +34,21 @@ const navigateError = process.argv.find(a => a.startsWith("--navigate-error="))? // Page.navigate for a URL it cannot parse. const cdpErrorOn = process.argv.find(a => a.startsWith("--cdp-error-on="))?.slice("--cdp-error-on=".length); +// `--malformed=<kind>` omits or corrupts a field that real Chrome always sends. One kind per run: +// attach-no-session-id Target.attachToTarget replies with {} +// attach-bad-utf8 ... replies with a sessionId that isn't valid UTF-8 +// attach-non-ascii ... replies with a sessionId that's valid UTF-8 but not ASCII +// click-no-value the click(selector) check replies without result.value +// click-huge-value ... replies with a position too large for a double (1e999) +// detach-no-session-id Target.detachedFromTarget arrives with params {} +// event-bad-utf8 a page event arrives with an invalid UTF-8 sessionId +// navigate-bad-utf8 Page.navigate replies with an errorText that isn't valid UTF-8 +const malformed = process.argv.find(a => a.startsWith("--malformed="))?.slice("--malformed=".length); + +// Placeholder that sendWithInvalidUtf8() corrupts after encoding, since +// JSON.stringify can't emit invalid UTF-8. +const BAD_UTF8 = "not-utf8"; + const NO_REPLY = Symbol("no reply"); let commandsClosed = false; Object.assign(globalThis, { @@ -56,14 +71,31 @@ Object.assign(globalThis, { closeSync(COMMANDS); setInterval(() => {}, 2 ** 30); }, + // Returns every Input.dispatchMouseEvent this process has received, in order. + __fake_mouse_events() { + return mouseEvents; + }, }); -function send(message: unknown) { - const bytes = Buffer.from(JSON.stringify(message) + "\0"); +const mouseEvents: { type: string; x: number; y: number }[] = []; + +function sendBytes(bytes: Buffer) { let written = 0; while (written < bytes.length) written += writeSync(REPLIES, bytes, written, bytes.length - written); } +function send(message: unknown) { + sendBytes(Buffer.from(JSON.stringify(message) + "\0")); +} + +// Sends the message with BAD_UTF8's first byte overwritten by 0xff, which is +// never a valid UTF-8 byte. +function sendWithInvalidUtf8(message: unknown) { + const bytes = Buffer.from(JSON.stringify(message) + "\0"); + bytes[bytes.indexOf(BAD_UTF8)] = 0xff; + sendBytes(bytes); +} + let targets = 0; let loads = 0; @@ -81,22 +113,48 @@ async function handle(command: { id: number; method: string; params?: any; sessi case "Target.createTarget": return reply({ targetId: "T" + ++targets }); case "Target.attachToTarget": + if (malformed === "attach-no-session-id") return reply({}); + if (malformed === "attach-bad-utf8") return sendWithInvalidUtf8({ id, result: { sessionId: BAD_UTF8 } }); + if (malformed === "attach-non-ascii") return reply({ sessionId: "S\u0100" }); return reply({ sessionId: "S" + params.targetId.slice(1) }); case "Page.navigate": { if (navigateError) return reply({ frameId: "F", errorText: navigateError }); + if (malformed === "navigate-bad-utf8") { + return sendWithInvalidUtf8({ id, result: { frameId: "F", errorText: BAD_UTF8 }, sessionId }); + } const loaderId = "L" + ++loads; reply({ frameId: "F", loaderId }); event("Page.frameNavigated", { frame: { id: "F", loaderId, url: params.url, mimeType: "text/html" } }); event("Page.loadEventFired", { timestamp: loads }); + // Send one stray event per load, with a missing or unusable sessionId. + if (malformed === "detach-no-session-id") send({ method: "Target.detachedFromTarget", params: {} }); + if (malformed === "event-bad-utf8") { + sendWithInvalidUtf8({ method: "Page.loadEventFired", params: {}, sessionId: BAD_UTF8 }); + } return; } case "Page.captureScreenshot": return reply({ data: screenshotBase64 }); + case "Input.dispatchMouseEvent": + mouseEvents.push({ type: params.type, x: params.x, y: params.y }); + return reply({}); case "Runtime.evaluate": { if (params.expression === "document.title") { if (noTitleReply) return; return reply({ result: { type: "string", value: "fake chrome" } }); } + // This is the click(selector) actionability check. It needs a DOM, so + // reply with a fixed [cx, cy] instead of running it. + if (params.expression.includes("elementFromPoint")) { + if (malformed === "click-no-value") return reply({ result: { type: "undefined" } }); + // JSON.stringify encodes 1e999 (Infinity) as null, so splice the + // literal in by hand. + if (malformed === "click-huge-value") { + const frame = JSON.stringify({ id, result: { result: { type: "object", value: "HUGE" } }, sessionId }); + return sendBytes(Buffer.from(frame.replace('"HUGE"', "[1e999,0]") + "\0")); + } + return reply({ result: { type: "object", value: [12, 34] } }); + } let value: unknown; try { value = await (0, eval)(params.expression); diff --git a/test/js/bun/webview/webview-chrome-pipe.test.ts b/test/js/bun/webview/webview-chrome-pipe.test.ts index e010a2bb3aa0..48e5b6197194 100644 --- a/test/js/bun/webview/webview-chrome-pipe.test.ts +++ b/test/js/bun/webview/webview-chrome-pipe.test.ts @@ -255,6 +255,27 @@ test.concurrent("a navigation that Chrome fails with errorText rejects and fires }); }); +test.concurrent("navigate() rejects with a generic message when errorText isn't valid UTF-8", async () => { + const result = await runScenario(` + const view = new Bun.WebView({ + backend: { ...backend, argv: [...backend.argv, "--malformed=navigate-bad-utf8"] }, + width: 100, + height: 100, + }); + let failedMessage; + view.onNavigationFailed = e => { failedMessage = e.message; }; + const held = await outcome(view.navigate("http://fake/")); + const loadingAfterFail = view.loading; + view.close(); + print({ held, failedMessage, loadingAfterFail }); + `); + expect(result).toEqual({ + held: { rejected: "navigation failed" }, + failedMessage: "navigation failed", + loadingAfterFail: false, + }); +}); + // A CDP protocol error ({"error":{"code":-32000}}) can fail a navigation at // any stage: the attach chain, or Page.navigate itself (real Chrome answers // "Cannot navigate to invalid URL" this way). The constructor url has no @@ -336,6 +357,71 @@ test.concurrent("onNavigationFailed can retry navigate() immediately", async () expect(result).toBe("retry was accepted and failed too"); }); +// Commands are routed to a page by the sessionId that Target.attachToTarget returns. CDP session ids are ASCII. +for (const kind of ["attach-no-session-id", "attach-bad-utf8", "attach-non-ascii"]) { + test.concurrent(`an attach reply without a usable sessionId fails the navigation (${kind})`, async () => { + const result = await runScenario(` + const view = new Bun.WebView({ + backend: { ...backend, argv: [...backend.argv, "--malformed=${kind}"] }, + width: 100, + height: 100, + }); + const held = await outcome(view.navigate("http://fake/")); + const loadingAfterFail = view.loading; + view.close(); + print({ held, loadingAfterFail }); + `); + expect(result).toEqual({ held: { rejected: "malformed attach response" }, loadingAfterFail: false }); + }); +} + +for (const kind of ["detach-no-session-id", "event-bad-utf8"]) { + test.concurrent(`ignores an event without a usable sessionId (${kind})`, async () => { + const result = await runScenario(` + const view = new Bun.WebView({ + backend: { ...backend, argv: [...backend.argv, "--malformed=${kind}"] }, + width: 100, + height: 100, + }); + await view.navigate("http://fake/"); + print({ url: view.url, value: await view.evaluate("6 * 7") }); + view.close(); + `); + expect(result).toEqual({ url: "http://fake/", value: 42 }); + }); +} + +// click(selector) waits for the element in the page, which returns the element's center as [cx, cy]. +test.concurrent("click(selector) clicks at the position the page reported", async () => { + const result = await runScenario(` + const view = newView(); + await view.navigate("http://fake/"); + await view.click("#target", { timeout: 100 }); + print(await view.evaluate("__fake_mouse_events()")); + view.close(); + `); + expect(result).toEqual([ + { type: "mousePressed", x: 12, y: 34 }, + { type: "mouseReleased", x: 12, y: 34 }, + ]); +}); + +for (const kind of ["click-no-value", "click-huge-value"]) { + test.concurrent(`click() rejects when the reply has no usable position (${kind})`, async () => { + const result = await runScenario(` + const view = new Bun.WebView({ + backend: { ...backend, argv: [...backend.argv, "--malformed=${kind}"] }, + width: 100, + height: 100, + }); + await view.navigate("http://fake/"); + print(await outcome(view.click("#target", { timeout: 100 }))); + view.close(); + `); + expect(result).toEqual({ rejected: "malformed click response" }); + }); +} + // `bun test --isolate` replaces the global object between files. The transport // is bound to the global that spawned the browser, so it has to go with that // file: its open views are closed, their pending promises rejected, and the diff --git a/test/js/bun/webview/webview.test.ts b/test/js/bun/webview/webview.test.ts index 2ce5614802cb..e0db29f91866 100644 --- a/test/js/bun/webview/webview.test.ts +++ b/test/js/bun/webview/webview.test.ts @@ -1250,6 +1250,59 @@ itPersistentDataStore("persistent dataStore: localStorage survives across instan expect(got).toBe("survives"); }); +// Runs in a subprocess because aborting the shared host would fail every other view in this file. +it("dataStore.directory works, or only fails that view if WebKit doesn't have initWithDirectory:", async () => { + using dir = tempDir("webview-persist-old-webkit", {}); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const html = h => "data:text/html," + encodeURIComponent(h); + const settle = fn => fn().catch(e => e.message); + const ephemeral = new Bun.WebView({ width: 100, height: 100 }); + const persistent = new Bun.WebView({ + width: 100, + height: 100, + dataStore: { directory: ${JSON.stringify(String(dir))} }, + }); + const failed = []; + persistent.onNavigationFailed = e => failed.push(e.message); + const result = { + persistent: await settle(async () => { + await persistent.navigate(html("<body>persistent</body>")); + return "navigated"; + }), + ephemeral: await settle(async () => { + await ephemeral.navigate(html("<body>ephemeral</body>")); + return ephemeral.evaluate("document.body.textContent"); + }), + failed, + }; + persistent.close(); + ephemeral.close(); + console.log(JSON.stringify(result)); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout, stderr).toEndWith("}\n"); + const result = JSON.parse(stdout); + const unsupported = + 'dataStore.directory needs a newer WebKit (macOS 15.2 or later); use dataStore: "ephemeral" or backend: "chrome"'; + // Older macOS versions only have initWithDirectory: after a Safari update. + const supported = isMacOSVersionAtLeast(15.2) || result.persistent === "navigated"; + expect(result).toEqual( + supported + ? { persistent: "navigated", ephemeral: "ephemeral", failed: [] } + : { persistent: unsupported, ephemeral: "ephemeral", failed: [unsupported] }, + ); + expect(exitCode, stderr).toBe(0); +}); + it("ephemeral dataStore: localStorage does NOT survive across instances", async () => { using server = Bun.serve({ port: 0, diff --git a/test/js/bun/windows/appcontainer.test.ts b/test/js/bun/windows/appcontainer.test.ts index c98daaf770cc..42b094d8e379 100644 --- a/test/js/bun/windows/appcontainer.test.ts +++ b/test/js/bun/windows/appcontainer.test.ts @@ -317,6 +317,34 @@ async function main() { } })(); + // In an AppContainer, any call on a closed handle kills the process (exit 0xC0000008). When a socket + // is closed in its own data() handler, its libuv poll has to be closed before the socket. + r.closeInHandler = await (async () => { + let server; + try { + server = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data(socket) { socket.terminate(); } } }); + } catch (e) { + return "listen:" + (e.code || e); + } + try { + const closed = new Promise(resolve => { + const timer = setTimeout(() => resolve("timeout waiting for the reset"), 15000); + Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + socket: { + open(socket) { socket.write("x"); }, + data() {}, + close() { clearTimeout(timer); resolve("OK"); }, + }, + }).catch(e => { clearTimeout(timer); resolve("connect:" + (e.code || e)); }); + }); + return await closed; + } finally { + server.stop(true); + } + })(); + fs.writeFileSync("results.json", JSON.stringify(r)); } main().then( @@ -350,6 +378,9 @@ main().then( // classified outcome; this guards only an unset key, an unclassified // crash, or a served wrong body. Pin the value once container CI reports it. expect(String(r.serveFetch)).toMatch(/^(OK$|listen:|fetch:)/); + // Same loopback caveat as serveFetch. The exit code above is the real check, + // because touching a closed handle on the way out of data() kills the child. + expect(String(r.closeInHandler)).toMatch(/^(OK$|listen:|connect:)/); }, 90_000, ); diff --git a/test/js/bun/xml/xml.test.ts b/test/js/bun/xml/xml.test.ts index 99046ece8656..a135c8ba7c0c 100644 --- a/test/js/bun/xml/xml.test.ts +++ b/test/js/bun/xml/xml.test.ts @@ -1,5 +1,6 @@ import { XML } from "bun"; import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; // Hand-written coverage beyond the W3C conformance suite (xml-test-suite.test.ts): // the JS-facing API surface, the two result shapes, Bun-specific input types, @@ -50,6 +51,46 @@ describe("input types", () => { expect(XML.parse(sab)).toEqual(expected); }); + test("parses a SharedArrayBuffer that another thread is writing", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + /* js */ ` + const { Worker } = require("node:worker_threads"); + const text = "<r>" + "<aaaaaaaa/>".repeat(50) + "</r>"; + const bytes = new Uint8Array(new SharedArrayBuffer(text.length)); + bytes.set(Buffer.from(text)); + const writer = new Worker( + \`const { workerData: bytes, parentPort } = require("node:worker_threads"); + parentPort.postMessage("ready"); + // Every "a" of a name becomes a "b" and back, so the document stays well-formed. + for (;;) for (let i = 3; i < bytes.length - 4; i++) if (bytes[i] === 97 || bytes[i] === 98) bytes[i] ^= 3;\`, + { eval: true, workerData: bytes }, + ); + writer.once("message", () => { + for (let i = 0; i < 4000; i++) { + const { r } = Bun.XML.parse(bytes); + for (const key of Object.keys(r)) { + if (!Object.hasOwn(r, key.split("").join(""))) { + console.log("an equal string doesn't find the key", key); + process.exit(1); + } + } + } + console.log("done"); + process.exit(0); + }); + `, + ], + env: bunEnv, + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("done\n"); + expect(exitCode).toBe(0); + }); + test("a Blob whose bytes are not in memory is refused, and does not parse as nothing", () => { const s3 = new Bun.S3Client({ accessKeyId: "id", secretAccessKey: "secret", bucket: "bucket" }); for (const file of [Bun.file(import.meta.path), s3.file("key")]) { diff --git a/test/js/bun/yaml/yaml.test.ts b/test/js/bun/yaml/yaml.test.ts index 0dd67edfb87b..0eee940eec61 100644 --- a/test/js/bun/yaml/yaml.test.ts +++ b/test/js/bun/yaml/yaml.test.ts @@ -5702,3 +5702,38 @@ test("parse rejects an input of 2**31 bytes or more instead of panicking", () => 'The value of "input.byteLength" is out of range. It must be <= 2147483647. Received 2147483650', ); }); + +// With Malloc=1 the builder's buffer comes from the system allocator, so ASAN's allocation cap makes it fail to grow. +describe.concurrent.skipIf(!isASAN)("stringify throws when the string builder can't grow", () => { + test.each([ + ["before a capacity reservation", `Bun.YAML.stringify([big, 1], null, 2)`], + ["before a UTF-16 key", `Bun.YAML.stringify({ a: big, "日本": 1 })`], + ])("%s", async (_name, expression) => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const big = Buffer.alloc(3 * 1024 * 1024, "x").toString("latin1"); + try { + console.log("returned", ${expression}.length); + } catch (e) { + console.log("threw", e.name, e.message); + } + `, + ], + env: { + ...bunEnv, + Malloc: "1", + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "allocator_may_return_null=1", "max_allocation_size_mb=4", "detect_leaks=0"] + .filter(Boolean) + .join(":"), + }, + stdout: "pipe", + stderr: "ignore", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("threw RangeError Out of memory\n"); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/first_party/utf-8-validate/utf-8-validate.test.ts b/test/js/first_party/utf-8-validate/utf-8-validate.test.ts index fffd72bd36e8..7768db347435 100644 --- a/test/js/first_party/utf-8-validate/utf-8-validate.test.ts +++ b/test/js/first_party/utf-8-validate/utf-8-validate.test.ts @@ -5,3 +5,7 @@ test("utf-8-validate", () => { expect(isValidUTF8(Buffer.from("😀"))).toBeTrue(); expect(isValidUTF8(Buffer.from([0xff]))).toBeFalse(); }); + +test("utf-8-validate is not a constructor", () => { + expect(() => Reflect.construct(isValidUTF8, [Buffer.from("x")])).toThrow(TypeError); +}); diff --git a/test/js/junit-reporter/junit.test.js b/test/js/junit-reporter/junit.test.js index 5cf40e2d295d..2d383eb794a5 100644 --- a/test/js/junit-reporter/junit.test.js +++ b/test/js/junit-reporter/junit.test.js @@ -608,6 +608,25 @@ describe("junit reporter", () => { expect(longPathCase.failure[0]._).toContain(`at fromLongPath (${longPath}:1:`); expect(pathCase.failure[0]._).toContain("at fromPath (generated.js:1:"); }); + + it("prints a write error when the outfile is longer than the path buffer", async () => { + // A bunfig value can be longer than the path buffer on every platform. + const outfile = Buffer.alloc(100_000, "j").toString(); + await using tmpDir = tempDir("junit-outfile-too-long", { + "package.json": "{}", + "bunfig.toml": `[test.reporter]\njunit = "${outfile}"\n`, + "passing.test.js": ` + import { test } from "bun:test"; + test("passes", () => {}); + `, + }); + await using proc = spawn([bunExe(), "test"], { cwd: tmpDir, env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + expect(stderr).toContain(" 1 pass"); + expect(stderr).toContain(`Failed to write JUnit report to ${outfile}`); + expect(exitCode).toBe(0); + }); }); function filterJunitXmlOutput(xmlContent) { diff --git a/test/js/node/buffer-resolveObjectURL.test.ts b/test/js/node/buffer-resolveObjectURL.test.ts index b5a18cdb3cfe..7709613fa7ef 100644 --- a/test/js/node/buffer-resolveObjectURL.test.ts +++ b/test/js/node/buffer-resolveObjectURL.test.ts @@ -49,3 +49,7 @@ test("buffer.resolveObjectURL args", async () => { ).toBeUndefined(); URL.revokeObjectURL(id); }); + +test("buffer.resolveObjectURL is not a constructor", () => { + expect(() => Reflect.construct(resolveObjectURL, ["foo"])).toThrow(TypeError); +}); diff --git a/test/js/node/buffer.test.js b/test/js/node/buffer.test.js index a2df8e1a0176..b2439f486d4e 100644 --- a/test/js/node/buffer.test.js +++ b/test/js/node/buffer.test.js @@ -1,6 +1,6 @@ import { Buffer, SlowBuffer, isAscii, isUtf8, kMaxLength } from "buffer"; import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, gc, isASAN, isDebug, nodeExe, withoutAggressiveGC } from "harness"; +import { bunEnv, bunExe, gc, isASAN, isDebug, nodeExe, tempDir, withoutAggressiveGC } from "harness"; import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import os from "node:os"; @@ -197,7 +197,7 @@ for (let withOverridenBufferWrite of [false, true]) { expect(isAscii(new Buffer(""))).toBeTrue(); expect(isAscii(new Buffer([32, 32, 128]))).toBeFalse(); expect(isAscii(new Buffer("What did the 🦊 say?"))).toBeFalse(); - expect(new isAscii(new Buffer("What did the 🦊 say?"))).toBeFalse(); + expect(() => new isAscii(new Buffer("abc"))).toThrow(TypeError); expect(isAscii(new Buffer("").buffer)).toBeTrue(); expect(isAscii(new Buffer([32, 32, 128]).buffer)).toBeFalse(); }); @@ -207,6 +207,7 @@ for (let withOverridenBufferWrite of [false, true]) { expect(isAscii(new Buffer(""))).toBeTrue(); expect(isUtf8(new Buffer("What did the 🦊 say?"))).toBeTrue(); expect(isUtf8(new Buffer([129, 129, 129]))).toBeFalse(); + expect(() => new isUtf8(new Buffer("abc"))).toThrow(TypeError); expect(isUtf8(new Buffer("abc").buffer)).toBeTrue(); expect(isAscii(new Buffer("").buffer)).toBeTrue(); @@ -2899,6 +2900,7 @@ for (let withOverridenBufferWrite of [false, true]) { it("transcode", () => { expect(typeof BufferModule.transcode).toBe("function"); const transcode = BufferModule.transcode; + expect(() => Reflect.construct(transcode, [Buffer.from("a"), "latin1", "utf8"])).toThrow(TypeError); expect(transcode(Buffer.from("hä", "latin1"), "latin1", "utf8")).toStrictEqual(Buffer.from("hä", "utf8")); expect(() => transcode(Buffer.from("a"), "b", "utf8")).toThrow( @@ -5424,6 +5426,24 @@ describe("transcode allocation limits", () => { }); }); +it("read*/write* called without a receiver throw the same error as .call(undefined)", () => { + for (const [name, a, b, c] of [ + ["readInt8", 0], + ["readIntLE", 0, 1], + ["writeInt8", 0], + ["writeUIntBE", 0, 0, 1], + ["writeBigInt64LE", 0n, 0], + ]) { + // When a captured variable is called bare, JSC passes its scope object as `this`. + const callee = Buffer.prototype[name]; + expect(() => callee(a, b, c)).toThrow('The "buf" argument must be of type Buffer. Received undefined'); + // `constructor` is captured, so it lives in that scope object and is still uninitialized during the call. + // The error message must not read it to describe the receiver. + let constructor; + (() => constructor)(); + } +}); + // The fixed-width read* / write* accessors are C++ host functions that JSC's DFG/FTL compile into // bounds-checked loads / stores (JSBuffer.cpp + JavaScriptCore's BufferAccessorRegistry). They must // keep agreeing with a DataView reference after tier-up, and everything the JIT does not speculate @@ -5715,3 +5735,71 @@ describe("read*/write* after JIT tier-up", () => { expect(() => scratch.readIntLE(1.5, 2)).toThrow("an integer"); }); }); + +// The length pass doesn't count a UTF-16 unit for a 0x80 byte. If the byte is 0x61 by the conversion pass, that pass +// writes one more unit than was allocated. +it("UTF-8 decoders don't write out of bounds while another thread writes to the SharedArrayBuffer input", async () => { + const fixture = ` + import { Worker, isMainThread, workerData, parentPort } from "node:worker_threads"; + import { transcode } from "node:buffer"; + import { StringDecoder } from "node:string_decoder"; + if (!isMainThread) { + const bytes = new Uint8Array(workerData); + parentPort.postMessage("go"); + for (;;) { + bytes.fill(0x80); + bytes.fill(0x61); + } + } else { + const iterations = 500; + const shared = new SharedArrayBuffer(512); + const buf = Buffer.from(shared).fill(0x61); + const worker = new Worker(import.meta.path, { workerData: shared }); + worker.on("message", async () => { + let total = 0; + for (let i = 0; i < iterations; i++) total += buf.toString("utf8").length; + for (let i = 0; i < iterations; i++) { + try { + total += transcode(buf, "utf8", "ucs2").length; + } catch (e) { + if (e.code !== "U_INVALID_CHAR_FOUND") throw e; + } + } + const decoder = new StringDecoder("utf8"); + for (let i = 0; i < iterations; i++) { + total += decoder.write(buf).length + decoder.text(buf, 0).length + decoder.end(buf).length; + } + for (const options of [{}, { ignoreBOM: true }]) { + const stream = new TextDecoderStream("utf-8", options); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + for (let i = 0; i < iterations; i++) { + writer.write(i & 1 ? buf : shared); + total += (await reader.read()).value.length; + } + } + let enqueued = 0; + const source = new ReadableStream({ + pull(controller) { + controller.enqueue(enqueued & 1 ? buf : shared); + if (++enqueued >= iterations) controller.close(); + }, + }); + for await (const text of new Response(source).textStream()) total += text.length; + console.log("ok", total > 0); + process.exit(0); + }); + } + `; + using dir = tempDir("buffer-shared-decode-race", { "fixture.mjs": fixture }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr }).toEqual({ stdout: "ok true\n", stderr: "" }); + expect(exitCode).toBe(0); +}); diff --git a/test/js/node/child_process/child_process.test.ts b/test/js/node/child_process/child_process.test.ts index 8cd1bb81e8ae..7845b6d9f361 100644 --- a/test/js/node/child_process/child_process.test.ts +++ b/test/js/node/child_process/child_process.test.ts @@ -1472,6 +1472,31 @@ describe.skipIf(!isPosix)("stdout pipe backpressure", () => { }); }); +// The stderr pipe closes without any data before JS touches child.stderr, so the exit path creates its stream +// without a native handle. +it.skipIf(!isPosix)("emits 'close' when an unread stdio pipe closed without any data", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { spawn } = require("child_process"); + const c = spawn("/bin/sh", ["-c", "echo hello"], { stdio: ["inherit", "pipe"] }); + c.stdout.on("data", d => process.stdout.write("OUT:" + d)); + c.on("close", () => { + c.stderr.unref(); + c.stderr.ref(); + console.log("closed", c.stderr.readableEnded, c.stderr.destroyed); + });`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("OUT:hello\nclosed true true\n"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); +}); + // child.stdout.pause() must stop the native reader so the kernel pipe fills // and the child blocks on write. Previously, once the stream had flowed even // once the native FileReader kept the poll armed (or uv_read_start active on diff --git a/test/js/node/cluster.test.ts b/test/js/node/cluster.test.ts index 3248646bd3b0..034a68b99502 100644 --- a/test/js/node/cluster.test.ts +++ b/test/js/node/cluster.test.ts @@ -582,6 +582,50 @@ test("disconnect() on a cluster.Worker built around a plain object does not abor expect({ stdout: stdout.trim(), exitCode }).toEqual({ stdout: "returned self: true", exitCode: 0 }); }); +// cluster._getServer() copies every key of its options into the worker's queryServer message. +// Node compares `ack` and `addressType` with ===, so each of these queries binds IPv4 and gets a handle. +const malformedQueryFixture = ` +const cluster = require("node:cluster"); +const fs = require("node:fs"); + +// Under SCHED_NONE the primary binds the address itself, which is where it reads addressType. +cluster.schedulingPolicy = cluster.SCHED_NONE; + +if (cluster.isPrimary) { + const worker = cluster.fork(); + worker.on("message", reply => { + // If it were treated as a pipe (addressType -1), "127.0.0.1" would be a unix socket in the cwd. + console.log(JSON.stringify({ ...reply, unixSocket: fs.existsSync("127.0.0.1") })); + worker.kill(); + process.exit(0); + }); + worker.on("exit", (code, signal) => { + console.error("worker exited before the primary answered (" + code + ", " + signal + ")"); + process.exit(1); + }); +} else { + const query = { address: "127.0.0.1", port: 0, addressType: 4, fd: -1, ...JSON.parse(process.env.MALFORMED) }; + cluster._getServer({ once() {} }, query, (errno, handle) => { + process.send({ errno, handle: handle != null }); + }); +} +`; + +test.concurrent.each([{ ack: {} }, { addressType: {} }, { addressType: false }])( + "primary handles %j in a worker's queryServer message like node", + async malformed => { + using dir = tempDir("cluster-malformed-query", { "fixture.js": malformedQueryFixture }); + const { stdout, stderr, exitCode } = await bunRun(joinP(String(dir), "fixture.js"), { + MALFORMED: JSON.stringify(malformed), + }); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: JSON.stringify({ errno: 0, handle: true, unixSocket: false }), + stderr: "", + exitCode: 0, + }); + }, +); + const listeningPayloadFixture = ` const cluster = require("node:cluster"); @@ -932,21 +976,19 @@ if (cluster.isPrimary) { }, ); -test.skipIf(isWindows)("dgram worker releases a shared fd it failed to adopt", async () => { - using dir = tempDir("cluster-dgram-adopt-fail", { +test.skipIf(isWindows)("dgram bind({ fd }) on a stream socket doesn't close the primary's server", async () => { + using dir = tempDir("cluster-dgram-stream-fd", { "main.ts": ` const cluster = require("node:cluster"); const dgram = require("node:dgram"); const net = require("node:net"); if (cluster.isPrimary) { - // A stream socket passes the primary's fd check but cannot be adopted as a dgram socket in the worker. const tcp = net.createServer().listen(0, "127.0.0.1", () => { const { port } = tcp.address(); const worker = cluster.fork(); worker.on("message", m => { console.log("worker error code:", m.code); - // Refused once both processes closed their copy; a leaked copy in either keeps the socket accepting. const probe = net.connect(port, "127.0.0.1"); probe.on("connect", () => { console.log("probe: connected"); probe.destroy(); finish(); }); probe.on("error", err => { console.log("probe:", err.code); finish(); }); @@ -976,7 +1018,109 @@ if (cluster.isPrimary) { }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stdout: stdout.trim(), stderr }).toEqual({ - stdout: "worker error code: EINVAL\nprobe: ECONNREFUSED", + stdout: "worker error code: EINVAL\nprobe: connected", + stderr: "", + }); + expect(exitCode).toBe(0); +}); + +test.skipIf(isWindows)("dgram worker releases a shared fd it failed to adopt", async () => { + using dir = tempDir("cluster-dgram-adopt-fail", { + "main.ts": ` +const cluster = require("node:cluster"); +const dgram = require("node:dgram"); +const fs = require("node:fs"); + +if (cluster.isPrimary) { + const worker = cluster.fork(); + worker.on("message", m => { + console.log("worker error code:", m.code); + // The port is free once both processes have closed their copy. + const probe = dgram.createSocket("udp4"); + probe.on("listening", () => { console.log("probe: listening"); probe.close(finish); }); + probe.on("error", err => { console.log("probe:", err.code); finish(); }); + probe.bind(Number(process.env.PORT), "127.0.0.1"); + }); + function finish() { + worker.kill(); + worker.on("exit", () => process.exit(0)); + } +} else { + // A worker can adopt any datagram socket the primary shares, so point the handle at a regular file to make it fail. + const getServer = cluster._getServer; + cluster._getServer = (socket, options, callback) => + getServer(socket, options, (err, handle) => { + if (handle) handle.sharedFd = fs.openSync(__filename, "r"); + callback(err, handle); + }); + const socket = dgram.createSocket("udp4"); + socket.on("listening", () => process.send({ code: "listening" })); + socket.on("error", err => process.send({ code: err.code })); + socket.bind({ fd: 3 }); +} +`, + }); + const udp = await Bun.udpSocket({ hostname: "127.0.0.1", port: 0 }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.ts"], + env: { ...bunEnv, PORT: String(udp.port) }, + cwd: String(dir), + // Becomes fd 3 in the primary. + stdio: ["ignore", "pipe", "pipe", (udp as typeof udp & { fd: number }).fd], + }); + // Close this process's copy, which would otherwise keep the port bound. + udp.close(); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr }).toEqual({ + stdout: "worker error code: ENOTSOCK\nprobe: listening", + stderr: "", + }); + expect(exitCode).toBe(0); +}); + +test.skipIf(isWindows)("a second listen({ fd }) on an fd that a handle already owns fails with EEXIST", async () => { + using dir = tempDir("cluster-fd-held", { + "main.ts": ` +const cluster = require("node:cluster"); +const fs = require("node:fs"); +const net = require("node:net"); + +cluster.schedulingPolicy = cluster.SCHED_NONE; + +if (cluster.isPrimary) { + const worker = cluster.fork(); + worker.on("message", second => { + console.log("second listen:", second); + try { + fs.fstatSync(3); + console.log("primary's fd: open"); + } catch (err) { + console.log("primary's fd:", err.code); + } + worker.kill(); + worker.on("exit", () => process.exit(0)); + }); +} else { + net.createServer().listen({ fd: 3 }, () => { + const second = net.createServer(); + second.on("listening", () => process.send("listening")); + second.on("error", err => process.send(err.code)); + second.listen({ fd: 3 }); + }); +} +`, + }); + using listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.ts"], + env: bunEnv, + cwd: String(dir), + // Becomes fd 3 in the primary. + stdio: ["ignore", "pipe", "pipe", (listener as typeof listener & { fd: number }).fd], + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr }).toEqual({ + stdout: "second listen: EEXIST\nprimary's fd: open", stderr: "", }); expect(exitCode).toBe(0); diff --git a/test/js/node/crypto/crypto.key-objects.test.ts b/test/js/node/crypto/crypto.key-objects.test.ts index 5177cf034560..9c4b75bb442f 100644 --- a/test/js/node/crypto/crypto.key-objects.test.ts +++ b/test/js/node/crypto/crypto.key-objects.test.ts @@ -26,7 +26,8 @@ import { verify, } from "crypto"; import fs from "fs"; -import { bunEnv, bunExe, isASAN, isWindows } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, isWindows, tempDir } from "harness"; +import { totalmem } from "node:os"; import { createContext, runInContext, runInThisContext, Script } from "node:vm"; import path from "path"; @@ -620,6 +621,15 @@ describe("crypto.KeyObjects", () => { }); }); + test("createPrivateKey rejects an EC JWK whose d doesn't match its public point", () => { + const a = generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey.export({ format: "jwk" }); + const b = generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey.export({ format: "jwk" }); + expect(createPrivateKey({ key: a, format: "jwk" }).export({ format: "jwk" })).toEqual(a); + expect(() => createPrivateKey({ key: { ...a, d: b.d }, format: "jwk" })).toThrow( + expect.objectContaining({ code: "ERR_CRYPTO_INVALID_JWK" }), + ); + }); + test("private encrypted should work", async () => { // Reading an encrypted key without a passphrase should fail. expect(() => createPrivateKey(privateEncryptedPem)).toThrow(); @@ -660,6 +670,51 @@ describe("crypto.KeyObjects", () => { expect(privateKey.symmetricKeySize).toBe(undefined); }); + // The child never writes to the 2 GiB buffer, so its pages are never committed. The memory gate is for builds + // without the length check, which copy the buffer twice. + test.skipIf(totalmem() < 10 * 1024 ** 3)("a passphrase of 2 ** 31 bytes throws ERR_OUT_OF_RANGE", async () => { + const fixture = ` + import crypto from "node:crypto"; + + const passphrase = Buffer.alloc(2 ** 31); + const ec = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" }).privateKey; + const pkcs8 = { type: "pkcs8", format: "pem", cipher: "aes-256-cbc", passphrase }; + + const cases = { + "generateKeyPairSync": () => crypto.generateKeyPairSync("ec", { namedCurve: "P-256", privateKeyEncoding: pkcs8 }), + "export pkcs8 pem": () => ec.export(pkcs8), + "export sec1 pem": () => ec.export({ ...pkcs8, type: "sec1" }), + "createPrivateKey": () => crypto.createPrivateKey({ key: ${JSON.stringify(privateEncryptedPem)}, passphrase }), + }; + for (const [name, run] of Object.entries(cases)) { + try { + run(); + console.log(name + ": did not throw"); + } catch (e) { + console.log(name + ": " + e.name + " [" + e.code + "]: " + e.message); + } + } + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim().split("\n"), stderr, exitCode }).toEqual({ + stdout: [ + "generateKeyPairSync: RangeError [ERR_OUT_OF_RANGE]: passphrase is too big", + "export pkcs8 pem: RangeError [ERR_OUT_OF_RANGE]: passphrase is too big", + "export sec1 pem: RangeError [ERR_OUT_OF_RANGE]: passphrase is too big", + "createPrivateKey: RangeError [ERR_OUT_OF_RANGE]: passphrase is too big", + ], + stderr: "", + exitCode: 0, + }); + }); + [2048, 4096].forEach(suffix => { test(`RSA-${suffix} should work`, async () => { { @@ -1922,3 +1977,50 @@ describe.skipIf(!isASAN)("async crypto jobs: process.exit() in the callback leak }); } }); + +test("worker.terminate() during createPrivateKey from an EC JWK doesn't crash", async () => { + const rounds = isDebug || isASAN ? 2 : 6; + using dir = tempDir("ec-jwk-terminate", { + "main.mjs": ` + import { createPrivateKey, generateKeyPairSync } from "node:crypto"; + + if (Bun.isMainThread) { + for (let round = 0; round < ${rounds}; round++) { + const workers = Array.from({ length: 4 }, () => new Worker(import.meta.url)); + await Promise.all( + workers.map( + worker => + new Promise((resolve, reject) => { + worker.onmessage = resolve; + worker.onerror = event => reject(event.error ?? new Error(event.message)); + }), + ), + ); + const closed = Promise.all( + workers.map(worker => new Promise(resolve => worker.addEventListener("close", resolve, { once: true }))), + ); + for (const worker of workers) worker.terminate(); + await closed; + } + console.log("survived"); + } else { + const jwk = generateKeyPairSync("ec", { namedCurve: "P-256" }).privateKey.export({ format: "jwk" }); + // Leading zero bytes don't change the scalar, but they make decoding "d" dominate each import. + jwk.d = Buffer.concat([Buffer.alloc(768 * 1024), Buffer.from(jwk.d, "base64url")]).toString("base64url"); + createPrivateKey({ key: jwk, format: "jwk" }); + postMessage("busy"); + for (;;) createPrivateKey({ key: jwk, format: "jwk" }); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "survived\n", stderr: "", exitCode: 0 }); +}); diff --git a/test/js/node/crypto/node-crypto.test.js b/test/js/node/crypto/node-crypto.test.js index 5b9ff83d78d7..2db4858dc8eb 100644 --- a/test/js/node/crypto/node-crypto.test.js +++ b/test/js/node/crypto/node-crypto.test.js @@ -1165,6 +1165,63 @@ it("cipher.setAAD on a non-authenticated cipher throws ERR_CRYPTO_INVALID_STATE" }); }); +// The child never writes to the 2 GiB buffer, so its pages are never committed. +it("a 2 GiB buffer argument throws ERR_OUT_OF_RANGE", async () => { + const script = ` + import crypto from "node:crypto"; + let big; + try { + big = new Uint8Array(2 ** 31); + } catch { + console.log("SKIP"); + process.exit(0); + } + const { publicKey, privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" }); + const results = {}; + const record = (label, fn) => { + try { + fn(); + results[label] = "returned"; + } catch (e) { + results[label] = e.code + ": " + e.message; + } + }; + record("createSecretKey", () => crypto.createSecretKey(big)); + record("createSecretKey ArrayBuffer", () => crypto.createSecretKey(big.buffer)); + record("createHmac", () => crypto.createHmac("sha256", big)); + record("createCipheriv", () => crypto.createCipheriv("aes-128-gcm", big, Buffer.alloc(12))); + record("hkdfSync ikm", () => crypto.hkdfSync("sha256", big, "salt", "info", 16)); + record("hkdfSync ikm ArrayBuffer", () => crypto.hkdfSync("sha256", big.buffer, "salt", "info", 16)); + record("hkdfSync salt", () => crypto.hkdfSync("sha256", "key", big, "info", 16)); + record("hkdfSync info", () => crypto.hkdfSync("sha256", "key", "salt", big, 16)); + record("sign data", () => crypto.sign("sha256", big, privateKey)); + record("verify signature", () => crypto.verify("sha256", Buffer.from("x"), publicKey, big)); + record("publicEncrypt oaepLabel", () => + crypto.publicEncrypt({ key: publicKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepLabel: big }, Buffer.alloc(16)), + ); + console.log(JSON.stringify(results)); + `; + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + if (stdout.trim() !== "SKIP") { + expect(JSON.parse(stdout)).toEqual({ + "createSecretKey": "ERR_OUT_OF_RANGE: key is too big", + "createSecretKey ArrayBuffer": "ERR_OUT_OF_RANGE: key is too big", + "createHmac": "ERR_OUT_OF_RANGE: key is too big", + "createCipheriv": "ERR_OUT_OF_RANGE: key is too big", + "hkdfSync ikm": "ERR_OUT_OF_RANGE: key is too big", + "hkdfSync ikm ArrayBuffer": "ERR_OUT_OF_RANGE: key is too big", + "hkdfSync salt": "ERR_OUT_OF_RANGE: salt is too big", + "hkdfSync info": "ERR_OUT_OF_RANGE: info is too big", + "sign data": "ERR_OUT_OF_RANGE: data is too big", + "verify signature": "ERR_OUT_OF_RANGE: signature is too big", + "publicEncrypt oaepLabel": "ERR_OUT_OF_RANGE: oaepLabel is too big", + }); + } + expect(exitCode).toBe(0); +}); + it("generatePrime(Sync) should return an ArrayBuffer", async () => { const prime = crypto.generatePrimeSync(512); expect(prime).toBeInstanceOf(ArrayBuffer); diff --git a/test/js/node/crypto/pbkdf2.test.ts b/test/js/node/crypto/pbkdf2.test.ts index ec69d5e9f0f8..2ad3860ec8be 100644 --- a/test/js/node/crypto/pbkdf2.test.ts +++ b/test/js/node/crypto/pbkdf2.test.ts @@ -267,6 +267,53 @@ test("pbkdf2 keeps the callback alive across GC until the job completes", async expect(results.filter(r => r === "PBKDF2 derivation failed")).toHaveLength(count); }); +test("pbkdf2 copies password and salt before it returns", async () => { + const password = Buffer.alloc(4096, "p"); + const salt = Buffer.alloc(16, "s"); + const expected = crypto.pbkdf2Sync(password, salt, 1, 32, "sha256").toString("hex"); + const results: string[] = []; + for (let i = 0; i < 20; i++) { + password.fill("p"); + salt.fill("s"); + const { promise, resolve, reject } = Promise.withResolvers<string>(); + crypto.pbkdf2(password, salt, 1, 32, "sha256", (err, key) => (err ? reject(err) : resolve(key.toString("hex")))); + password.fill(0); + salt.fill(0); + results.push(await promise); + } + expect(results).toEqual(Array(20).fill(expected)); +}); + +test("pbkdf2 doesn't read a resizable ArrayBuffer that shrinks after the call", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const crypto = require("crypto"); + const expected = crypto.pbkdf2Sync(Buffer.alloc(65536, 0x41), "salt", 1, 32, "sha256").toString("hex"); + let wrong = 0; + for (let i = 0; i < 20; i++) { + const ab = new ArrayBuffer(65536, { maxByteLength: 1 << 20 }); + new Uint8Array(ab).fill(0x41); + const { promise, resolve, reject } = Promise.withResolvers(); + crypto.pbkdf2(new Uint8Array(ab), "salt", 1, 32, "sha256", (err, key) => (err ? reject(err) : resolve(key.toString("hex")))); + ab.resize(0); + if ((await promise) !== expected) wrong++; + } + console.log("wrong:", wrong); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("wrong: 0\n"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); +}); + test("pbkdf2 works with util.promisify", async () => { const key = await promisify(crypto.pbkdf2)("pw", "salt", 1, 8, "sha256"); expect(Buffer.isBuffer(key)).toBe(true); diff --git a/test/js/node/crypto/scrypt.test.ts b/test/js/node/crypto/scrypt.test.ts index 89ab6c6146fe..468367f730d5 100644 --- a/test/js/node/crypto/scrypt.test.ts +++ b/test/js/node/crypto/scrypt.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; -import { scryptSync } from "node:crypto"; +import { scrypt, scryptSync } from "node:crypto"; // When `crypto.scrypt` fails to allocate the output buffer (OOM for a huge // `keylen`), `CryptoJob.init` takes the error path. Previously the `errdefer` @@ -78,6 +78,53 @@ test("scrypt async does not leak callback/buffers when output allocation fails", expect(exitCode).toBe(0); }); +test("scrypt copies password and salt before it returns", async () => { + const password = Buffer.alloc(4096, "p"); + const salt = Buffer.alloc(16, "s"); + const expected = scryptSync(password, salt, 32, { N: 1024 }).toString("hex"); + const results: string[] = []; + for (let i = 0; i < 20; i++) { + password.fill("p"); + salt.fill("s"); + const { promise, resolve, reject } = Promise.withResolvers<string>(); + scrypt(password, salt, 32, { N: 1024 }, (err, key) => (err ? reject(err) : resolve(key.toString("hex")))); + password.fill(0); + salt.fill(0); + results.push(await promise); + } + expect(results).toEqual(Array(20).fill(expected)); +}); + +test("scrypt doesn't read a resizable ArrayBuffer that shrinks after the call", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const crypto = require("crypto"); + const expected = crypto.scryptSync(Buffer.alloc(65536, 0x41), "salt", 32, { N: 1024 }).toString("hex"); + let wrong = 0; + for (let i = 0; i < 20; i++) { + const ab = new ArrayBuffer(65536, { maxByteLength: 1 << 20 }); + new Uint8Array(ab).fill(0x41); + const { promise, resolve, reject } = Promise.withResolvers(); + crypto.scrypt(new Uint8Array(ab), "salt", 32, { N: 1024 }, (err, key) => (err ? reject(err) : resolve(key.toString("hex")))); + ab.resize(0); + if ((await promise) !== expected) wrong++; + } + console.log("wrong:", wrong); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("wrong: 0\n"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); +}); + test("scryptSync reads its buffers only after every argument has been coerced", () => { const passwordBytes = new Uint8Array(64).fill(97); const key = scryptSync(passwordBytes, "salt", 16, { diff --git a/test/js/node/dns/node-dns.test.js b/test/js/node/dns/node-dns.test.js index 96c809607070..9f437be3db43 100644 --- a/test/js/node/dns/node-dns.test.js +++ b/test/js/node/dns/node-dns.test.js @@ -492,6 +492,79 @@ test.skipIf(!isLinux)("dns.lookup uses getaddrinfo, not the c-ares resolver", as expect(exitCode).toBe(0); }); +// On Windows, c-ares closes the UDP socket from inside its uv_poll_t's poll callback. A nested uv_run +// must not run the close callback that frees the handle while libuv is still dispatching to it. +test("dns.Resolver: a query's promise reaction can re-enter the event loop", async () => { + const rounds = 20; + const fixture = ` + const dns = require("node:dns"); + // Echoes the question back with one A record. + function answer(query) { + let off = 12; + while (off < query.length && query[off] !== 0) off += query[off] + 1; + off += 1 + 2 + 2; + const header = Buffer.alloc(12); + header[0] = query[0]; + header[1] = query[1]; + header[2] = 0x81; // QR=1, RD=1 + header[3] = 0x80; // RA=1 + header[5] = 1; // QDCOUNT + header[7] = 1; // ANCOUNT + const record = Buffer.from([ + 0xc0, 0x0c, // NAME: pointer to the question name + 0x00, 0x01, // TYPE A + 0x00, 0x01, // CLASS IN + 0x00, 0x00, 0x00, 0x3c, // TTL 60 + 0x00, 0x04, // RDLENGTH + 127, 0, 0, 1, + ]); + return Buffer.concat([header, query.subarray(12, off), record]); + } + const server = await Bun.udpSocket({ + hostname: "127.0.0.1", + port: 0, + socket: { data(sock, buf, port, addr) { sock.send(answer(Buffer.from(buf)), port, addr); } }, + }); + const resolver = new dns.promises.Resolver({ timeout: 5000, tries: 1 }); + resolver.setServers(["127.0.0.1:" + server.port]); + + // Bun.build() spins the event loop until the promise returned by a plugin's setup() settles. + let reentries = 0; + const builds = []; + function reenterEventLoop() { + const before = reentries; + builds.push(Bun.build({ + entrypoints: ["/entry.js"], + files: { "/entry.js": "" }, + plugins: [{ + name: "spin", + setup: () => new Promise(resolve => setImmediate(() => { reentries++; resolve(); })), + }], + })); + if (reentries !== before + 1) throw new Error("Bun.build() returned without spinning the event loop"); + } + + // Each round uses a new name, because c-ares answers a repeated name from its cache without opening a socket. + for (let i = 0; i < ${rounds}; i++) { + const addresses = await resolver.resolve4("round" + i + ".example.test").then(addresses => { + reenterEventLoop(); + return addresses; + }); + if (addresses[0] !== "127.0.0.1") throw new Error("unexpected answer: " + addresses); + } + await Promise.all(builds); + server.close(); + console.log("reentries=" + reentries); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: `reentries=${rounds}\n`, stderr: "", exitCode: 0 }); +}); + test("dns.getServers", () => { function parseResolvConf() { const servers = []; diff --git a/test/js/node/fs/fs-stat-seccomp-linux.test.ts b/test/js/node/fs/fs-stat-seccomp-linux.test.ts index 40044b34569d..6f6df55e0cdc 100644 --- a/test/js/node/fs/fs-stat-seccomp-linux.test.ts +++ b/test/js/node/fs/fs-stat-seccomp-linux.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; // Seccomp helper: installs a filter that makes one syscall (`BLOCK_SYSCALL`, // a `-D` define) fail with the errno given in argv[1], then execs argv[2..]. +// An errno of 0 makes the syscall raise SIGSYS (SECCOMP_RET_TRAP) instead. // With `-DBLOCK_ARG1=value` only calls whose second argument is `value` fail. // Shared by the describe blocks below. const helperSrc = ` @@ -34,6 +35,12 @@ const helperSrc = ` #ifndef __NR_faccessat2 #define __NR_faccessat2 439 #endif +#ifndef __NR_pidfd_open + #define __NR_pidfd_open 434 +#endif +#ifndef __NR_close_range + #define __NR_close_range 436 +#endif /* instructions between the syscall-nr test and the errno return */ #ifdef BLOCK_ARG1 @@ -47,6 +54,7 @@ int main(int argc, char **argv) { if (argc < 3) return 2; if (MY_AUDIT_ARCH == 0) return 77; /* unsupported arch, skip */ unsigned int err = (unsigned int)atoi(argv[1]); + unsigned int action = err == 0 ? SECCOMP_RET_TRAP : SECCOMP_RET_ERRNO | (err & SECCOMP_RET_DATA); struct sock_filter filter[] = { /* arch check */ @@ -62,7 +70,7 @@ int main(int argc, char **argv) { BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[1])), BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, BLOCK_ARG1, 0, 1), #endif - BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (err & SECCOMP_RET_DATA)), + BPF_STMT(BPF_RET | BPF_K, action), /* else → allow */ BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW), }; @@ -86,6 +94,7 @@ int main(int argc, char **argv) { } `; +const TRAP = 0; // Linux errno values (identical on x86_64 and aarch64). const EPERM = 1; const EACCES = 13; @@ -450,6 +459,85 @@ describe.skipIf(!isLinux || isASAN)("bun build when prlimit64 is blocked by secc } }); +// Linux kernels older than 3.17 don't have getrandom(2). +describe.skipIf(!isLinux)("bun build when getrandom is blocked by seccomp", () => { + const helperBin = tryBuildHelper("__NR_getrandom"); + + for (const [name, errno] of [ + ["ENOSYS", ENOSYS], + ["EPERM", EPERM], + ] as const) { + test.concurrent(`bundles a file (${name})`, async () => { + if (helperBin == null) { + console.warn("SKIP getrandom seccomp: cc or seccomp headers not available"); + return; + } + using dir = tempDir("getrandom-seccomp-build", { "index.js": `console.log("bun ok");` }); + const cwd = String(dir); + const out = await runBunUnderSeccomp(helperBin, errno, ["build", "index.js", "--outdir=out"], { cwd }); + if (out == null) { + console.warn("SKIP getrandom seccomp: seccomp not permitted in this environment"); + return; + } + expect({ built: existsSync(join(cwd, "out", "index.js")), exitCode: out.exitCode }).toEqual({ + built: true, + exitCode: 0, + }); + }); + } +}); + +// Android's seccomp policy raises SIGSYS for a disallowed syscall instead of returning an errno. +describe.skipIf(!isLinux)("a syscall trapped by seccomp fails with ENOSYS", () => { + const closeRange = tryBuildHelper("__NR_close_range"); + const pidfdOpen = tryBuildHelper("__NR_pidfd_open"); + + async function run(helperBin: string | null, argv: string[], cwd?: string) { + if (helperBin == null) { + console.warn("SKIP seccomp trap: cc or seccomp headers not available"); + return null; + } + const out = await runBunUnderSeccomp(helperBin, TRAP, argv, { cwd }); + if (out == null) { + console.warn("SKIP seccomp trap: seccomp not permitted in this environment"); + return null; + } + return { stdout: out.stdout, exitCode: out.exitCode }; + } + + test.concurrent("close_range at startup and in a spawned child", async () => { + const out = await run(closeRange, ["-e", `process.stdout.write(Bun.spawnSync(["echo", "hi"]).stdout)`]); + if (out == null) return; + expect(out).toEqual({ stdout: "hi\n", exitCode: 0 }); + }); + + // The signal-exit package listens for SIGSYS, so most CLIs do too. + test.concurrent("pidfd_open while process.on('SIGSYS') has a listener, and after it's removed", async () => { + const snippet = ` + const { promise, resolve } = Promise.withResolvers(); + process.on("SIGSYS", resolve); + const during = Bun.spawnSync(["echo", "hi"]).stdout.toString(); + process.kill(process.pid, "SIGSYS"); + await promise; + process.removeAllListeners("SIGSYS"); + const after = Bun.spawnSync(["echo", "hi"]).stdout.toString(); + console.log(JSON.stringify({ during, after })); + `; + const out = await run(pidfdOpen, ["-e", snippet]); + if (out == null) return; + expect(out).toEqual({ stdout: JSON.stringify({ during: "hi\n", after: "hi\n" }) + "\n", exitCode: 0 }); + }); + + test.concurrent("pidfd_open in bun run --no-orphans isn't forwarded to the script as a signal", async () => { + using dir = tempDir("seccomp-trap-run", { + "package.json": JSON.stringify({ name: "app", scripts: { hello: "echo hello-from-script" } }), + }); + const out = await run(pidfdOpen, ["run", "--no-orphans", "--silent", "hello"], String(dir)); + if (out == null) return; + expect(out).toEqual({ stdout: "hello-from-script\n", exitCode: 0 }); + }); +}); + // The kernel is not bound to the errno table bun knows: FUSE filesystems and // some drivers return codes above EHWPOISON (133). fs.fsyncSync decodes the // errno through SystemErrno::from_raw; a code outside the table must report diff --git a/test/js/node/fs/fs-stats-constructor.test.ts b/test/js/node/fs/fs-stats-constructor.test.ts index 3017c69dd143..406814e8ed26 100644 --- a/test/js/node/fs/fs-stats-constructor.test.ts +++ b/test/js/node/fs/fs-stats-constructor.test.ts @@ -130,4 +130,38 @@ describe("Stats methods and accessors called without a receiver", () => { exitCode: 0, }); }); + + test("a date setter throws a TypeError for a WebAssembly GC object", async () => { + const src = ` + // (module (type $s (struct (field (mut i32)))) + // (func (export "mk") (result (ref null $s)) struct.new_default $s)) + const bytes = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + 0x01, 0x0a, 0x02, 0x5f, 0x01, 0x7f, 0x01, 0x60, 0x00, 0x01, 0x63, 0x00, + 0x03, 0x02, 0x01, 0x01, + 0x07, 0x06, 0x01, 0x02, 0x6d, 0x6b, 0x00, 0x00, + 0x0a, 0x07, 0x01, 0x05, 0x00, 0xfb, 0x01, 0x00, 0x0b, + ]); + const ref = new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports.mk(); + const { set } = Object.getOwnPropertyDescriptor(require("node:fs").Stats.prototype, "mtime"); + try { + set.call(ref, new Date(0)); + console.log("no error"); + } catch (e) { + console.log(e.constructor.name); + } + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "TypeError\n", + stderr: "", + exitCode: 0, + }); + }); }); diff --git a/test/js/node/fs/fs.test.ts b/test/js/node/fs/fs.test.ts index 88c5dca21d6a..bb0ce02ff402 100644 --- a/test/js/node/fs/fs.test.ts +++ b/test/js/node/fs/fs.test.ts @@ -1072,6 +1072,41 @@ describe("copyFileSync", () => { } }); +// On Windows, each work-pool thread converts the destination path in its own buffer, so each new thread is another +// chance to crash. The child raises the thread cap and follows every copy with a 1 MiB read to keep the pool +// spawning threads. +it.concurrent("copyFile to an empty destination path fails with ENOENT in the promise and callback APIs", async () => { + using dir = tempDir("fs-copyfile-empty-dest", { "src.txt": "x", "srcdir/.keep": "" }); + const script = ` + const fs = require("fs"); + fs.writeFileSync("big.bin", Buffer.alloc(1 << 20)); + const busy = []; + const code = promise => { + busy.push(fs.promises.readFile("big.bin")); + return promise.then(() => "resolved", e => e.code); + }; + const codes = []; + for (let i = 0; i < 32; i++) { + codes.push(code(fs.promises.copyFile("src.txt", ""))); + codes.push(code(new Promise((resolve, reject) => fs.copyFile("src.txt", "", e => (e ? reject(e) : resolve()))))); + } + console.log([...new Set(await Promise.all(codes))].join()); + console.log(await code(fs.promises.copyFile("srcdir", ""))); + await Promise.all(busy); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: { ...bunEnv, UV_THREADPOOL_SIZE: "32" }, + cwd: String(dir), + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + // An error for the source is still reported first. + expect(stdout).toBe(`ENOENT\n${isWindows ? "EPERM" : "ENOTSUP"}\n`); + expect(exitCode).toBe(0); +}); + describe("mkdirSync", () => { it("should create a directory", () => { const now = Date.now().toString(); @@ -5532,6 +5567,82 @@ describe("fs.write", () => { }); }); +describe("fs.read/readv/write/writev pass the buffer to the callback on error", () => { + // Reading from a write-only fd or writing to a read-only fd fails with EBADF. + async function failing(flags: "r" | "w", call: (fd: number, cb: (...args: any[]) => void) => void) { + using dir = tempDir("fs-callback-error", { "file.txt": "bun" }); + const fd = fs.openSync(join(String(dir), "file.txt"), flags); + try { + const { promise, resolve } = Promise.withResolvers<any[]>(); + call(fd, (...args) => resolve(args)); + const [err, ...rest] = await promise; + return [err.code, ...rest]; + } finally { + fs.closeSync(fd); + } + } + + it("when the call fails with EBADF", async () => { + const buffer = Buffer.alloc(8); + const buffers = [Buffer.alloc(4), Buffer.alloc(4)]; + expect(await failing("w", (fd, cb) => fs.read(fd, buffer, 0, 8, null, cb))).toEqual(["EBADF", 0, buffer]); + expect(await failing("w", (fd, cb) => fs.readv(fd, buffers, cb))).toEqual(["EBADF", 0, buffers]); + expect(await failing("r", (fd, cb) => fs.write(fd, buffer, 0, 8, null, cb))).toEqual(["EBADF", 0, buffer]); + expect(await failing("r", (fd, cb) => fs.write(fd, "bun", cb))).toEqual(["EBADF", 0, "bun"]); + expect(await failing("r", (fd, cb) => fs.writev(fd, buffers, cb))).toEqual(["EBADF", 0, buffers]); + }); + + // A full non-blocking FIFO makes the first write fail with EAGAIN. The wrapper + // drains the FIFO before writeAll sees the error, so the retry succeeds. + it.skipIf(isWindows)("fs.WriteStream retries an EAGAIN write with the same buffer", async () => { + using dir = tempDir("fs-write-stream-eagain", {}); + const path = join(String(dir), "fifo"); + mkfifo(path, 0o666); + const reader = fs.openSync(path, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK); + const writer = fs.openSync(path, fs.constants.O_WRONLY | fs.constants.O_NONBLOCK); + try { + const filler = Buffer.alloc(4096, 0x61); + let filled = 0; + expect(() => { + for (;;) filled += fs.writeSync(writer, filler); + }).toThrow(expect.objectContaining({ code: "EAGAIN" })); + + const scratch = Buffer.alloc(65536); + const payload = Buffer.alloc(1024, 0x62); + const calls: any[] = []; + const { promise, resolve, reject } = Promise.withResolvers<void>(); + const stream = fs.createWriteStream(null, { + fd: writer, + autoClose: false, + fs: { + write(fd, buffer, offset, length, position, cb) { + fs.write(fd, buffer, offset, length, position, (err, bytesWritten, buf) => { + calls.push([err?.code, bytesWritten, buf === buffer]); + if (err?.code === "EAGAIN") { + for (let left = filled; left > 0;) + left -= fs.readSync(reader, scratch, 0, Math.min(left, scratch.length)); + } + cb(err, bytesWritten, buf); + }); + }, + }, + }); + stream.on("error", reject); + stream.write(payload, err => (err ? reject(err) : resolve())); + await promise; + + expect(calls).toEqual([ + ["EAGAIN", 0, true], + [undefined, payload.length, true], + ]); + expect(scratch.subarray(0, fs.readSync(reader, scratch))).toEqual(payload); + } finally { + fs.closeSync(writer); + fs.closeSync(reader); + } + }); +}); + describe("fs.read", () => { it("should work with (fd, callback)", done => { const path = `${tmpdir()}/bun-fs-read-1-${Date.now()}.txt`; @@ -6930,6 +7041,74 @@ it("fs.promises.stat reads a Buffer path captured at call time when its resizabl expect(exitCode).toBe(0); }); +// The reader drains the fifo one pipe buffer at a time, so the pool thread is still inside `write(2)` +// when the memory grows and frees its old block. +it.skipIf(isWindows)( + "fs.write sends the bytes the caller passed when a WebAssembly.Memory grows mid-write", + async () => { + using dir = tempDir("fs-write-wasm", {}); + const fifoPath = join(String(dir), "w.fifo"); + mkfifo(fifoPath); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + import fs from "node:fs"; + const PAGES = 16; // 1 MB, which is 16 pipe buffers + const TOTAL = PAGES * 65536; + const newMemory = () => new WebAssembly.Memory({ initial: PAGES, maximum: PAGES + 8 }); + + const mem = newMemory(); + const view = new Uint8Array(mem.buffer).fill(0x41); + + // O_RDWR keeps the open from blocking and lets this process drain the fifo itself. + const fd = fs.openSync(process.env.FIFO_PATH, fs.constants.O_RDWR); + const written = Promise.withResolvers(); + fs.write(fd, view, 0, TOTAL, null, (err, n) => written.resolve(err ? err.code : n)); + + const chunk = Buffer.alloc(65536); + const expected = Buffer.alloc(65536, 0x41); + const read = () => + new Promise((resolve, reject) => + fs.read(fd, chunk, 0, chunk.length, null, (err, n) => (err ? reject(err) : resolve(n))), + ); + + // Read one pipe buffer. The pool thread is still inside write(2) with the rest. + let drained = await read(); + let foreignChunks = 0; + mem.grow(2); + // Claim the freed block so that reading it can't return the caller's bytes. + const claim = Array.from({ length: 4 }, () => { + const memory = newMemory(); + new Uint8Array(memory.buffer).fill(0xee); + return memory; + }); + + while (drained < TOTAL) { + const n = await read(); + if (!n) break; + if (Buffer.compare(chunk.subarray(0, n), expected.subarray(0, n)) !== 0) foreignChunks++; + drained += n; + } + console.log(JSON.stringify({ drained, foreignChunks, written: await written.promise })); + process.exit(0); + `, + ], + // Without fast memory, `grow()` moves the block on every platform. + env: { ...bunEnv, BUN_JSC_useWasmFastMemory: "0", FIFO_PATH: fifoPath }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.trim()).toBe(JSON.stringify({ drained: 1048576, foreignChunks: 0, written: 1048576 })); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }, +); + // A sync call reads the path after the option getters ran. It reads the bytes captured at call time // when a getter shrinks the buffer. it("sync fs calls read a Buffer path captured at call time when an option getter shrinks its resizable ArrayBuffer", async () => { @@ -7551,6 +7730,61 @@ describe("fs.Utf8Stream", () => { }); }); +describe.if(isPosix)("fs.read into a view over a WebAssembly.Memory that grows during the read", () => { + // The fifo is empty, so the pool thread waits in read(2) until the memory has grown. + async function run(view: string, env: Record<string, string>) { + using dir = tempDir("fs-read-wasm-grow", { + "run.mjs": ` + import fs from "node:fs"; + const fd = fs.openSync(process.argv[2], fs.constants.O_RDWR); + const mem = new WebAssembly.Memory({ initial: 1, maximum: 4 }); + const view = new Uint8Array(${view}); + view.fill(0x2e); + const { promise, resolve } = Promise.withResolvers(); + fs.read(fd, view, 64, 4096, null, (err, bytesRead) => resolve({ err, bytesRead })); + mem.grow(1); + fs.writeSync(fd, Buffer.alloc(100, 0x41)); + const { err, bytesRead } = await promise; + const bytes = view.byteLength ? Buffer.from(view).subarray(0, 65536) : Buffer.alloc(0); + console.log(JSON.stringify({ + err: err?.code ?? null, + bytesRead, + first: bytes.indexOf(0x41), + count: bytes.filter(b => b === 0x41).length, + untouched: bytes.filter(b => b === 0x2e).length, + })); + fs.closeSync(fd); + `, + }); + const fifo = join(String(dir), "fifo"); + mkfifo(fifo, 0o666); + await using proc = Bun.spawn({ + cmd: [bunExe(), "run.mjs", fifo], + // Without fast memory, grow() moves the block and frees the old one. + env: { ...bunEnv, BUN_JSC_useWasmFastMemory: "0", ...env }, + cwd: String(dir), + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + return { stdout: JSON.parse(stdout), exitCode }; + } + + it.concurrent("writes the bytes into a view that tracks the memory", async () => { + expect(await run("mem.toResizableBuffer()", {})).toEqual({ + stdout: { err: null, bytesRead: 100, first: 64, count: 100, untouched: 65436 }, + exitCode: 0, + }); + }); + + it.concurrent("doesn't read into a detached view's freed block", async () => { + // Without the Gigacage, the freed block is unmapped and read(2) into it fails with EFAULT. + expect(await run("mem.buffer", { Malloc: "1" })).toEqual({ + stdout: { err: null, bytesRead: 100, first: -1, count: 0, untouched: 0 }, + exitCode: 0, + }); + }); +}); + describe("module init", () => { it("fs.promises is a lazy getter that resolves to node:fs/promises", () => { const desc = Object.getOwnPropertyDescriptor(fs, "promises")!; diff --git a/test/js/node/fs/readdir-malformed-dirent.test.ts b/test/js/node/fs/readdir-malformed-dirent.test.ts new file mode 100644 index 000000000000..0eb87007ede5 --- /dev/null +++ b/test/js/node/fs/readdir-malformed-dirent.test.ts @@ -0,0 +1,118 @@ +// A sandbox that emulates getdents64(2) can return a record with a bad length. A normal filesystem can't, +// so an LD_PRELOAD shim corrupts the buffer after the real syscall returns. The directory's name selects +// the kind of corruption. +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, isLinux, tempDir } from "harness"; +import { join } from "node:path"; + +const cc = Bun.which("cc") || Bun.which("gcc") || Bun.which("clang"); + +const shim = /* c */ ` +#define _GNU_SOURCE +#include <dlfcn.h> +#include <limits.h> +#include <stdarg.h> +#include <stdint.h> +#include <stdio.h> +#include <string.h> +#include <sys/syscall.h> +#include <unistd.h> + +// struct linux_dirent64 { u64 d_ino; s64 d_off; u16 d_reclen; u8 d_type; char d_name[]; } +enum { RECLEN_OFFSET = 16, NAME_OFFSET = 19 }; + +static long reclen_of(const unsigned char *record) { + uint16_t value; + memcpy(&value, record + RECLEN_OFFSET, sizeof value); + return value; +} + +static void set_reclen(unsigned char *record, long to) { + uint16_t value = (uint16_t)to; + memcpy(record + RECLEN_OFFSET, &value, sizeof value); +} + +static long (*next_syscall)(long, ...); + +long syscall(long nr, ...) { + va_list ap; + va_start(ap, nr); + long a1 = va_arg(ap, long), a2 = va_arg(ap, long), a3 = va_arg(ap, long); + long a4 = va_arg(ap, long), a5 = va_arg(ap, long), a6 = va_arg(ap, long); + va_end(ap); + if (!next_syscall) next_syscall = dlsym(RTLD_NEXT, "syscall"); + long rc = next_syscall(nr, a1, a2, a3, a4, a5, a6); + if (nr != SYS_getdents64 || rc <= 0) return rc; + + char link[64], path[PATH_MAX]; + snprintf(link, sizeof link, "/proc/self/fd/%d", (int)a1); + ssize_t n = readlink(link, path, sizeof path - 1); + if (n <= 0) return rc; + path[n] = 0; + const char *mode = strrchr(path, '/') + 1; + + unsigned char *buf = (unsigned char *)a2; + unsigned char *last = buf; + while (last + reclen_of(last) < buf + rc) last += reclen_of(last); + + if (strcmp(mode, "reclen-zero") == 0) set_reclen(buf, 0); + if (strcmp(mode, "reclen-below-header") == 0) set_reclen(buf, 8); + if (strcmp(mode, "reclen-past-end") == 0) set_reclen(buf, rc + 8); + if (strcmp(mode, "second-record") == 0) set_reclen(buf + reclen_of(buf), 0); + if (strcmp(mode, "name-unterminated") == 0) memset(last + NAME_OFFSET, 'x', reclen_of(last) - NAME_OFFSET); + if (strcmp(mode, "header-cut") == 0) return (last - buf) + 10; + if (strcmp(mode, "count-above-buffer") == 0) return a3 + 64; + return rc; +} +`; + +const fixture = /* js */ ` +const fs = require("node:fs"); +for (const mode of fs.readdirSync("read").sort()) { + for (const list of [dir => fs.readdirSync(dir), dir => [...new Bun.Glob("*").scanSync({ cwd: dir })]]) { + try { + console.log(mode, list("read/" + mode).sort().join()); + } catch (e) { + console.log(mode, e.code); + } + } +} +`; + +const damaged = [ + "count-above-buffer", + "header-cut", + "name-unterminated", + "reclen-below-header", + "reclen-past-end", + "reclen-zero", + "second-record", +]; + +test.skipIf(!isLinux || !cc)("readdir and Bun.Glob fail with EIO on a malformed directory record", async () => { + const tree: Record<string, string> = { "shim.c": shim, "fixture.js": fixture }; + for (const mode of [...damaged, "intact"]) { + for (const file of ["a.txt", "b.txt", "c.txt"]) tree[`read/${mode}/${file}`] = ""; + } + using dir = tempDir("readdir-malformed-dirent", tree); + const shimPath = join(String(dir), "shim.so"); + await using compile = Bun.spawn({ + cmd: [cc!, "-shared", "-fPIC", "-o", shimPath, join(String(dir), "shim.c"), "-ldl"], + env: bunEnv, + stderr: "inherit", + }); + expect(await compile.exited).toBe(0); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.js"], + cwd: String(dir), + env: { ...bunEnv, LD_PRELOAD: [shimPath, bunEnv.LD_PRELOAD].filter(Boolean).join(":") }, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + // Each mode prints one line for node:fs and one for Bun.Glob, which use different directory walkers. + const expected = [...damaged.map(mode => `${mode} EIO`), "intact a.txt,b.txt,c.txt"].sort(); + expect(stdout.trim().split("\n")).toEqual(expected.flatMap(line => [line, line])); + expect(exitCode).toBe(0); +}); diff --git a/test/js/node/http/node-http-parser.test.ts b/test/js/node/http/node-http-parser.test.ts index dbda5947efe0..d501fabb23cf 100644 --- a/test/js/node/http/node-http-parser.test.ts +++ b/test/js/node/http/node-http-parser.test.ts @@ -409,6 +409,17 @@ describe("ConnectionsList", () => { expect(list.expired()).toEqual([]); }); + test("idle() and expired() skip a closed parser", () => { + const list = new ConnectionsList(); + const closed = new HTTPParser(); + closed.initialize(HTTPParser.REQUEST, {}, 0, 0, list); + closed.close(); + + expect(list.idle()).toEqual([]); + expect(list.expired(1, 1)).toEqual([]); + expect(list.all()).toEqual([closed]); + }); + test("works with HTTPParser", () => { const p1 = new HTTPParser(); p1.name = "parser1"; diff --git a/test/js/node/http/node-http-pinned-write.test.ts b/test/js/node/http/node-http-pinned-write.test.ts index 85040d451a14..49efd38b538a 100644 --- a/test/js/node/http/node-http-pinned-write.test.ts +++ b/test/js/node/http/node-http-pinned-write.test.ts @@ -396,6 +396,91 @@ describe("node:http large Buffer writes are sent zero-copy", () => { expect(exitCode).toBe(0); }); + // `grow()` detaches a `WebAssembly.Memory`'s buffer and frees the old block, even if the buffer is pinned. + // The loopback send and receive buffers absorb several MB, so the body has to be big enough that part of it + // is still pending after write(). + const WASM_PAGES = 512; + const wasmMemoryChild = /* js */ ` + import http from "node:http"; + import net from "node:net"; + import { once } from "node:events"; + const PAGES = ${WASM_PAGES}; // 32 MB + const newMemory = () => new WebAssembly.Memory({ initial: PAGES, maximum: PAGES + 4 }); + + const mem = newMemory(); + const payload = new Uint8Array(mem.buffer).fill(7); + const CHUNK_SIZE = payload.byteLength; + + let detachedAfterGrow; + const wrote = Promise.withResolvers(); + const server = http.createServer((req, res) => { + res.writeHead(200, { + "Content-Type": "application/octet-stream", + "Content-Length": String(CHUNK_SIZE), + }); + res.write(payload); + mem.grow(2); + detachedAfterGrow = payload.byteLength === 0; + // Claim the freed block so that reading it can't return the caller's bytes. + // A plain Uint8Array reuses it on release builds, and another memory does on debug builds. + globalThis.claim = Array.from({ length: 4 }, () => new Uint8Array(CHUNK_SIZE).fill(0xee)).concat( + Array.from({ length: 2 }, () => { + const memory = newMemory(); + new Uint8Array(memory.buffer).fill(0xee); + return memory; + }), + ); + wrote.resolve(); + res.end(); // spills the pending tail + }); + await once(server.listen(0), "listening"); + + const socket = net.connect(server.address().port, "127.0.0.1"); + await once(socket, "connect"); + socket.pause(); + socket.write("GET / HTTP/1.1\\r\\nHost: x\\r\\nConnection: close\\r\\n\\r\\n"); + await wrote.promise; + + const chunks = []; + socket.on("data", c => chunks.push(c)); + const closed = once(socket, "close"); + socket.resume(); + await closed; + + const received = Buffer.concat(chunks); + const body = received.subarray(received.indexOf("\\r\\n\\r\\n") + 4); + const expected = Buffer.alloc(CHUNK_SIZE, 7); + console.log(JSON.stringify({ + detachedAfterGrow, + bodyLength: body.length, + bodyMatches: Buffer.compare(body, expected) === 0, + })); + server.close(); + `; + + test.skipIf(isWindows)( + "write() copies a WebAssembly.Memory's bytes, so growing the memory afterwards doesn't corrupt the body", + async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", wasmMemoryChild], + // Without fast memory, `grow()` moves the block on every platform. With system malloc, the freed block + // is unmapped. + env: { ...bunEnv, BUN_JSC_useWasmFastMemory: "0", Malloc: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr }).toEqual({ + stdout: JSON.stringify({ detachedAfterGrow: true, bodyLength: WASM_PAGES * 64 * 1024, bodyMatches: true }), + stderr: expect.any(String), + }); + expect(exitCode).toBe(0); + }, + // Sending 32 MB on a debug build can exceed the default 5 s timeout. + 30_000, + ); + test("a second write before drain releases the pin on the first buffer", async () => { let detachedAfterSecondWrite: boolean | undefined; const total = CHUNK_SIZE + 1024; diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index 26142da2ae36..82572313f54a 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -7572,3 +7572,59 @@ it("req.socket.setKeepAlive() and resetAndDestroy() return the socket", async () server.close(); } }); + +it("addTrailers() and end() don't crash on the response of a request that was upgraded to a WebSocket", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const http = require("node:http"); + const net = require("node:net"); + const { WebSocketServer } = require("ws"); + const wss = new WebSocketServer({ noServer: true }); + const server = http.createServer((req, res) => { + if (req.url === "/first") { + res.write("body"); + res.addTrailers({ "x-first": Buffer.alloc(200, "a").toString() }); + res.end(); + return; + } + wss.handleUpgrade(req, req.socket, Buffer.alloc(0), ws => { + try { + res.addTrailers({ "x-second": Buffer.alloc(100, "b").toString() }); + res.end("late"); + } catch {} + ws.send("alive"); + }); + }); + server.listen(0, "127.0.0.1", () => { + const client = net.connect(server.address().port, "127.0.0.1"); + let received = ""; + let upgrading = false; + client.on("data", data => { + received += data.toString("latin1"); + if (!upgrading && received.endsWith("\\r\\n\\r\\n") && received.includes("x-first")) { + upgrading = true; + received = ""; + client.write( + "GET /second HTTP/1.1\\r\\nHost: x\\r\\nConnection: Upgrade\\r\\nUpgrade: websocket\\r\\n" + + "Sec-WebSocket-Version: 13\\r\\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\\r\\n\\r\\n", + ); + } else if (upgrading && received.endsWith("alive")) { + console.log(received.includes("x-second") ? "trailer on the WebSocket" : "alive"); + process.exit(0); + } + }); + client.write("GET /first HTTP/1.1\\r\\nHost: x\\r\\n\\r\\n"); + }); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr }).toEqual({ stdout: "alive\n", stderr: "" }); + expect(exitCode).toBe(0); +}); diff --git a/test/js/node/http2/node-http2-client-close.test.ts b/test/js/node/http2/node-http2-client-close.test.ts index 642de0da42de..ae9ff785bb22 100644 --- a/test/js/node/http2/node-http2-client-close.test.ts +++ b/test/js/node/http2/node-http2-client-close.test.ts @@ -322,6 +322,38 @@ for (const [site, table] of [ } } +// request() rejects invalid headers one tick later, so the user's close() or destroy() runs first. +describe("closing a request before its invalid headers are rejected keeps rstCode 0 and doesn't emit 'error'", () => { + const invalidHeaders = { + "a :path with a space": { ":path": "/a b" }, + "content-length on a DELETE": { ":method": "DELETE", "content-length": "1" }, + }; + const nextTurn = () => new Promise(resolve => setImmediate(resolve)); + + for (const [name, headers] of Object.entries(invalidHeaders)) { + for (const call of ["close", "destroy"] as const) { + test(`${call}() with ${name}`, async () => { + const server = http2.createServer(); + const client = http2.connect(`http://127.0.0.1:${await listen(server)}`); + try { + await new Promise(resolve => client.on("connect", resolve)); + const events: string[] = []; + const req = client.request(headers); + req.on("close", () => events.push("close:" + req.rstCode)); + req[call](); + // close() waits for 'ready' and then queues the RST_STREAM with setImmediate. + await nextTurn(); + await nextTurn(); + assert.deepStrictEqual(events, ["close:0"]); + } finally { + client.destroy(); + server.close(); + } + }); + } + } +}); + if (typeof Bun !== "undefined") { const node = Bun.which("node"); // Alpine's node segfaults at a random point of this file (alpine 3.23 aarch64, on main too), and diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 3fc46c689423..039554480383 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -5146,6 +5146,157 @@ it("http2 stream.respond accepts raw-headers arrays; respondWithFD/respondWithFi server.close(); } }); +it("http2 respondWithFile/respondWithFD send an empty range and reset the stream on an invalid offset", async () => { + using dir = tempDir("h2-respond-file-range", { + "empty.css": "", + "full.txt": "hello world", + "main.mjs": ` + import http2 from "node:http2"; + import fs from "node:fs"; + process.on("uncaughtException", e => { + console.log(JSON.stringify({ uncaughtException: e.code })); + process.exit(70); + }); + const cases = [ + ["empty-file", "empty.css", {}], + ["empty-fd", "empty.css", { fd: true }], + ["offset=size", "full.txt", { offset: 11 }], + ["offset=size+1", "full.txt", { offset: 12 }], + ["offset=NaN", "full.txt", { offset: NaN }], + ["offset=1.5", "full.txt", { offset: 1.5 }], + ["length=0", "full.txt", { offset: 0, length: 0 }], + ["offset=6", "full.txt", { offset: 6 }], + ]; + const results = {}; + const server = http2.createServer(); + server.on("stream", (stream, headers) => { + const [name, file, opts] = cases[+headers[":path"].slice(1)]; + const result = (results[name] ??= {}); + stream.on("error", e => (result.serverError = e.code)); + const options = { ...opts, onError: e => (result.onError = e.code) }; + if (opts.fd) { + delete options.fd; + const fd = fs.openSync(file, "r"); + stream.on("close", () => fs.closeSync(fd)); + stream.respondWithFD(fd, {}, options); + } else { + stream.respondWithFile(file, {}, options); + } + }); + server.listen(0, async () => { + const client = http2.connect("http://127.0.0.1:" + server.address().port); + for (let i = 0; i < cases.length; i++) { + const result = (results[cases[i][0]] ??= {}); + await new Promise(resolve => { + const req = client.request({ ":path": "/" + i }); + let body = ""; + req.setEncoding("utf8"); + req.on("response", h => { + result.status = h[":status"]; + result.contentLength = h["content-length"]; + }); + req.on("data", d => (body += d)); + req.on("error", e => (result.clientError = e.code)); + req.on("close", () => { + result.body = body; + result.rstCode = req.rstCode; + resolve(); + }); + }); + } + client.close(); + server.close(); + console.log(JSON.stringify(results)); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + const ok = { status: 200, contentLength: "0", body: "", rstCode: 0 }; + const reset = { + serverError: "ERR_OUT_OF_RANGE", + clientError: "ERR_HTTP2_STREAM_ERROR", + body: "", + rstCode: http2.constants.NGHTTP2_INTERNAL_ERROR, + }; + expect(JSON.parse(stdout.trim())).toEqual({ + "empty-file": ok, + "empty-fd": ok, + "offset=size": ok, + "offset=size+1": reset, + "offset=NaN": reset, + "offset=1.5": reset, + "length=0": ok, + "offset=6": { status: 200, contentLength: "5", body: "world", rstCode: 0 }, + }); + expect(exitCode).toBe(0); +}, 15_000); +it("http2 native setNextStreamID, pushPromise and altsvc throw for bad arguments", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const http2 = require("node:http2"); + const { Duplex } = require("node:stream"); + const socket = new Duplex({ write(chunk, encoding, callback) { callback(); }, read() {} }); + const session = http2.performServerHandshake(socket); + const native = session[Symbol.for("::bunhttp2native::")]; + const outcome = fn => { + try { + return "returned " + fn(); + } catch (err) { + return (err.code ?? err.name) + ": " + err.message; + } + }; + const value = 'h2=":443"'; + const fill = length => Buffer.alloc(length, "x").toString(); + // U+4E2D is three bytes in UTF-8. + const wide = bytes => fill(bytes % 3) + Buffer.alloc(bytes - (bytes % 3), "\u4e2d").toString(); + console.log(JSON.stringify([ + outcome(() => native.setNextStreamID()), + outcome(() => native.setNextStreamID("3")), + outcome(() => native.setNextStreamID(3)), + outcome(() => native.pushPromise("1", 2, {}, {})), + outcome(() => native.pushPromise(1, undefined, {}, {})), + outcome(() => native.pushPromise(NaN, 2, {}, {})), + outcome(() => native.pushPromise(1, 0, {}, {})), + outcome(() => native.altsvc(fill(16382 - value.length), value, 0)), + outcome(() => native.altsvc(fill(16383 - value.length), value, 0)), + outcome(() => native.altsvc(fill(65536), value, 0)), + outcome(() => session.altsvc(value, { origin: wide(16383 - value.length) })), + ], null, 1)); + process.exit(0); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + const tooLong = "ERR_HTTP2_ALTSVC_LENGTH: HTTP/2 ALTSVC frames are limited to 16382 bytes"; + expect(JSON.parse(stdout || "[]")).toEqual([ + "Error: Expected stream_id to be a number", + "Error: Expected stream_id to be a number", + "returned undefined", + "Error: Expected parentId to be a number", + "Error: Expected promisedId to be a number", + "Error: Invalid stream id", + "Error: Invalid stream id", + "returned undefined", + tooLong, + tooLong, + tooLong, + ]); + expect(exitCode).toBe(0); +}); it("http2 client.request() on a destroyed or closed session uses the right error codes", async () => { // Node: destroyed session -> ERR_HTTP2_INVALID_SESSION, // closed (GOAWAY-pending) session -> ERR_HTTP2_GOAWAY_SESSION. diff --git a/test/js/node/module/node-module-module.test.js b/test/js/node/module/node-module-module.test.js index c017ea4baea5..7f7bee6f3e06 100644 --- a/test/js/node/module/node-module-module.test.js +++ b/test/js/node/module/node-module-module.test.js @@ -39,6 +39,30 @@ describe.concurrent("node-module-module", () => { expect(Array.isArray(require("module").globalPaths)).toBe(true); }); + test("Object.entries(Module) passes exception check validation", async () => { + // jest-runtime copies Module's statics this way. Release builds ignore the option, so they only check that + // the copy is complete. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const Module = require("node:module"); + const copy = Object.fromEntries(Object.entries(Module)); + const missing = Object.keys(Module).filter(key => !(key in copy)); + console.log(JSON.stringify({ missing, builtinModules: copy.builtinModules.length, globalPaths: Array.isArray(copy.globalPaths) }));`, + ], + env: { ...bunEnv, BUN_JSC_validateExceptionChecks: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: JSON.stringify({ missing: [], builtinModules: 76, globalPaths: true }), + stderr: "", + exitCode: 0, + }); + }); + test("Module._findPath propagates an error thrown by an onResolve plugin", async () => { // Plugins are process-global; run in a child so the throwing resolver can't affect other tests. await using proc = Bun.spawn({ @@ -467,6 +491,52 @@ console.log("survived", require("./late.js"));`, expect(Module._resolveFilename("fs", null, false, { paths: null })).toBe("fs"); }); + // Hitting the real limit needs 5 GB, so the child runs with a 64 KiB synthetic allocation limit (2730 entries). + test("options.paths with too many entries for the native list throws a RangeError", async () => { + const LIMIT_BYTES = 64 * 1024; + const MAX_PATHS = Math.floor(LIMIT_BYTES / 24); + using dir = tempDir("resolve-paths-limit", { + "lib/node_modules/limit-pkg/package.json": JSON.stringify({ name: "limit-pkg", main: "index.js" }), + "lib/node_modules/limit-pkg/index.js": "module.exports = 1;", + "main.cjs": ` + const path = require("node:path"); + const Module = require("node:module"); + const resolvers = { + "require.resolve": paths => require.resolve("limit-pkg", { paths }), + "Module._resolveFilename": paths => Module._resolveFilename("limit-pkg", module, false, { paths }), + }; + // Only the last entry contains the package, so resolving it proves the whole list reached the resolver. + const resolveWith = (resolve, count) => { + const paths = new Array(count).fill(path.join(__dirname, "missing")); + paths[count - 1] = path.join(__dirname, "lib"); + try { + return path.relative(__dirname, resolve(paths)); + } catch (e) { + return e.name + ": " + e.message; + } + }; + const result = {}; + for (const [name, resolve] of Object.entries(resolvers)) { + result[name] = { atLimit: resolveWith(resolve, ${MAX_PATHS}), onePast: resolveWith(resolve, ${MAX_PATHS + 1}) }; + } + console.log(JSON.stringify(result)); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), path.join(String(dir), "main.cjs")], + env: { ...bunEnv, BUN_FEATURE_FLAG_SYNTHETIC_MEMORY_LIMIT: String(LIMIT_BYTES) }, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const resolved = path.join("lib", "node_modules", "limit-pkg", "index.js"); + expect(stderr).toBe(""); + expect(JSON.parse(stdout || "null")).toEqual({ + "require.resolve": { atLimit: resolved, onePast: "RangeError: Out of memory" }, + "Module._resolveFilename": { atLimit: resolved, onePast: "RangeError: Out of memory" }, + }); + expect(exitCode).toBe(0); + }); + test("createRequire trailing slash", () => { const req = createRequire(import.meta.dir + "/"); expect(req.resolve("./node-module-module.test.js")).toBe( @@ -474,6 +544,39 @@ console.log("survived", require("./late.js"));`, ); }); + // Runs in a child because a failure here crashes the process. + test("module APIs don't crash on paths longer than a path buffer", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const m = require("node:module"); + const long = Buffer.alloc(100_000, "a").toString(); + // createRequire() joins "noop.js" onto a directory path. + const req = m.createRequire("/" + Buffer.alloc(100_000, "d/").toString()); + let code; + try { req.resolve("./x"); } catch (e) { code = e.code; } + console.log(JSON.stringify({ + createRequire: code, + nodeModulePaths: m._nodeModulePaths("/" + long).map(p => p.length), + enableCompileCache: m.enableCompileCache(long).message, + }));`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + const root = path.resolve("/").length; + expect(JSON.parse(stdout)).toEqual({ + createRequire: "MODULE_NOT_FOUND", + nodeModulePaths: [root + 100_000 + "/node_modules".length, root + "node_modules".length], + enableCompileCache: "Cannot create cache directory: path too long", + }); + expect(exitCode).toBe(0); + }); + test("createRequire trailing slash file url", () => { const req = createRequire(Bun.pathToFileURL(import.meta.dir + "/")); expect(req.resolve("./node-module-module.test.js")).toBe( @@ -588,6 +691,33 @@ console.log("survived", require("./late.js"));`, expect(exitCode).toBe(0); }, 20_000); + test("Module._stat truncates the path at the first null byte", async () => { + using dir = tempDir("module-stat-null-byte", { "file.js": "", "directory": { "x": "" } }); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { _stat } = require("node:module"); + const path = require("node:path"); + const file = path.join(process.cwd(), "file.js"); + const directory = path.join(process.cwd(), "directory"); + console.log(JSON.stringify({ + file: _stat(file + "\\0ignored"), + directory: _stat(directory + "\\0ignored"), + missing: _stat(path.join(process.cwd(), "missing\\0file.js")) < 0, + }));`, + ], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + // node v26.3.0 prints the same line. It passes the path to uv_fs_stat as a C string. + expect(stdout).toBe('{"file":0,"directory":1,"missing":true}\n'); + expect(exitCode).toBe(0); + }); + test("Module.wrap", () => { var mod = { exports: {} }; expect(eval(wrap("exports.foo = 1; return 42"))(mod.exports, mod)).toBe(42); @@ -687,6 +817,46 @@ console.log("survived", require("./late.js"));`, expect(exitCode).toBe(0); }); + test("require() throws when an overridden _resolveFilename returns a path with a null byte", async () => { + using dir = tempDir("resolve-filename-null-byte", { + "x.js": `module.exports = "loaded:" + __filename;`, + "main.cjs": ` + const path = require("node:path"); + const { Module } = require("node:module"); + const real = path.join(__dirname, "x.js"); + Module._resolveFilename = () => real + "\\u0000IGNORED"; + const out = {}; + try { + out.require = "loaded " + require("anything"); + } catch (e) { + out.require = e.code; + out.requireMessageMentionsNullBytes = e.message.includes("without null bytes"); + } + // Node returns the override's value from require.resolve() unchanged. + try { + out.resolveKeepsNullByte = require.resolve("anything").endsWith("\\u0000IGNORED"); + } catch (e) { + out.resolveKeepsNullByte = e.code; + } + console.log(JSON.stringify(out)); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), path.join(String(dir), "main.cjs")], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + // Node v26.3.0 prints this exact object. + expect(JSON.parse(stdout)).toEqual({ + require: "ERR_INVALID_ARG_VALUE", + requireMessageMentionsNullBytes: true, + resolveKeepsNullByte: true, + }); + expect(exitCode).toBe(0); + }); + test("Overridden _resolveFilename receives a parent Module for createRequire from ESM", async () => { using dir = tempDir("resolve-filename-args-esm", { "real.cjs": "module.exports = 'REAL';", @@ -898,6 +1068,66 @@ console.log("survived", require("./late.js"));`, expect(await proc.exited).toBe(0); }); + // Node's Module.prototype.require is `Module._load(id, this, false)`, so `this` is only used as the parent. + test("Module.prototype.require accepts a `this` that isn't a module", async () => { + using dir = tempDir("module-prototype-require-this", { + "dep.cjs": `module.exports = { name: "dep" };`, + "bad.cjs": `module.exports = ;`, + "main.cjs": ` + const Module = require("node:module"); + const path = require("node:path"); + const dep = path.join(__dirname, "dep.cjs"); + const bad = path.join(__dirname, "bad.cjs"); + + function load(parent, request) { + try { + return Module.prototype.require.call(parent, request); + } catch (e) { + return "threw " + e.name; + } + } + + const plain = { id: __filename, filename: __filename }; + const result = {}; + result.plainObject = load(plain, dep); + result.parentIsThis = require.cache[dep].parent === plain; + result.requireAgain = require(dep) === result.plainObject; + delete require.cache[dep]; + result.number = load(5, dep); + delete require.cache[dep]; + result.nullThis = load(null, dep); + delete require.cache[dep]; + result.relative = load(plain, "./dep.cjs"); + result.builtin = load({}, "path") === path; + // A failed load must remove its placeholder module from require.cache. + result.failedLoad = load(plain, bad); + result.leftInCache = bad in require.cache; + console.log(JSON.stringify(result)); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.cjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + plainObject: { name: "dep" }, + parentIsThis: true, + requireAgain: true, + number: { name: "dep" }, + nullThis: { name: "dep" }, + relative: { name: "dep" }, + builtin: true, + failedLoad: "threw BuildMessage", + leftInCache: false, + }); + expect(exitCode).toBe(0); + }); + test.each([ "/file/name/goes/here.js", "file/here.js", @@ -994,6 +1224,52 @@ console.log("survived", require("./late.js"));`, expect(require("./esm_to_cjs_interop.mjs")).toEqual(Symbol.for("meow")); }); + test("require.cache throws if it's first accessed near the stack limit, and the next access creates it", async () => { + const code = ` + const Module = require("node:module"); + // Only the deepest frame catches the overflow, and any call into the builtin from there overflows again. + function probe(useModuleCache) { + let outcome; + function recurse() { + try { + recurse(); + } catch { + try { + outcome = typeof (useModuleCache ? Module._cache : require.cache); + } catch (err) { + outcome = err.constructor.name; + } + } + } + recurse(); + return outcome; + } + const probes = [probe(false), probe(true)]; + const cache = require.cache; + const hasEvalEntry = Object.keys(cache).some(key => key.endsWith("[eval]")); + const marker = {}; + cache.fs = { exports: marker }; + console.log( + JSON.stringify([ + probes, + Object.getPrototypeOf(cache), + Module._cache === cache, + hasEvalEntry, + require("fs") === marker, + ]), + ); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", code], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.trim()).toBe(JSON.stringify([["RangeError", "RangeError"], null, true, true, true])); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + test("Module.runMain", async () => { await using proc = Bun.spawn({ cmd: [ diff --git a/test/js/node/module/require-extensions.test.ts b/test/js/node/module/require-extensions.test.ts index 86dfa1d98429..430865f99c5e 100644 --- a/test/js/node/module/require-extensions.test.ts +++ b/test/js/node/module/require-extensions.test.ts @@ -1,6 +1,6 @@ import assert from "assert"; import { describe, expect, mock, test } from "bun:test"; -import { bunRun, tempDir } from "harness"; +import { bunEnv, bunExe, bunRun, tempDir } from "harness"; import path from "path"; test("require.extensions shape makes sense", () => { @@ -162,6 +162,45 @@ test("wrapping an existing extension but it's secretly sync esm", () => { require.extensions[".cjs"] = original; } }); +test.concurrent("default loader throws when module._compile was replaced with a non-function", async () => { + using dir = tempDir("extensions-bad-compile", { + "plain.js": `module.exports = "plain";`, + "run.cjs": ` + const Module = require("module"); + const file = require("path").join(__dirname, "plain.js"); + const original = Module._extensions[".js"]; + const messages = [undefined, null, 42, true, "not a function", {}].map(value => { + Module._extensions[".js"] = function (module, filename) { + module._compile = value; + return original(module, filename); + }; + try { + require(file); + return "did not throw"; + } catch (e) { + return e.name + ": " + e.message; + } + }); + Module._extensions[".js"] = original; + console.log(JSON.stringify({ messages: [...new Set(messages)], afterwards: require(file) })); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "run.cjs"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + messages: ["TypeError: module._compile is not a function"], + afterwards: "plain", + }); + expect(exitCode).toBe(0); +}); test("mutating extensions is banned by some files", () => { // vercel is not allowed to mutate require.extensions const files = ["node_modules/next/dist/build/next-config-ts/index.js", "node_modules/@meteorjs/babel/index.js"]; diff --git a/test/js/node/net/socketaddress.spec.ts b/test/js/node/net/socketaddress.spec.ts index 732ad9dc178d..4c88cee7ee07 100644 --- a/test/js/node/net/socketaddress.spec.ts +++ b/test/js/node/net/socketaddress.spec.ts @@ -218,6 +218,9 @@ describe("SocketAddress.parse", () => { it.each([ "", "invalid", + "x", + "a:80", + ".", "1.2.3.4.5.6", "0.0.0.9999", "1.2.3.4:-1", diff --git a/test/js/node/path/resolve.test.js b/test/js/node/path/resolve.test.js index 86de2722e3f8..289d0c304c3c 100644 --- a/test/js/node/path/resolve.test.js +++ b/test/js/node/path/resolve.test.js @@ -168,6 +168,12 @@ describe("path.resolve", () => { }, ); + test.skipIf(!isWindows)("win32 drive-relative input on another drive resolves against that drive's root", () => { + const drive = process.cwd()[0].toUpperCase() === "Z" ? "Y:" : "Z:"; + expect(path.resolve(drive + "foo")).toBe(drive + "\\foo"); + expect(path.resolve(drive + "a", "b")).toBe(drive + "\\a\\b"); + }); + test("undefined argument are ignored if absolute path comes first (reverse loop through args)", () => { expect(() => { return path.posix.resolve(undefined, "hi"); diff --git a/test/js/node/perf_hooks/perf_hooks.test.ts b/test/js/node/perf_hooks/perf_hooks.test.ts index f9aff1b1e9a5..ab29474c4f82 100644 --- a/test/js/node/perf_hooks/perf_hooks.test.ts +++ b/test/js/node/perf_hooks/perf_hooks.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe } from "harness"; import net from "net"; import perf, { PerformanceObserver, type PerformanceNodeEntry } from "perf_hooks"; @@ -365,3 +365,44 @@ test("mark/measure toJSON and inspection include detail without perf_hooks being expect(result.polluted).toBe("PerformanceMark {"); expect(exitCode).toBe(0); }); + +// `options.resolution` can be any safe integer >= 1. +describe("monitorEventLoopDelay with a resolution above the int32 range", () => { + const cases = [ + { resolution: 2 ** 31, earlierCall: false, when: "as the first call" }, + { resolution: 2 ** 31, earlierCall: true, when: "after an earlier call" }, + { resolution: 2 ** 32, earlierCall: true, when: "after an earlier call" }, + { resolution: 2 ** 32 + 1, earlierCall: true, when: "after an earlier call" }, + ]; + + test.concurrent.each(cases)("doesn't wrap resolution $resolution $when", async ({ resolution, earlierCall }) => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { monitorEventLoopDelay } = require("node:perf_hooks"); + ${earlierCall ? "monitorEventLoopDelay();" : ""} + const histogram = monitorEventLoopDelay({ resolution: ${resolution} }); + console.log("enable", histogram.enable()); + // If the resolution wrapped to 1 ms (2 ** 32 + 1 as an int32), it would record samples during these 10 ms. + setTimeout(() => { + console.log("count", histogram.count); + console.log("disable", histogram.disable()); + }, 10);`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + // If the period wrapped to 0 ms (2 ** 32 as an int32), the timer would re-arm forever and hang the event loop. + timeout: 4_000, + killSignal: "SIGKILL", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderr, stdout, exitCode, signalCode: proc.signalCode }).toEqual({ + stderr: "", + stdout: "enable true\ncount 0\ndisable true\n", + exitCode: 0, + signalCode: null, + }); + }); +}); diff --git a/test/js/node/process/dlopen-duplicate-load.test.ts b/test/js/node/process/dlopen-duplicate-load.test.ts index b4cc35c51367..69abfc9d5a06 100644 --- a/test/js/node/process/dlopen-duplicate-load.test.ts +++ b/test/js/node/process/dlopen-duplicate-load.test.ts @@ -1,25 +1,41 @@ import { spawnSync } from "bun"; import { beforeAll, describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, canBuildNodeAddons, tempDirWithFiles } from "harness"; +import { copyFileSync } from "fs"; +import { bunEnv, bunExe, canBuildNodeAddons, isWindows, tempDirWithFiles } from "harness"; import { join } from "path"; -// These tests share one node-gyp-built V8 addon (the compile dominates the wall -// time), covering two previously-broken paths: +// These tests share one node-gyp build of two V8 addons (the compile +// dominates the wall time). They cover: // - duplicate loads: the second dlopen of the same module used to fail with // "symbol 'napi_register_module_v1' not found" because static constructors // only run once, so the module registration wasn't replayed // - non-object exports: null/undefined/primitive exports used to segfault +// - non-context-aware addons (NODE_MODULE, the NAN pattern): loading one from a +// second thread must not run the init function again, because that would +// point the addon's static V8 handles into the second thread's heap +// - nested calls: a dlopen made while the filename is converted to a string, or +// from an init function, must not steal the outer call's registration +// - static constructor order: the init function must run after dlopen() returns, +// once the static constructors after the module registration have run describe.skipIf(!canBuildNodeAddons())("process.dlopen native addon", () => { let addonPath: string; + let nonContextAwarePath: string; + let workerFixturePath: string; + // A copy of the same addon at a second path, which the loader treats as a different library. + let addonCopyPath: string; beforeAll(() => { const addonSource = ` #include <node.h> +#include <node_api.h> namespace demo { +using v8::Boolean; using v8::Context; +using v8::Exception; +using v8::Function; using v8::FunctionCallbackInfo; using v8::Isolate; using v8::Local; @@ -27,21 +43,109 @@ using v8::Object; using v8::String; using v8::Value; +// Set by late_static's constructor, which is defined after the module registration. +static bool late_static_constructed = false; + void Hello(const FunctionCallbackInfo<Value>& args) { Isolate* isolate = args.GetIsolate(); args.GetReturnValue().Set(String::NewFromUtf8(isolate, "world").ToLocalChecked()); } +static Local<Value> GetOption(Isolate* isolate, Local<Context> context, Local<Value> module, const char* name) { + return module.As<Object>()->Get(context, String::NewFromUtf8(isolate, name).ToLocalChecked()).ToLocalChecked(); +} + +// The tests control the init function through properties on the module object: +// duringInit (a function to call), fatalInInit and throwInInit (true). void Initialize(Local<Object> exports, Local<Value> module, Local<Context> context, void* priv) { + Isolate* isolate = Isolate::GetCurrent(); + Local<Value> duringInit = GetOption(isolate, context, module, "duringInit"); + if (duringInit->IsFunction() && duringInit.As<Function>()->Call(context, module, 0, nullptr).IsEmpty()) { + return; + } + if (GetOption(isolate, context, module, "fatalInInit")->IsTrue()) { + napi_fatal_error("Initialize", NAPI_AUTO_LENGTH, "fatal error in init", NAPI_AUTO_LENGTH); + } + if (GetOption(isolate, context, module, "throwInInit")->IsTrue()) { + isolate->ThrowException(Exception::Error(String::NewFromUtf8(isolate, "thrown by init").ToLocalChecked())); + return; + } NODE_SET_METHOD(exports, "hello", Hello); + exports->Set(context, + String::NewFromUtf8(isolate, "lateStaticConstructed").ToLocalChecked(), + Boolean::New(isolate, late_static_constructed)).Check(); } } // namespace demo NODE_MODULE_CONTEXT_AWARE(addon, demo::Initialize) + +namespace demo { + +// The registration above runs in a static constructor. This constructor runs after it, like re2's addonDataMap. +struct LateStatic { + LateStatic() { late_static_constructed = true; } +}; +static LateStatic late_static; + +} // namespace demo +`; + + // Mimics what NAN generates: NODE_MODULE (no context-aware register function) and + // a static Persistent that the init function sets for the isolate it ran in. + const nonContextAwareSource = ` +#include <node.h> + +namespace demo { + +using v8::Context; +using v8::Function; +using v8::FunctionCallbackInfo; +using v8::FunctionTemplate; +using v8::Isolate; +using v8::Local; +using v8::Number; +using v8::Object; +using v8::Persistent; +using v8::String; +using v8::Value; + +Persistent<FunctionTemplate> constructor; +int init_count = 0; + +void Hello(const FunctionCallbackInfo<Value>& args) { + Isolate* isolate = args.GetIsolate(); + args.GetReturnValue().Set(String::NewFromUtf8(isolate, "world").ToLocalChecked()); +} + +void InitCount(const FunctionCallbackInfo<Value>& args) { + args.GetReturnValue().Set(Number::New(args.GetIsolate(), init_count)); +} + +// Reads the static that the init function set, like a NAN class's NewInstance. +void MakeInstance(const FunctionCallbackInfo<Value>& args) { + Isolate* isolate = args.GetIsolate(); + Local<Context> context = isolate->GetCurrentContext(); + Local<FunctionTemplate> tpl = Local<FunctionTemplate>::New(isolate, constructor); + Local<Function> fn = tpl->GetFunction(context).ToLocalChecked(); + args.GetReturnValue().Set(fn->NewInstance(context).ToLocalChecked()); +} + +void Initialize(Local<Object> exports, Local<Value> module, void* priv) { + Isolate* isolate = Isolate::GetCurrent(); + init_count++; + constructor.Reset(isolate, FunctionTemplate::New(isolate, Hello)); + NODE_SET_METHOD(exports, "hello", Hello); + NODE_SET_METHOD(exports, "initCount", InitCount); + NODE_SET_METHOD(exports, "makeInstance", MakeInstance); +} + +} // namespace demo + +NODE_MODULE(non_context_aware, demo::Initialize) `; const bindingGyp = ` @@ -50,13 +154,81 @@ NODE_MODULE_CONTEXT_AWARE(addon, demo::Initialize) { "target_name": "addon", "sources": [ "addon.cpp" ] + }, + { + "target_name": "non_context_aware", + "sources": [ "non_context_aware.cpp" ] } ] } +`; + + // Loads the addon at $ADDON_PATH on the main thread and in a Worker and + // prints one JSON report. + // + // Main thread first (default): main loads, the Worker loads, the Worker + // exits, main uses the exports it already has. + // + // Worker first ($WORKER_LOADS_FIRST): the Worker loads and then blocks on a + // message from main, main loads while the Worker is alive, the Worker + // exits, main loads again. + const workerFixture = ` +const { Worker, isMainThread, parentPort } = require("node:worker_threads"); + +function describeExports(exports) { + const report = { hello: exports.hello() }; + if (exports.initCount) report.initCount = exports.initCount(); + if (exports.makeInstance) report.instance = typeof exports.makeInstance(); + return report; +} + +function load() { + const m = { exports: {} }; + try { + process.dlopen(m, process.env.ADDON_PATH); + } catch (e) { + return { report: { loaded: false, code: e.code, message: e.message } }; + } + return { report: { loaded: true, ...describeExports(m.exports) }, exports: m.exports }; +} + +if (!isMainThread) { + parentPort.postMessage(load().report); + parentPort.on("message", () => process.exit(0)); +} else { + const workerLoadsFirst = !!process.env.WORKER_LOADS_FIRST; + const result = {}; + const main = workerLoadsFirst ? undefined : load(); + if (main) result.main = main.report; + + const worker = new Worker(__filename); + worker.on("message", report => { + result.worker = report; + if (workerLoadsFirst) result.mainWhileWorkerAlive = load().report; + worker.postMessage("exit"); + }); + worker.on("error", e => { + result.workerError = e.message; + }); + worker.on("exit", exitCode => { + result.workerExitCode = exitCode; + if (workerLoadsFirst) { + result.mainAfterWorkerExit = load().report; + } else if (main.exports) { + // makeInstance reads the static that the init function set. It must still + // point into this thread's heap after the worker has exited. + Bun.gc(true); + result.mainExportsAfterWorkerExit = describeExports(main.exports); + } + console.log(JSON.stringify(result)); + }); +} `; const dir = tempDirWithFiles("dlopen-duplicate-test", { "addon.cpp": addonSource, + "non_context_aware.cpp": nonContextAwareSource, + "worker-fixture.js": workerFixture, "binding.gyp": bindingGyp, "package.json": JSON.stringify({ name: "test", @@ -77,7 +249,7 @@ NODE_MODULE_CONTEXT_AWARE(addon, demo::Initialize) }), }); - // Build the addon + // Build the addons const build = spawnSync({ cmd: [bunExe(), "install"], cwd: dir, @@ -87,10 +259,14 @@ NODE_MODULE_CONTEXT_AWARE(addon, demo::Initialize) }); if (!build.success) { - throw new Error("Failed to build native addon"); + throw new Error("Failed to build native addons"); } addonPath = join(dir, "build", "Release", "addon.node"); + nonContextAwarePath = join(dir, "build", "Release", "non_context_aware.node"); + workerFixturePath = join(dir, "worker-fixture.js"); + addonCopyPath = join(dir, "build", "Release", "addon-copy.node"); + copyFileSync(addonPath, addonCopyPath); }, 180_000); // Each test spawns an isolated child (dlopen state is process-global), so @@ -161,6 +337,162 @@ NODE_MODULE_CONTEXT_AWARE(addon, demo::Initialize) }); }); + describe.concurrent("process.dlopen init function", () => { + test("runs after all of the addon's static constructors", async () => { + const testScript = ` + const m = { exports: {} }; + process.dlopen(m, ${JSON.stringify(addonPath)}); + console.log(JSON.stringify({ hello: m.exports.hello(), lateStaticConstructed: m.exports.lateStaticConstructed })); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", testScript], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ hello: "world", lateStaticConstructed: true }); + expect(exitCode).toBe(0); + }); + + // A debug build's exception check validator aborts if the throw isn't checked on the way out. + test("its exception propagates to the caller", async () => { + const testScript = ` + try { + process.dlopen({ exports: {}, throwInInit: true }, ${JSON.stringify(addonPath)}); + console.log("no exception"); + } catch (e) { + console.log("caught: " + e.message); + } + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", testScript], + env: { ...bunEnv, BUN_JSC_validateExceptionChecks: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout).toBe("caught: thrown by init\n"); + expect(exitCode).toBe(0); + }); + + // The crash handler only records which addon is loading on POSIX. + describe.skipIf(isWindows)("the crash report shows the addon whose init function crashed", () => { + test.each([ + ["in a plain load", "addon", `process.dlopen({ exports: {}, fatalInInit: true }, addon);`], + [ + "in the outer load, after a nested load", + "addon", + `process.dlopen({ exports: {}, fatalInInit: true, duringInit() { process.dlopen({ exports: {} }, addonCopy); } }, addon);`, + ], + [ + "in a nested load", + "addonCopy", + `process.dlopen({ exports: {}, duringInit() { process.dlopen({ exports: {}, fatalInInit: true }, addonCopy); } }, addon);`, + ], + ])( + "%s", + async (_, crashed, load) => { + const testScript = ` + const addon = ${JSON.stringify(addonPath)}; + const addonCopy = ${JSON.stringify(addonCopyPath)}; + ${load} + console.log("loaded"); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", testScript], + env: { ...bunEnv, BUN_INTERNAL_SUPPRESS_CRASH_ON_NAPI_ABORT: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toContain("NAPI FATAL ERROR: Initialize fatal error in init"); + expect(stderr).toContain( + `Crashed while loading native module: ${crashed === "addon" ? addonPath : addonCopyPath}\n`, + ); + expect(stdout).not.toContain("loaded"); + // The longer timeout is for debug builds, which symbolize the whole stack before exiting. + }, + 10_000, + ); + }); + }); + + describe.concurrent("process.dlopen nested in the init function", () => { + test.each([ + ["another addon", "addonCopy"], + ["the same addon", "addon"], + ])("both modules get their exports when the init function loads %s", async (_, nested) => { + const testScript = ` + const addon = ${JSON.stringify(addonPath)}; + const addonCopy = ${JSON.stringify(addonCopyPath)}; + const inner = { exports: {} }; + const outer = { exports: {}, duringInit() { process.dlopen(inner, ${nested}); } }; + process.dlopen(outer, addon); + console.log(JSON.stringify({ outer: typeof outer.exports.hello, inner: typeof inner.exports.hello })); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", testScript], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ outer: "function", inner: "function" }); + expect(exitCode).toBe(0); + }); + }); + + describe.concurrent("process.dlopen nested in the filename's toString()", () => { + test.each([ + ["fails", { outer: "function", inner: "undefined" }], + ["succeeds", { outer: "function", inner: "function" }], + ])("the outer module gets the exports when the nested dlopen %s", async (nested, expected) => { + const testScript = ` + const addonPath = ${JSON.stringify(addonPath)}; + const inner = { exports: {} }; + const outer = { exports: {} }; + process.dlopen(outer, { + toString() { + try { + process.dlopen(inner, ${nested === "fails" ? `addonPath + ".missing"` : "addonPath"}); + } catch {} + return addonPath; + }, + }); + console.log(JSON.stringify({ outer: typeof outer.exports.hello, inner: typeof inner.exports.hello })); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", testScript], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(expected); + expect(exitCode).toBe(0); + }); + }); + describe.concurrent("process.dlopen with non-object exports", () => { test("should throw error when exports is null", async () => { const testScript = ` @@ -239,4 +571,61 @@ NODE_MODULE_CONTEXT_AWARE(addon, demo::Initialize) expect(exitCode).toBe(0); }); }); + + describe.concurrent("process.dlopen of an addon that is already loaded", () => { + async function runJsonFixture(cmd: string[], env: Record<string, string>) { + await using proc = Bun.spawn({ + cmd, + env: { ...bunEnv, ...env }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ exe: cmd[0], stderr, exitCode }).toEqual({ exe: cmd[0], stderr: "", exitCode: 0 }); + return JSON.parse(stdout); + } + + const selfRegisterError = { + loaded: false, + code: "ERR_DLOPEN_FAILED", + message: expect.stringContaining("Module did not self-register"), + }; + + test("a non-context-aware addon loaded by the main thread fails to load in a Worker", async () => { + expect(await runJsonFixture([bunExe(), workerFixturePath], { ADDON_PATH: nonContextAwarePath })).toEqual({ + main: { loaded: true, hello: "world", initCount: 1, instance: "object" }, + worker: selfRegisterError, + workerExitCode: 0, + mainExportsAfterWorkerExit: { hello: "world", initCount: 1, instance: "object" }, + }); + }); + + test("a non-context-aware addon loaded by a Worker fails on the main thread until the Worker exits", async () => { + const env = { ADDON_PATH: nonContextAwarePath, WORKER_LOADS_FIRST: "1" }; + expect(await runJsonFixture([bunExe(), workerFixturePath], env)).toEqual({ + worker: { loaded: true, hello: "world", initCount: 1, instance: "object" }, + mainWhileWorkerAlive: selfRegisterError, + workerExitCode: 0, + mainAfterWorkerExit: { loaded: true, hello: "world", initCount: 2, instance: "object" }, + }); + }); + + // `bun --hot` relies on this. + test("a non-context-aware addon loads again on the thread that first loaded it", async () => { + const script = ` + const load = () => { + const m = { exports: {} }; + process.dlopen(m, process.env.ADDON_PATH); + return { hello: m.exports.hello(), initCount: m.exports.initCount(), instance: typeof m.exports.makeInstance() }; + }; + const first = load(); + const second = load(); + console.log(JSON.stringify({ first, second })); + `; + expect(await runJsonFixture([bunExe(), "-e", script], { ADDON_PATH: nonContextAwarePath })).toEqual({ + first: { hello: "world", initCount: 1, instance: "object" }, + second: { hello: "world", initCount: 2, instance: "object" }, + }); + }); + }); }); diff --git a/test/js/node/process/process-signal-listener-count.test.ts b/test/js/node/process/process-signal-listener-count.test.ts index b4bac490ab77..1f8e6f419b6d 100644 --- a/test/js/node/process/process-signal-listener-count.test.ts +++ b/test/js/node/process/process-signal-listener-count.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isWindows, normalizeBunSnapshot } from "harness"; // When multiple listeners are registered for the same signal, removing one @@ -134,3 +134,65 @@ done" `); expect(exitCode).toBe(0); }); + +// SIGIOT is an alias for SIGABRT. Node starts one watcher per event name, so one signal runs the listeners +// for both names, and the OS handler is only removed once both names have no listeners. +// On Windows, no signal number has two names. +describe.concurrent.skipIf(isWindows)("SIGABRT and SIGIOT", () => { + // The child adds a listener for each of `events` and sends `sent` to itself. + // It prints every listener call as [event, name argument, number argument]. + // It also adds a listener for `removed` and removes it before sending the signal. + async function run({ sent, events, removed }: { sent: string; events: string[]; removed?: string }) { + const script = /*js*/ ` + const calls = []; + for (const event of ${JSON.stringify(events)}) { + process.on(event, (name, number) => calls.push([event, name, number])); + } + const removed = ${JSON.stringify(removed)}; + if (removed) { + const listener = () => calls.push([removed, "removed"]); + process.on(removed, listener); + process.off(removed, listener); + } + + const keepAlive = setTimeout(() => {}, 60_000); + process.on("SIGUSR2", () => { + clearTimeout(keepAlive); + console.log(JSON.stringify(calls.sort())); + }); + process.kill(process.pid, ${JSON.stringify(sent)}); + // The event loop runs the pending signal's listeners before the immediate. + setImmediate(() => process.kill(process.pid, "SIGUSR2")); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode, signalCode: proc.signalCode }; + } + + const listenersRan = (events: string[]) => ({ + stdout: JSON.stringify(events.map(event => [event, event, 6]).sort()) + "\n", + stderr: "", + exitCode: 0, + signalCode: null, + }); + + test.each([ + ["SIGABRT", ["SIGIOT"]], + ["SIGIOT", ["SIGABRT"]], + ["SIGABRT", ["SIGABRT", "SIGIOT"]], + ])("sending %s runs the listeners for %p", async (sent, events) => { + expect(await run({ sent, events })).toEqual(listenersRan(events)); + }); + + test.each([ + ["SIGABRT", "SIGIOT"], + ["SIGIOT", "SIGABRT"], + ])("removing the %s listener keeps the handler for the %s listener", async (removed, kept) => { + expect(await run({ sent: removed, events: [kept], removed })).toEqual(listenersRan([kept])); + }); +}); diff --git a/test/js/node/process/process-stdio.test.ts b/test/js/node/process/process-stdio.test.ts index 6d0a0b9c4ddd..32385537b981 100644 --- a/test/js/node/process/process-stdio.test.ts +++ b/test/js/node/process/process-stdio.test.ts @@ -159,6 +159,31 @@ describe.concurrent("process-stdio", () => { `hello worldhello again|😋 Get Emoji — All Emojis to ✂️ Copy and 📋 Paste 👌`.repeat(9999), ); }); + + test.skipIf(isWindows)("process.stdout still writes when it's first read with no file descriptors left", async () => { + const script = ` + const fs = require("node:fs"); + // Debug builds load the stream modules from disk on first use, so load them before the fds run out. + process.stderr; + await new Promise(resolve => process.nextTick(resolve)); + const held = []; + try { + for (;;) held.push(fs.openSync("/dev/null", "r")); + } catch {} + const stdout = process.stdout; + for (const fd of held) fs.closeSync(fd); + stdout.write("written\\n", err => console.error("callback", err)); + `; + await using proc = spawn({ + cmd: ["sh", "-c", 'ulimit -n 64 && exec "$0" -e "$1"', bunExe(), script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr }).toEqual({ stdout: "written\n", stderr: "callback null\n" }); + expect(exitCode).toBe(0); + }); }); // Like Node, process.stdout/stderr put O_NONBLOCK on fd 1/2 when they are pipes or sockets. The flag lives on the shared open diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 15dc93631425..e74d93b0e88f 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -472,6 +472,72 @@ it("process.env is spreadable and editable", () => { expect(eval(`globalThis.process.env.USER = "${orig}"`)).toBe(String(orig)); }); +it("process.env coerces the value to a string every time the same assignment runs", () => { + const computedKey = "BUN_TEST_ENV_REPEATED_BYVAL"; + process.env.BUN_TEST_ENV_REPEATED_EXISTING = "preset"; + try { + const fresh = []; + const existing = []; + const byVal = []; + const byIndex = []; + for (let i = 0; i < 4; i++) { + process.env.BUN_TEST_ENV_REPEATED_FRESH = i; + fresh.push(process.env.BUN_TEST_ENV_REPEATED_FRESH); + process.env.BUN_TEST_ENV_REPEATED_EXISTING = i; + existing.push(process.env.BUN_TEST_ENV_REPEATED_EXISTING); + process.env[computedKey] = i; + byVal.push(process.env[computedKey]); + process.env[700] = i; + byIndex.push(process.env[700]); + } + // First store to an adjacent index. + process.env[701] = 7; + const expected = ["0", "1", "2", "3"]; + expect({ fresh, existing, byVal, byIndex, neighbor: process.env[701] }).toEqual({ + fresh: expected, + existing: expected, + byVal: expected, + byIndex: expected, + neighbor: "7", + }); + } finally { + delete process.env.BUN_TEST_ENV_REPEATED_FRESH; + delete process.env.BUN_TEST_ENV_REPEATED_EXISTING; + delete process.env[computedKey]; + delete process.env[700]; + delete process.env[701]; + } +}); + +it.concurrent("process.env stores the value when its toString() modifies process.env", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + process.env.SAME = { toString() { process.env.SAME = "inner"; return "same" } }; + console.log("same", process.env.SAME); + process.env.ADD = "0"; + process.env.ADD = { toString() { process.env.ADDED = "1"; return "add" } }; + console.log("add", process.env.ADD, process.env.ADDED); + process.env.DEL = "0"; + process.env.DOOMED = "0"; + process.env.DEL = { toString() { delete process.env.DOOMED; return "del" } }; + console.log("del", process.env.DEL, String(process.env.DOOMED)); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr: exitCode === 0 ? "" : stderr, exitCode }).toEqual({ + stdout: "same same\nadd add 1\ndel del undefined\n", + stderr: "", + exitCode: 0, + }); +}); + it("process.env reads are never stale after a write (JIT inline-cache soundness)", async () => { // process.env only sets OverridesPut (not ProhibitsPropertyCaching), so // reads hit the ordinary self-access IC. This test verifies that writes @@ -753,6 +819,36 @@ it("process.binding", () => { expect(() => process.binding(Object.freeze({ __proto__: null }))).toThrow(); }); +it("spreading a process.binding() object passes exception check validation", async () => { + // Release builds ignore BUN_JSC_validateExceptionChecks, so there this only checks that every key is copied. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const missing = []; + for (const name of ["buffer", "config", "constants", "crypto/x509", "fs", "http_parser", "natives", "tty_wrap", "util", "uv"]) { + const binding = process.binding(name); + const spread = { ...binding }; + for (const key of Object.keys(binding)) { + if (!(key in spread) || spread[key] !== binding[key]) missing.push(name + "." + key); + } + } + const { methods, allMethods } = process.binding("http_parser"); + const { statValues, bigintStatValues } = process.binding("fs"); + console.log(JSON.stringify({ missing, methods: methods.length, allMethods: allMethods.length, statValues: statValues.length, bigintStatValues: bigintStatValues.length }));`, + ], + env: { ...bunEnv, BUN_JSC_validateExceptionChecks: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: JSON.stringify({ missing: [], methods: 35, allMethods: 47, statValues: 36, bigintStatValues: 36 }), + stderr: "", + exitCode: 0, + }); +}); + it("process.argv in testing", () => { expect(process.argv).toBeInstanceOf(Array); expect(process.argv[0]).toBe(process.execPath); @@ -1638,17 +1734,21 @@ describe.concurrent(() => { expect(() => process.dlopen({ module: { exports: Symbol("123") } }, Symbol("badddd"))).toThrow(); }); - it("dlopen rejects over-length paths with ERR_DLOPEN_FAILED", async () => { - // Spawn so an unfixed build crashing doesn't take the whole suite down. + it.each([ // On Windows the path is widened into a 32767-unit WPathBuffer; an // over-length path must come back as an error, not a Rust panic across - // the extern "C" boundary. POSIX already surfaces dlerror() here. + // the extern "C" boundary. + ["longer than the Windows path buffer", 40000], + // glibc's dlopen() uses alloca() to copy a name without a "/" onto the stack. + ["longer than the stack", 16 * 1024 * 1024], + ])("dlopen rejects over-length paths with ERR_DLOPEN_FAILED (%s)", async (_, length) => { + // Spawn so an unfixed build crashing doesn't take the whole suite down. await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", `try { - process.dlopen({ exports: {} }, Buffer.alloc(40000, "x").toString()); + process.dlopen({ exports: {} }, Buffer.alloc(${length}, "x").toString()); console.log("FAIL: did not throw"); } catch (e) { console.log("CODE:" + e.code); @@ -1680,6 +1780,36 @@ describe.concurrent(() => { ); }); + // Macros run inside the crash handler's parse action, and dlopen sets its own action on top of it. + it("dlopen works in a macro", async () => { + using dir = tempDir("dlopen-macro", { + "macro.ts": `export function load() { + try { + process.dlopen({ exports: {} }, "not-found.node"); + } catch (e) { + return e.code; + } + return "loaded"; +} +`, + "index.ts": `import { load } from "./macro.ts" with { type: "macro" }; +console.log(load()); +`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + // Debug builds also log "[macro] call load" to stdout. + expect(stdout).toContain("ERR_DLOPEN_FAILED\n"); + expect(exitCode).toBe(0); + }); + it("process.constrainedMemory()", () => { expect(process.constrainedMemory() >= 0).toBe(true); }); @@ -1781,6 +1911,41 @@ describe.concurrent(() => { expect(exitCode).toBe(0); }); + it("building process.env doesn't run Object.prototype setters for a variable named like an array index", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `let fired = 0; + let caught = null; + Object.defineProperty(Object.prototype, "0", { + configurable: true, + set() { + fired++; + throw new Error("boom"); + }, + }); + try { + process.env; + } catch (e) { + caught = e.message; + } + delete Object.prototype[0]; + const env = process.env; + console.log(JSON.stringify({ fired, caught, zero: env[0], named: env.BUN_TEST_LAZY_ENV, cached: Bun.env === env }));`, + ], + env: { ...bunEnv, "0": "zero", BUN_TEST_LAZY_ENV: "lazy-env-value" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderr, exitCode, result: stdout && JSON.parse(stdout) }).toEqual({ + stderr: "", + exitCode: 0, + result: { fired: 0, caught: null, zero: "zero", named: "lazy-env-value", cached: true }, + }); + }); + if (isWindows) { it("ownKeys trap windows process.env", () => { expect(() => Object.keys(process.env)).not.toThrow(); @@ -1823,6 +1988,107 @@ describe.concurrent(() => { delete process.env.BUN_TEST_ENV_PROXY; } }); + + // On Windows a JS builtin finishes building process.env, and it throws a RangeError near the stack limit. + async function firstReadNearStackLimitBuildsRealEnv(entryPoint) { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const entryPoint = process.argv[1]; + let reached = false; + const readers = { + "process.env": () => { reached = true; return process.env; }, + "Bun.env": () => { reached = true; return Bun.env; }, + "import.meta.env": () => { reached = true; return import.meta.env; }, + "Bun.$": () => { reached = true; return Bun.$; }, + "Bun.sql": () => { reached = true; return Bun.sql; }, + }; + const read = readers[entryPoint]; + let value; + let readThrew = 0; + function recurse() { + try { + recurse(); + } catch {} + if (value !== undefined) return; + reached = false; + try { + value = read(); + } catch { + // Ignore a stack overflow thrown by the call to read() itself. + if (reached) readThrew++; + } + } + recurse(); + const result = { + retried: readThrew > 0, + type: typeof value, + envIntact: process.env.BUN_TEST_LAZY_ENV === "lazy-env-value", + sameObject: Bun.env === process.env && import.meta.env === process.env, + }; + if (entryPoint.endsWith("env")) result.isEnv = value === process.env; + if (entryPoint === "Bun.$") result.echo = (await value({ raw: ["echo $BUN_TEST_LAZY_ENV"] }).text()).trim(); + console.log(JSON.stringify(result));`, + entryPoint, + ], + env: { ...bunEnv, BUN_TEST_LAZY_ENV: "lazy-env-value" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const expected = { retried: true, envIntact: true, sameObject: true }; + if (entryPoint.endsWith("env")) Object.assign(expected, { type: "object", isEnv: true }); + else if (entryPoint === "Bun.$") Object.assign(expected, { type: "function", echo: "lazy-env-value" }); + else Object.assign(expected, { type: "function" }); + expect({ stderr, exitCode, result: stdout && JSON.parse(stdout) }).toEqual({ + stderr: "", + exitCode: 0, + result: expected, + }); + } + + it.each(["process.env", "import.meta.env"])( + "%s throws when first read near the stack limit, and the next read builds the real env", + firstReadNearStackLimitBuildsRealEnv, + ); + + // The builtin assigns toJSON to an ordinary object, so this setter runs mid-build. + it("reading process.env from user code while it's being built returns the same env object", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `let inner; + Object.defineProperty(Object.prototype, "toJSON", { + configurable: true, + set(fn) { + // Remove the setter first, because the inner build assigns toJSON too. + delete Object.prototype.toJSON; + inner = process.env; + Object.defineProperty(this, "toJSON", { value: fn, writable: true, configurable: true, enumerable: true }); + }, + }); + const outer = Bun.env; + console.log(JSON.stringify({ + reentered: inner !== undefined, + oneObject: outer === inner && process.env === inner && import.meta.env === inner, + envIntact: inner.BUN_TEST_LAZY_ENV, + json: JSON.parse(JSON.stringify(process.env)).BUN_TEST_LAZY_ENV, + }));`, + ], + env: { ...bunEnv, BUN_TEST_LAZY_ENV: "lazy-env-value" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderr, exitCode, result: stdout && JSON.parse(stdout) }).toEqual({ + stderr: "", + exitCode: 0, + result: { reentered: true, oneObject: true, envIntact: "lazy-env-value", json: "lazy-env-value" }, + }); + }); } it("catches exceptions with process.setUncaughtExceptionCaptureCallback", async () => { @@ -3300,3 +3566,55 @@ it("no socket close handler runs after the 'exit' event", async () => { expect(stdout).toBe("exit\n"); expect(exitCode).toBe(0); }); + +it("native functions that define a property on `this` throw a TypeError when the receiver rejects it", async () => { + const src = ` + // (module (type $s (struct (field (mut i32)))) + // (func (export "mk") (result (ref null $s)) struct.new_default $s)) + const bytes = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + 0x01, 0x0a, 0x02, 0x5f, 0x01, 0x7f, 0x01, 0x60, 0x00, 0x01, 0x63, 0x00, + 0x03, 0x02, 0x01, 0x01, + 0x07, 0x06, 0x01, 0x02, 0x6d, 0x6b, 0x00, 0x00, + 0x0a, 0x07, 0x01, 0x05, 0x00, 0xfb, 0x01, 0x00, 0x0b, + ]); + const wasmGCReference = new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports.mk(); + const requirePrototype = Object.getPrototypeOf(require); + const natives = { + ppid: o => Object.getOwnPropertyDescriptor(process, "ppid").set.call(o, 7), + cache: o => Object.getOwnPropertyDescriptor(requirePrototype, "cache").set.call(o, 7), + extensions: o => Object.getOwnPropertyDescriptor(requirePrototype, "extensions").set.call(o, 7), + StringDecoder: o => require("node:string_decoder").StringDecoder.call(o, "utf8"), + on: o => process.on.call(o, "x", () => {}), + }; + for (const [name, native] of Object.entries(natives)) { + for (const receiver of [wasmGCReference, Object.freeze({})]) { + try { + native(receiver); + console.log(name, "no error"); + } catch (e) { + console.log(name, e.constructor.name); + } + } + } + process.ppid = "assigned"; + require.cache = "assigned"; + console.log(process.ppid, require.cache); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.split("\n"), stderr, exitCode }).toEqual({ + stdout: [ + ...["ppid", "cache", "extensions", "StringDecoder", "on"].flatMap(name => Array(2).fill(name + " TypeError")), + "assigned assigned", + "", + ], + stderr: "", + exitCode: 0, + }); +}); diff --git a/test/js/node/quic/quic-endpoint.test.ts b/test/js/node/quic/quic-endpoint.test.ts index 868f9feae7c4..863bb2f3593d 100644 --- a/test/js/node/quic/quic-endpoint.test.ts +++ b/test/js/node/quic/quic-endpoint.test.ts @@ -2,7 +2,7 @@ // connect() through an endpoint; a later connect in the other mode must fail // loudly instead of silently reusing an engine that cannot frame it. import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness"; +import { bunEnv, bunExe, isASAN, isLinux, normalizeBunSnapshot, tempDir } from "harness"; import { createPrivateKey } from "node:crypto"; import { createSocket } from "node:dgram"; import { readFileSync } from "node:fs"; @@ -310,3 +310,96 @@ describe("endpoint.close() while a session is live", () => { expect({ announced, resolved }).toEqual({ announced: 1, resolved: true }); }); }); + +describe("node:quic in a Worker", () => { + test("worker.terminate() doesn't crash with live sessions and streams", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + join(import.meta.dir, "quic-worker-terminate-fixture.ts"), + join(keysDir, "agent1-key.pem"), + join(keysDir, "agent1-cert.pem"), + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // stderr contains each worker's ExperimentalWarning. + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "PASS\n", stderr: expect.any(String), exitCode: 0 }); + }, 30000); +}); + +// lsquic queues a stateless reset for retry when its send fails. The endpoint socket sets IP_RECVERR, +// so on Linux the ICMP port unreachable for one reset fails the next send with ECONNREFUSED. +describe("endpoint.close() with an unsent stateless reset", () => { + test.skipIf(!isASAN || !isLinux)( + "frees the reset's connection object", + async () => { + const fixture = ` + import { createPrivateKey } from "node:crypto"; + import { readFileSync } from "node:fs"; + import { listen } from "node:quic"; + + const key = createPrivateKey(readFileSync(${JSON.stringify(join(keysDir, "agent1-key.pem"))})); + const cert = readFileSync(${JSON.stringify(join(keysDir, "agent1-cert.pem"))}); + const server = await listen(async s => { + await s.closed.catch(() => {}); + }, { + sni: { "*": { keys: [key], certs: [cert] } }, + transportParams: { maxIdleTimeout: 1 }, + }); + const { port } = server.address; + + // A short-header packet with an unknown DCID. The server answers each one with a stateless reset. + const stray = fill => Buffer.alloc(64, fill); + + // The server socket's first writable event retries unsent packets, so wait until it has fired. + const answered = Promise.withResolvers(); + const live = await Bun.udpSocket({ hostname: "127.0.0.1", socket: { data: () => answered.resolve() } }); + live.send(stray(0x40), port, "127.0.0.1"); + await answered.promise; + live.close(); + + // Trigger two resets to ports that close before the server answers. The ICMP + // error for the first reset makes the second send fail. + const fills = [0x41, 0x42]; + const gone = await Promise.all(fills.map(() => Bun.udpSocket({ hostname: "127.0.0.1" }))); + gone.forEach((socket, i) => socket.send(stray(fills[i]), port, "127.0.0.1")); + for (const socket of gone) socket.close(); + while (server.stats.statelessResetCount < 3n) await new Promise(resolve => setImmediate(resolve)); + console.log("resets=" + server.stats.statelessResetCount + " sent=" + server.stats.packetsSent); + await server.close(); + + // The loop's send buffer still points into the unsent reset. Send one more + // datagram to overwrite it, so LeakSanitizer doesn't see the reset as reachable. + const last = await Bun.udpSocket({ hostname: "127.0.0.1" }); + last.send(Buffer.alloc(8), last.port, "127.0.0.1"); + last.close(); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: { + ...bunEnv, + NODE_NO_WARNINGS: "1", + BUN_DESTRUCT_VM_ON_EXIT: "1", + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=1"].filter(Boolean).join(":"), + LSAN_OPTIONS: `print_suppressions=0:suppressions=${join(import.meta.dir, "..", "..", "..", "leaksan.supp")}`, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // sent=2 means the third reset was still unsent when the engine was destroyed. + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: "resets=3 sent=2", + stderr: "", + exitCode: 0, + }); + }, + // LeakSanitizer needs more than the default 5 s on a debug build. + 60_000, + ); +}); diff --git a/test/js/node/quic/quic-sni.test.ts b/test/js/node/quic/quic-sni.test.ts index af944e671366..460332bb9010 100644 --- a/test/js/node/quic/quic-sni.test.ts +++ b/test/js/node/quic/quic-sni.test.ts @@ -107,6 +107,48 @@ test("an identity with several cert/key pairs installs a matching pair", async ( } }); +// The client destroys itself as soon as it has 1-RTT keys. The listener reads its Finished and +// CONNECTION_CLOSE together, so lsquic frees the connection before the session reports the handshake. +test("session.certificate returns the servername's identity after lsquic frees the connection", async () => { + const reported = Promise.withResolvers<unknown>(); + await using server = await listen( + session => { + ignoreErrors(session); + session.closed.then( + () => reported.reject(new Error("the session closed before it reported its handshake")), + reported.reject, + ); + session.onhandshake = info => + reported.resolve({ + servername: info.servername, + certificate: (session.certificate as X509Certificate | undefined)?.subject.match(/CN=([^\s,]+)/)?.[1], + peerMaxIdleTimeout: (session as any).remoteTransportParams?.maxIdleTimeout, + }); + }, + { sni: { "*": identity1, "agent2.example": identity2 }, alpn: ["quic-test"] }, + ); + + const client = await connect(server.address!, { + alpn: "quic-test", + servername: "agent2.example", + verifyPeer: "manual", + transportParams: { maxIdleTimeout: 5 }, + keylog: true, + onkeylog(line) { + if (line.startsWith("CLIENT_TRAFFIC_SECRET_0")) this.destroy(undefined, { code: 1n }); + }, + onerror() {}, + } as SessionOptions); + client.opened.catch(() => {}); + client.closed.catch(() => {}); + + expect(await reported.promise).toEqual({ + servername: "agent2.example", + certificate: "agent2", + peerMaxIdleTimeout: 5n, + }); +}); + test("setSNIContexts() rejects a non-object and a closed endpoint", async () => { const endpoint = new QuicEndpoint(); expect(() => endpoint.setSNIContexts("nope" as any)).toThrow( diff --git a/test/js/node/quic/quic-stream-recycled-close-fixture.ts b/test/js/node/quic/quic-stream-recycled-close-fixture.ts new file mode 100644 index 000000000000..001b37853950 --- /dev/null +++ b/test/js/node/quic/quic-stream-recycled-close-fixture.ts @@ -0,0 +1,108 @@ +// Each flight, the client opens FLIGHT unidirectional streams in one tick. The server destroys the +// first REFUSED of them in `onstream` while their read and close events are still queued on the +// session, runs GC, and opens FRESH streams that reuse the freed allocations. If a stale close event +// reaches a fresh stream, it loses its GC root and the next GC frees it while lsquic is still writing. +import { createPrivateKey } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { connect, listen, type QuicStream, type SessionOptions } from "node:quic"; + +const [keyPath, certPath] = process.argv.slice(2); +const key = createPrivateKey(readFileSync(keyPath)); +const cert = readFileSync(certPath); + +const FLIGHTS = 2; +const FLIGHT = 8; +const REFUSED = 6; +const FRESH = 8; +// Larger than lsquic accepts in one write, so each stream still has a pending write after its flight. +const BODY = 65536; +// If a body is lost, the session idles out before the test times out. +const transportParams = { maxIdleTimeout: 10, initialMaxStreamsUni: 1024 }; + +let opened = 0; +let delivered = 0; +const closedBeforeSent: { id: number; sent: number; error?: string }[] = []; +const failed = Promise.withResolvers<void>(); +let flightDelivered = Promise.withResolvers<void>(); + +async function read(stream: QuicStream) { + const chunks: Uint8Array[] = []; + for await (const batch of stream) chunks.push(...batch); + return Buffer.concat(chunks); +} + +const server = await listen( + session => { + session.closed.catch(() => {}); + let seen = 0; + session.onstream = stream => { + stream.closed.catch(() => {}); + const index = seen++ % FLIGHT; + if (index < REFUSED) { + stream.destroy(); + return; + } + read(stream).catch(() => {}); + if (index !== REFUSED) return; + Bun.gc(true); + for (let i = 0; i < FRESH; i++) { + const id = opened++; + const body = Buffer.alloc(BODY, 0x66); + body.writeUInt32LE(id, 0); + session.createUnidirectionalStream({ body }).then(fresh => { + // `stats` is created lazily, and it reads all zeros if it's first accessed after the close. + const stats = fresh.stats; + const settled = (error?: unknown) => { + const sent = Number(stats.bytesSent); + if (sent === BODY && error === undefined) return; + closedBeforeSent.push({ id, sent, error: error === undefined ? undefined : String(error) }); + failed.resolve(); + }; + fresh.closed.then(() => settled(), settled); + }); + } + }; + }, + { alpn: ["echo"], sni: { "*": { keys: [key], certs: [cert] } }, transportParams }, +); + +const client = await connect(server.address!, { + alpn: "echo", + servername: "localhost", + verifyPeer: "manual", + transportParams, + onstream(stream: QuicStream) { + stream.closed.catch(() => {}); + read(stream).then( + body => { + if (body.length === BODY) delivered++; + if (delivered === opened) flightDelivered.resolve(); + }, + () => {}, + ); + }, +} as SessionOptions); +client.closed.then( + () => failed.resolve(), + () => failed.resolve(), +); +await client.opened; + +let ok = true; +for (let flight = 0; flight < FLIGHTS; flight++) { + flightDelivered = Promise.withResolvers<void>(); + for (let i = 0; i < FLIGHT; i++) { + client.createUnidirectionalStream({ body: Buffer.from("s" + i) }).then(stream => stream.closed.catch(() => {})); + } + if (!(await Promise.race([flightDelivered.promise.then(() => true), failed.promise.then(() => false)]))) ok = false; + // Let the flight's callbacks return, run GC, then let lsquic write again. + await new Promise(resolve => setImmediate(resolve)); + Bun.gc(true); + await new Promise(resolve => setImmediate(resolve)); +} + +console.log(JSON.stringify({ opened, delivered, closedBeforeSent })); +// After a bogus close event the sessions can't close cleanly. +if (!ok) process.exit(1); +await client.close(); +await server.close(); diff --git a/test/js/node/quic/quic-stream.test.ts b/test/js/node/quic/quic-stream.test.ts index fe3a7b08b396..995db8dbf7fc 100644 --- a/test/js/node/quic/quic-stream.test.ts +++ b/test/js/node/quic/quic-stream.test.ts @@ -2,6 +2,7 @@ // `onwanttrailers`, records `trailers_pending` rather than `fin_pending`) // must deliver it with a FIN, never retract it with a RESET_STREAM. import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir, withoutAsanQuarantine } from "harness"; import { createPrivateKey } from "node:crypto"; import { readFileSync } from "node:fs"; import { join } from "node:path"; @@ -374,3 +375,248 @@ describe("headers queued before the handshake", () => { client.close(); }); }); + +// lsquic can finish the handshake, read the peer's first stream and close the connection in one +// engine pass, before JS sees any of it. A request created before `opened` is sent with the +// client's Finished, and lsquic rejects its bad content-length with H3_MESSAGE_ERROR (0x10e). +test("a malformed request in the handshake's last flight closes only that connection", async () => { + const events: unknown[] = []; + const sessions: Promise<unknown>[] = []; + await using server = await listen( + (session: any) => { + const record = sessions.length === 0 ? (event: unknown) => events.push(event) : () => {}; + const closed = Promise.withResolvers<void>(); + sessions.push(closed.promise); + session.onerror = () => record("error"); + session.onhandshake = (info: any) => record(["handshake", info.protocol, info.servername]); + session.onstream = (stream: any) => { + record("stream"); + stream.closed.catch(() => {}); + }; + session.closed.then(closed.resolve, closed.resolve); + }, + { + alpn: ["h3"], + sni: { "*": { keys: [key], certs: [cert] } }, + transportParams: { maxIdleTimeout: 5 }, + onheaders(this: any) { + this.sendHeaders({ ":status": "200" }, { terminal: true }); + }, + }, + ); + const options = { + servername: "localhost", + verifyPeer: "manual", + transportParams: { maxIdleTimeout: 5 }, + onerror() {}, + } as SessionOptions; + const headers = { ":method": "GET", ":path": "/", ":scheme": "https", ":authority": "localhost" }; + + const malformed = await connect(server.address!, options); + malformed.opened.catch(() => {}); + const close = malformed.closed.then( + () => undefined, + (err: any) => ({ code: err.code, errorCode: err.errorCode }), + ); + (await malformed.createBidirectionalStream({ headers: { ...headers, "content-length": "abc" } })).closed.catch( + () => {}, + ); + expect(await close).toEqual({ code: "ERR_QUIC_APPLICATION_ERROR", errorCode: 0x10en }); + + const client = await connect(server.address!, options); + const answered = Promise.withResolvers<string>(); + client.closed.then(() => answered.reject(new Error("closed without an answer")), answered.reject); + const stream = await client.createBidirectionalStream({ + headers, + onheaders: (headers: Record<string, any>) => answered.resolve(headers[":status"]), + }); + stream.closed.catch(() => {}); + expect(await answered.promise).toBe("200"); + client.close(); + await Promise.all(sessions); + expect(events).toEqual([["handshake", "h3", "localhost"], "stream", "error"]); +}); + +test("session.close() before the handshake ends doesn't wait for a pending HTTP/3 stream", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + import { connect } from "node:quic"; + // This socket never answers, and the session never times out. + const silent = await Bun.udpSocket({ hostname: "127.0.0.1" }); + const session = await connect( + { address: "127.0.0.1", port: silent.port }, + { servername: "localhost", verifyPeer: "manual", handshakeTimeout: 600_000, transportParams: { maxIdleTimeout: 0 } }, + ); + session.opened.catch(() => {}); + const stream = await session.createBidirectionalStream({ + headers: { ":method": "GET", ":path": "/", ":scheme": "https", ":authority": "localhost" }, + }); + console.log("pending", stream.pending); + await session.close(); + await stream.closed; + console.log("closed"); + process.exit(0); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("pending true\nclosed\n"); + expect(exitCode).toBe(0); +}, 30_000); + +// `destroy()` sends STOP_SENDING and RESET_STREAM. Once the response is sent and +// acked, STOP_SENDING finishes the stream and lsquic frees it in the next service +// pass, so a queued RESET_STREAM packet would point to freed memory. +describe("QuicStream.destroy on a stream lsquic has already finished", () => { + // The peer's FIN has to arrive after a gap, or lsquic closes the read side + // before the app can call destroy(). A UDP relay in front of the server + // drops the packet carrying the body. + const script = String.raw` +import { createPrivateKey } from "node:crypto"; +import { createSocket } from "node:dgram"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { connect, listen } from "node:quic"; + +const keysDir = process.env.QUIC_KEYS_DIR; +const key = createPrivateKey(readFileSync(join(keysDir, "agent1-key.pem"))); +const cert = readFileSync(join(keysDir, "agent1-cert.pem")); +const quiet = x => (x.closed?.catch?.(() => {}), x); +const dec = new TextDecoder(); +const read = async s => { + let out = ""; + try { + for await (const chunk of s) for (const b of [chunk].flat()) out += dec.decode(b); + } catch {} + return out; +}; + +let stuck = null; +let nth = 0; +const destroyed = Promise.withResolvers(); + +const server = await listen( + session => { + quiet(session).onerror = () => {}; + session.onstream = stream => { + quiet(stream); + stream.sendHeaders({ ":status": "200" }); + stream.setBody(Buffer.from("answer")); + const i = nth++; + if (i === 0) { + stuck = stream; // never read, so the peer's FIN is stuck behind the gap + return; + } + if (i > 1) return; + (async () => { + for await (const _ of stream) { + stuck.destroy(); + destroyed.resolve(); + break; + } + })(); + }; + }, + { + alpn: ["h3"], + sni: { "*": { keys: [key], certs: [cert] } }, + transportParams: { maxIdleTimeout: 5 }, + }, +); + +const serverPort = server.address.port; +const front = createSocket("udp4"); +await new Promise(r => front.bind(0, "127.0.0.1", r)); +const dropped = Promise.withResolvers(); +let up = null; +let gap = false; +front.on("message", (m, from) => { + up ??= createSocket("udp4").on("message", back => front.send(back, from.port, "127.0.0.1")); + if (gap && m.length > 400) { + dropped.resolve(); + return; + } + up.send(m, serverPort, "127.0.0.1"); +}); + +const client = await connect( + { address: "127.0.0.1", port: front.address().port }, + { + alpn: "h3", + servername: "localhost", + verifyPeer: "manual", + transportParams: { maxIdleTimeout: 5 }, + onerror() {}, + }, +); +await client.opened; + +const headers = { ":method": "POST", ":path": "/", ":scheme": "https", ":authority": "localhost" }; + +// A gets a full response and is then abandoned. +const a = quiet(await client.createBidirectionalStream({})); +a.sendHeaders(headers); +console.log("A:" + (await read(a))); + +// B tells the server when to destroy A. Its headers reach the server before the relay starts dropping packets. +const b = quiet(await client.createBidirectionalStream({})); +b.sendHeaders(headers); +console.log("B:" + (await read(b))); + +// The relay drops the body packet, but the byte and the FIN after it arrive. +gap = true; +a.writer.writeSync(new Uint8Array(1000).fill(0x41)); +await dropped.promise; +a.writer.writeSync(new Uint8Array([0x42])); +a.writer.endSync(); +b.writer.writeSync(new Uint8Array([0x43])); +await destroyed.promise; + +// One more round trip, so the server ticks after the destroy. +const c = quiet(await client.createBidirectionalStream({})); +c.sendHeaders(headers); +console.log("C:" + (await read(c))); +process.exit(0); +`; + + test("answers on the same connection instead of resetting the freed stream", async () => { + using dir = tempDir("quic-destroy-finished", { "repro.mjs": script }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "repro.mjs"], + env: { ...bunEnv, QUIC_KEYS_DIR: keysDir }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.split("\n").filter(Boolean), stderr: stderr.includes("Assertion") ? stderr : "" }).toEqual({ + stdout: ["A:answer", "B:answer", "C:answer"], + stderr: "", + }); + expect(exitCode).toBe(0); + }, 30_000); +}); + +test("a stream that reuses a destroyed stream's allocation doesn't get its queued close event", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + join(import.meta.dir, "quic-stream-recycled-close-fixture.ts"), + join(keysDir, "agent1-key.pem"), + join(keysDir, "agent1-cert.pem"), + ], + // ASAN only reuses a freed block right away when its quarantine is off. + env: withoutAsanQuarantine(bunEnv), + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe(JSON.stringify({ opened: 16, delivered: 16, closedBeforeSent: [] }) + "\n"); + expect(exitCode).toBe(0); +}, 30_000); diff --git a/test/js/node/quic/quic-worker-terminate-fixture.ts b/test/js/node/quic/quic-worker-terminate-fixture.ts new file mode 100644 index 000000000000..ccc39056d070 --- /dev/null +++ b/test/js/node/quic/quic-worker-terminate-fixture.ts @@ -0,0 +1,60 @@ +// Tearing down a terminated Worker's VM finalizes its endpoints, sessions and streams in arbitrary order. +import { Worker } from "node:worker_threads"; +import { readFileSync } from "node:fs"; + +const [keyPath, certPath] = process.argv.slice(2); +const workerData = { + key: readFileSync(keyPath, "utf8"), + cert: readFileSync(certPath, "utf8"), +}; + +const src = ` + const { parentPort, workerData } = require("node:worker_threads"); + const { listen, connect } = require("node:quic"); + const { createPrivateKey } = require("node:crypto"); + + const key = createPrivateKey(workerData.key); + const cert = Buffer.from(workerData.cert); + + const server = await listen( + session => { + session.onstream = stream => stream.closed.catch(() => {}); + session.closed.catch(() => {}); + }, + { sni: { "*": { keys: [key], certs: [cert] } }, alpn: "wq", transportParams: { maxIdleTimeout: 30 } }, + ); + + // JSC allocates the first 8 cells of each type as PreciseAllocations and the rest in a MarkedBlock, + // and it sweeps MarkedBlocks first. With 12 sessions, some are finalized before the two endpoints. + const held = []; + for (let i = 0; i < 12; i++) { + const c = await connect(server.address, { + alpn: "wq", + servername: "localhost", + verifyPeer: "manual", + transportParams: { maxIdleTimeout: 30 }, + }); + c.closed.catch(() => {}); + await c.opened; + const st = await c.createBidirectionalStream({ + body: new Uint8Array(Buffer.alloc(1 << 16, 120)), + }); + st.closed.catch(() => {}); + held.push(c, st); + } + + parentPort.postMessage("up"); + await new Promise(() => {}); +`; + +for (let round = 0; round < 2; round++) { + const w = new Worker(src, { eval: true, workerData }); + await new Promise<void>((resolve, reject) => { + w.once("message", () => resolve()); + w.once("error", reject); + w.once("exit", code => reject(new Error(`worker exited with code ${code} before posting "up"`))); + }); + await w.terminate(); +} + +console.log("PASS"); diff --git a/test/js/node/sqlite/node-sqlite.test.ts b/test/js/node/sqlite/node-sqlite.test.ts index 340acf3638ee..c66fcfab2de1 100644 --- a/test/js/node/sqlite/node-sqlite.test.ts +++ b/test/js/node/sqlite/node-sqlite.test.ts @@ -589,6 +589,7 @@ describe("DatabaseSync", () => { expect(() => backup(db, ":memory:", undefined)).toThrow(invalidOptions); // @ts-expect-error expect(() => backup(db, ":memory:", null)).toThrow(invalidOptions); + expect(() => Reflect.construct(backup, [db, ":memory:"])).toThrow(TypeError); // Controls: these are value-gated in Node (explicit undefined is the // same as omission) and must NOT throw. @@ -1468,13 +1469,12 @@ describe.skipIf(!sqliteHasSession)("Session / changeset", () => { // On regression ASAN aborts the process before any line is printed. // db.close(): deferred on the first authorizer call (catches nothing), // throws ERR_INVALID_STATE on the second (db already marked closed). - // sess.close(): record->inUse → ERR_INVALID_STATE. - // Symbol.dispose: inUse → silent no-op (tolerant). + // sess.close() and Symbol.dispose: record->inUse → ERR_INVALID_STATE. expect({ lines, stderr, exitCode }).toEqual({ lines: [ { what: "db", len: 20, caught: "ERR_INVALID_STATE", dbOpen: false }, { what: "sess", len: 20, caught: "ERR_INVALID_STATE", dbOpen: true }, - { what: "dispose", len: 20, caught: null, dbOpen: true }, + { what: "dispose", len: 20, caught: "ERR_INVALID_STATE", dbOpen: true }, ], stderr: expect.any(String), exitCode: 0, @@ -1482,6 +1482,41 @@ describe.skipIf(!sqliteHasSession)("Session / changeset", () => { }, ); + test.concurrent.skipIf(!sqliteHasSession)("closing a session inside a SQLite callback throws", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { DatabaseSync, constants } = require("node:sqlite"); + for (const close of [s => s.close(), s => s[Symbol.dispose]()]) { + const db = new DatabaseSync(":memory:"); + db.exec("CREATE TABLE t(a PRIMARY KEY)"); + const session = db.createSession(); + let caught; + // The session's preupdate hook runs this pragma while it reads the session. + db.setAuthorizer((action, name) => { + if (action === constants.SQLITE_PRAGMA && /^table_x?info$/.test(name)) { + try { close(session); } catch (e) { caught = e.code + ": " + e.message; } + } + return constants.SQLITE_OK; + }); + db.exec("INSERT INTO t VALUES (1)"); + db.setAuthorizer(null); + console.log(caught, session.changeset().length > 0); + close(session); + db.close(); + } + `, + ], + env: systemMallocEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const line = "ERR_INVALID_STATE: session cannot be closed while in a callback true\n"; + expect({ stdout, stderr, exitCode }).toEqual({ stdout: line + line, stderr: expect.any(String), exitCode: 0 }); + }); + test("default onConflict aborts and returns false", () => { const src = new DatabaseSync(":memory:"); const dst = new DatabaseSync(":memory:"); @@ -1733,6 +1768,50 @@ describe("DatabaseSync.prototype.setAuthorizer()", () => { expect(() => db.prepare("SELECT 1")).toThrow(RangeError); db.close(); }); + + // An FTS5 index writes to its shadow tables when the transaction commits, which calls the authorizer. + test("close() works while an iterator's return() is committing a write", () => { + const db = new DatabaseSync(":memory:"); + db.exec("CREATE VIRTUAL TABLE ft USING fts5(x)"); + db.setAuthorizer((_action, table) => { + if (table === "ft_data") db.close(); + return constants.SQLITE_OK; + }); + for (const row of db.prepare("INSERT INTO ft VALUES ('a') RETURNING 1 AS one").iterate()) { + expect(row.one).toBe(1); + break; + } + expect(db.isOpen).toBe(false); + }); + + test("a statement rejects calls while run() commits its iterator's unfinished write", () => { + const db = new DatabaseSync(":memory:"); + db.exec("CREATE VIRTUAL TABLE ft USING fts5(x)"); + const stmt = db.prepare("INSERT INTO ft VALUES ('a') RETURNING 1"); + const iterator = stmt.iterate(); + iterator.next(); + const errors: unknown[] = []; + db.setAuthorizer((_action, table) => { + if (table === "ft_data" && errors.length === 0) { + for (const call of [() => iterator.next(), () => stmt.get()]) { + try { + call(); + } catch (error) { + errors.push(error); + } + } + } + return constants.SQLITE_OK; + }); + expect(stmt.run().changes).toBe(1); + expect(errors).toEqual([ + expect.objectContaining({ code: "ERR_INVALID_STATE", message: "iterator was invalidated" }), + expect.objectContaining({ code: "ERR_INVALID_STATE", message: "statement is currently executing" }), + ]); + db.setAuthorizer(null); + expect(db.prepare("SELECT count(*) AS n FROM ft").get()!.n).toBe(2); + db.close(); + }); }); describe("db.limits", () => { @@ -2580,6 +2659,72 @@ describe("GC lifetime", () => { expect(delta).toBeLessThanOrEqual(20); }); + // The write commits when the statement finishes. An FTS5 index writes to its shadow tables at commit, which + // calls the authorizer. + describe("a write statement that iterate() stopped after its first row", () => { + const open = (file: string) => { + const db = new DatabaseSync(file); + db.exec("CREATE VIRTUAL TABLE ft USING fts5(x)"); + db.prepare("INSERT INTO ft VALUES ('a') RETURNING 1").iterate().next(); + return db; + }; + + test("is finished by the next call on the connection after it's collected", () => { + const db = open(":memory:"); + const asked: unknown[] = []; + db.setAuthorizer((_action, table) => (asked.push(table), constants.SQLITE_OK)); + Bun.gc(true); + expect(asked).toEqual([]); + db.exec("SELECT 1"); + expect(asked).toContain("ft_data"); + db.close(); + }); + + test("is rolled back when it's collected along with a database that has an authorizer", () => { + using dir = tempDir("node-sqlite-collected-write", {}); + const file = path.join(String(dir), "db.sqlite"); + let asked = 0; + (() => open(file).setAuthorizer(() => (asked++, constants.SQLITE_OK)))(); + Bun.gc(true); + expect(asked).toBe(0); + const db = new DatabaseSync(file); + expect(db.prepare("SELECT count(*) AS n FROM ft").get()!.n).toBe(0); + db.close(); + }); + + test("doesn't call the authorizer when the graph that opened its database is disposed", () => { + const graph = new Bun.ModuleGraph(); + const db = graph.run(() => open(":memory:")); + let asked = 0; + db.setAuthorizer(() => (asked++, constants.SQLITE_OK)); + graph.dispose(); + expect(asked).toBe(0); + expect(db.isOpen).toBe(false); + }); + + test.concurrent("doesn't call the authorizer at process exit", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { DatabaseSync } = require("node:sqlite"); + globalThis.db = new DatabaseSync(":memory:"); + db.exec("CREATE VIRTUAL TABLE ft USING fts5(x)"); + globalThis.iterator = db.prepare("INSERT INTO ft VALUES ('a') RETURNING 1").iterate(); + iterator.next(); + db.setAuthorizer(() => (require("node:fs").writeSync(1, "asked\\n"), 0)); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "", stderr: expect.any(String), exitCode: 0 }); + }); + }); + test.concurrent("a database swept before its statements leaves nothing that points into its cell", async () => { // edenGC() marks and does not sweep: the next DatabaseSync takes the cell // of a dead one while the dead statements wait for their blocks' turn. diff --git a/test/js/node/stream/node-stream.test.js b/test/js/node/stream/node-stream.test.js index 782ed9039c62..b02756fdacfa 100644 --- a/test/js/node/stream/node-stream.test.js +++ b/test/js/node/stream/node-stream.test.js @@ -1434,6 +1434,40 @@ describe("webstreams adapters (Node v26 sync)", () => { expect(exitCode).toBe(0); }); }); + + it("Duplex.toWeb doesn't throw when the duplex is destroyed with an error after the writer closed", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + import { Duplex } from "node:stream"; + const duplex = new Duplex({ + allowHalfOpen: false, + read() {}, + write(chunk, encoding, callback) { callback(); }, + final(callback) { setImmediate(callback); }, + }); + const { readable, writable } = Duplex.toWeb(duplex); + duplex.on("error", e => console.log("duplex error:" + e.message)); + duplex.on("close", () => console.log("close event, errored:" + duplex.errored?.message)); + duplex.push(null); + const { done } = await readable.getReader().read(); + await writable.getWriter().close(); + console.log("close resolved, done:" + done); + duplex.destroy(new Error("boom")); + `, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ out: stdout, err: stderr }).toEqual({ + out: "close resolved, done:true\nduplex error:boom\nclose event, errored:boom\n", + err: "", + }); + expect(exitCode).toBe(0); + }); }); for (const size of [0x10, 0xffff, 0x10000, 0x1f000, 0x20000, 0x20010, 0x7ffff, 0x80000, 0xa0000, 0xa0010]) { @@ -1907,3 +1941,23 @@ it("internal FixedQueue backing list is not holey (test-fixed-queue.js)", () => for (let i = 0; i < list.length; i++) if (!(i in list)) holes++; expect(holes).toBe(0); }); + +it("Readable.fromWeb's native source accepts pull(view) without the flags array", async () => { + // The Blob source doesn't define setFlowing(), so the Readable calls this one with the native source as `this`. + const { promise, resolve } = Promise.withResolvers(); + Object.defineProperty(Object.prototype, "setFlowing", { + configurable: true, + value() { + delete Object.prototype.setFlowing; + this.start(65536); + const view = new Uint8Array(64); + resolve(Buffer.from(view.subarray(0, this.pull(view))).toString()); + }, + }); + try { + Readable.fromWeb(new Blob(["hello world"]).stream()).resume(); + expect(await promise).toBe("hello world"); + } finally { + delete Object.prototype.setFlowing; + } +}); diff --git a/test/js/node/test/parallel/test-stream-readable-to-web-termination.js b/test/js/node/test/parallel/test-stream-readable-to-web-termination.js index f30cf721e14c..8a15fa3f5fdb 100644 --- a/test/js/node/test/parallel/test-stream-readable-to-web-termination.js +++ b/test/js/node/test/parallel/test-stream-readable-to-web-termination.js @@ -42,3 +42,25 @@ const { setTimeout: delay } = require('timers/promises'); assert.deepStrictEqual(closeResult, { value: undefined, done: true }); })().then(common.mustCall()); } + +// Cancelling a web ReadableStream while the underlying Readable is actively +// producing data should not throw ERR_INVALID_STATE. The 'data' handler in +// newReadableStreamFromStreamReadable must check wasCanceled before calling +// controller.enqueue(). See: https://github.com/nodejs/node/issues/54205 +{ + const readable = new Readable({ + read() { + this.push(Buffer.alloc(1024)); + }, + }); + + const webStream = Readable.toWeb(readable); + const reader = webStream.getReader(); + + (async () => { + await reader.read(); + await reader.read(); + reader.releaseLock(); + await webStream.cancel(); + })().then(common.mustCall()); +} diff --git a/test/js/node/test/parallel/test-whatwg-webstreams-adapters-to-readablewritablepair.js b/test/js/node/test/parallel/test-whatwg-webstreams-adapters-to-readablewritablepair.js index 5062b9e179ac..cbe505ab956a 100644 --- a/test/js/node/test/parallel/test-whatwg-webstreams-adapters-to-readablewritablepair.js +++ b/test/js/node/test/parallel/test-whatwg-webstreams-adapters-to-readablewritablepair.js @@ -10,6 +10,7 @@ const { } = require('internal/webstreams/adapters'); const { + Duplex, PassThrough, } = require('stream'); @@ -254,3 +255,27 @@ const { code: 'ERR_INVALID_ARG_TYPE' }); } + +{ + const duplex = new Duplex({ + allowHalfOpen: false, + read() {}, + write(chunk, enc, cb) { cb(); }, + final(cb) { setImmediate(cb); }, + }); + + const { + readable, + writable, + } = newReadableWritablePairFromDuplex(duplex); + + const reader = readable.getReader(); + const writer = writable.getWriter(); + + duplex.push(null); + + reader.read().then(common.mustCall(({ done }) => { + assert.strictEqual(done, true); + writer.close().then(common.mustCall()); + })); +} diff --git a/test/js/node/test/parallel/test-zlib-brotli-flush-invalid-kind.js b/test/js/node/test/parallel/test-zlib-brotli-flush-invalid-kind.js new file mode 100644 index 000000000000..af8a2c67d1d9 --- /dev/null +++ b/test/js/node/test/parallel/test-zlib-brotli-flush-invalid-kind.js @@ -0,0 +1,92 @@ +'use strict'; +// Regression test for https://github.com/nodejs/node/issues/63701. +// Invalid Brotli flush kinds used to spin in native code. flush(kind) should +// reject invalid kinds before writing the fake flush chunk. + +require('../common'); +const assert = require('assert'); +const zlib = require('zlib'); + +const { + BROTLI_OPERATION_PROCESS, + BROTLI_OPERATION_FLUSH, + BROTLI_OPERATION_FINISH, + BROTLI_OPERATION_EMIT_METADATA, + Z_NO_FLUSH, + Z_BLOCK, + Z_FINISH, + ZSTD_e_continue, + ZSTD_e_flush, + ZSTD_e_end, +} = zlib.constants; + +const noop = () => {}; + +const flushKindTestCases = [ + { + factories: [zlib.createGzip], + validKinds: [Z_NO_FLUSH, Z_FINISH, Z_BLOCK], + invalidKinds: [-1, 6, 100], + }, + { + factories: [zlib.createBrotliCompress, zlib.createBrotliDecompress], + validKinds: [ + BROTLI_OPERATION_PROCESS, + BROTLI_OPERATION_FLUSH, + BROTLI_OPERATION_FINISH, + BROTLI_OPERATION_EMIT_METADATA, + ], + invalidKinds: [-1, Z_FINISH, Z_BLOCK, 6, 100], + }, + { + factories: [zlib.createZstdCompress, zlib.createZstdDecompress], + validKinds: [ZSTD_e_continue, ZSTD_e_flush, ZSTD_e_end], + invalidKinds: [-1, 3, Z_FINISH, Z_BLOCK, 100], + }, +]; + +for (const { factories, validKinds } of flushKindTestCases) { + for (const factory of factories) { + for (const kind of validKinds) { + const stream = factory(); + stream.on('error', noop); + stream.flush(kind); + } + } +} + +for (const { factories, invalidKinds } of flushKindTestCases) { + for (const factory of factories) { + for (const kind of invalidKinds) { + assert.throws( + () => factory().flush(kind), + { code: 'ERR_OUT_OF_RANGE', name: 'RangeError' }, + ); + } + } +} + +for (const { factories } of flushKindTestCases) { + for (const factory of factories) { + for (const kind of ['foobar', null, {}]) { + assert.throws( + () => factory().flush(kind), + { code: 'ERR_INVALID_ARG_TYPE', name: 'TypeError' }, + ); + } + } +} + +for (const { factories } of flushKindTestCases) { + for (const factory of factories) { + for (const kind of [undefined, NaN]) { + const stream = factory(); + stream.on('error', noop); + stream.flush(kind); + } + + const stream = factory(); + stream.on('error', noop); + stream.flush(noop); + } +} diff --git a/test/js/node/url/url-domain-ascii-unicode.test.js b/test/js/node/url/url-domain-ascii-unicode.test.js index 912c93637f79..8f9ab3cc094d 100644 --- a/test/js/node/url/url-domain-ascii-unicode.test.js +++ b/test/js/node/url/url-domain-ascii-unicode.test.js @@ -1,3 +1,4 @@ +import { setSyntheticAllocationLimitForTesting } from "bun:internal-for-testing"; import { describe, expect, test } from "bun:test"; import url from "node:url"; @@ -100,3 +101,16 @@ describe("url.domainToUnicode", () => { }); } }); + +test("rejects a host too long for the IDNA conversion buffer", () => { + // With a 1 MiB limit the buffer is capped at 524288 code units. + const rest = Buffer.alloc(524288, "a").toString(); + const originalLimit = setSyntheticAllocationLimitForTesting(1024 * 1024); + try { + expect(() => url.domainToUnicode("xn--nxa." + rest).length).toThrow("Out of memory"); + expect(url.domainToASCII("xn--mgbh0fb." + rest).length).toBe(0); + expect(URL.canParse("http://xn--mgbh0fb." + rest)).toBe(false); + } finally { + setSyntheticAllocationLimitForTesting(originalLimit); + } +}); diff --git a/test/js/node/util/custom-inspect.test.js b/test/js/node/util/custom-inspect.test.js index f39d2e0a6d39..9d7c6c198a3e 100644 --- a/test/js/node/util/custom-inspect.test.js +++ b/test/js/node/util/custom-inspect.test.js @@ -306,6 +306,14 @@ describe("Web Streams [nodejs.util.inspect.custom]", () => { expect(rs[customSymbol](-1, {})).toBe(rs); }); + // util.inspect passes unknown user options through to the hooks unchanged. + test("accepts a user option whose key is an array index", () => { + expect(util.inspect(new ReadableStream(), { 0: 1 })).toBe( + "ReadableStream { locked: false, state: 'readable', supportsBYOB: false }", + ); + expect(util.inspect(new URLSearchParams("a=1&b=2"), { 0: 1 })).toBe("URLSearchParams { 'a' => '1', 'b' => '2' }"); + }); + test("wrong receiver returns the receiver (no infinite recursion)", () => { const o = {}; expect(ReadableStream.prototype[customSymbol].call(o, 2, {})).toBe(o); diff --git a/test/js/node/util/mime-api.test.ts b/test/js/node/util/mime-api.test.ts index a977f789823a..d17b118f089a 100644 --- a/test/js/node/util/mime-api.test.ts +++ b/test/js/node/util/mime-api.test.ts @@ -1,3 +1,4 @@ +import { setSyntheticAllocationLimitForTesting } from "bun:internal-for-testing"; import { describe, expect, test } from "bun:test"; import { bunExe, expectRssDeltaBelow } from "harness"; import { MIMEParams, MIMEType } from "util"; @@ -457,3 +458,20 @@ test("MIMEType releases its type and subtype strings when garbage-collected", as // Unfixed: ~42 MiB (160 x 256 KiB). Fixed: 1 to 4 MiB of allocator slack. await expectRssDeltaBelow(["--smol", "-e", code], { release: 20, debug: 25 }); }); + +test("MIMEType and MIMEParams throw when the serialized string is too long", () => { + const half = Buffer.alloc(512 * 1024, "a").toString(); + const mime = new MIMEType("text/plain"); + // Serializing escapes each quote with a backslash, which doubles the length. + mime.params.set("a", Buffer.alloc(512 * 1024, '"').toString()); + const long = new MIMEType(half + "/" + half); + const originalLimit = setSyntheticAllocationLimitForTesting(1024 * 1024); + try { + expect(() => String(mime.params).length).toThrow("Out of memory"); + expect(() => String(mime).length).toThrow("Out of memory"); + expect(() => String(long).length).toThrow("Out of memory"); + expect(() => long.essence.length).toThrow("Out of memory"); + } finally { + setSyntheticAllocationLimitForTesting(originalLimit); + } +}); diff --git a/test/js/node/util/parse_args/default-args.test.mjs b/test/js/node/util/parse_args/default-args.test.mjs index 84bd49db2181..d84e860de4d8 100644 --- a/test/js/node/util/parse_args/default-args.test.mjs +++ b/test/js/node/util/parse_args/default-args.test.mjs @@ -77,4 +77,14 @@ describe("parseArgs default args", () => { expect(output?.execArgv).toEqual(execArgv); } }); + + test.each([ + ["--eval=<code>", code => [`--eval=${code}`]], + ["-pe <code>", code => ["-pe", code]], + ["a script that shortens process.argv", code => ["-e", `process.argv.length = 0; ${code}`]], + ])("returns no positionals when process.argv has no program arguments: %s", async (_, args) => { + const code = `console.log(JSON.stringify(require("node:util").parseArgs({ strict: false }).positionals))`; + const { stdout } = await spawnBun(...args(code)); + expect(stdout.split("\n")[0]).toBe("[]"); + }); }); diff --git a/test/js/node/util/parse_args/parse-args.test.mjs b/test/js/node/util/parse_args/parse-args.test.mjs index 9f5b8db41464..fdf07158f4cc 100644 --- a/test/js/node/util/parse_args/parse-args.test.mjs +++ b/test/js/node/util/parse_args/parse-args.test.mjs @@ -991,6 +991,13 @@ describe("parseArgs extra tests", () => { expect(result.positionals).toHaveLength(2); }); + test("a bare --no- with allowNegative stores an empty key", () => { + expect(parseArgs({ args: ["--no-"], allowNegative: true, strict: false })).toEqual({ + values: { __proto__: null, "": false }, + positionals: [], + }); + }); + describe("unicode support", () => { // node allows characters of 1/2 bytes as short option names, but not 3/4 const U1 = "$"; // 1 byte diff --git a/test/js/node/util/test-util-types.test.js b/test/js/node/util/test-util-types.test.js index 22e0c88b4eda..647897cadad3 100644 --- a/test/js/node/util/test-util-types.test.js +++ b/test/js/node/util/test-util-types.test.js @@ -363,3 +363,12 @@ export default /BADD~!!!!; expect(types.isNativeError(buildError)).toBeTrue(); expect(buildError.constructor.name).toBe("BuildMessage"); }); + +test("util/types functions aren't constructors", () => { + for (const [name, fn] of Object.entries(types)) { + if (typeof fn !== "function") continue; + expect(() => Reflect.construct(fn, [new Date()])).toThrow(TypeError); + expect(Bun.inspect(fn)).toBe(`[Function: ${name}]`); + } + expect(Array.of.call(types.isDate, 1, 2, 3)).toEqual([1, 2, 3]); +}); diff --git a/test/js/node/v8/capture-stack-trace.test.js b/test/js/node/v8/capture-stack-trace.test.js index 22662d45e6ee..9d8f0e57a920 100644 --- a/test/js/node/v8/capture-stack-trace.test.js +++ b/test/js/node/v8/capture-stack-trace.test.js @@ -1412,6 +1412,20 @@ test.concurrent.each([ expect(exitCode).toBe(0); }); +test("Error.appendStackTrace keeps the stack trace when the source and destination are the same error", () => { + function f(n) { + if (n > 0) return f(n - 1) + 1; + try { + null(); + } catch (e) { + Error.appendStackTrace(e, e); + expect(e.stack).toContain("at f "); + } + return 0; + } + f(64); +}); + test("Error.stackTraceLimit default matches the limit captureStackTrace applies", async () => { // Run in a fresh process so nothing has written to Error.stackTraceLimit yet. const src = ` diff --git a/test/js/node/vm/vm.test.ts b/test/js/node/vm/vm.test.ts index 6510b3c32ba2..a3e514d27f41 100644 --- a/test/js/node/vm/vm.test.ts +++ b/test/js/node/vm/vm.test.ts @@ -971,6 +971,33 @@ describe.concurrent("File and fs.Stats accept a newTarget that belongs to a cont }); }); +// The FTL calls a native getter with the inlined function's global object. For a function +// defined in a vm context, that's the context's global object. +test.concurrent.each(["cache", "extensions"])( + "require.%s returns the same object when read by a function from a vm context", + async property => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + /*js*/ ` + const vm = require("node:vm"); + const read = vm.runInNewContext("(function (r) { return r.${property}; })"); + let value; + for (let i = 0; i < 300_000; i++) value = read(require); + console.log(value === require.${property}); + `, + ], + // Compile on the main thread so the tier-up point doesn't depend on scheduling. + env: { ...bunEnv, BUN_JSC_useConcurrentJIT: "0" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ stdout: "true", stderr: "", exitCode: 0 }); + }, +); + test("can't use export syntax in vm.Script", () => { // vm.Script now parses eagerly (like Node), so the SyntaxError surfaces at // construction rather than at runInThisContext()/createCachedData(). @@ -1005,6 +1032,25 @@ test("accepts valid bytecode", () => { expect(secondScript.runInThisContext()).toBe(2); }); +test.concurrent("a Script created from accepted cachedData runs with the JIT disabled", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const vm = require("node:vm"); + const cachedData = new vm.Script("40 + 2").createCachedData(); + const script = new vm.Script("40 + 2", { cachedData }); + console.log(script.cachedDataRejected, script.runInThisContext());`, + ], + env: { ...bunEnv, BUN_JSC_useJIT: "0" }, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("false 42\n"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); +}); + test("can't use bytecode from a different script", () => { const firstScript = new Script("1 + 1;"); const cachedData = firstScript.createCachedData(); @@ -1026,6 +1072,30 @@ test("SourceTextModule accepts the cachedData it produced", () => { ); }); +test("SourceTextModule.link passes each request's import attributes when a specifier repeats", async () => { + const dep = new SourceTextModule(`export default 1; export let x = 1;`, { identifier: "dep" }); + const seen: [string, Record<string, string>][] = []; + const m = new SourceTextModule( + `import { x } from 'a'; import * as ns from 'a'; export { x as y } from 'a'; + import j from 'j' with { type: 'json' }; import c from 'c' with { type: 'css' }; + import 'i' with { "0": 'zero' }; + export const r = [x, ns.x].join();`, + { identifier: "root" }, + ); + await m.link((specifier: string, _referrer: any, extra: any) => { + seen.push([specifier, { ...extra.attributes }]); + return dep; + }); + await m.evaluate(); + expect(seen).toEqual([ + ["a", {}], + ["j", { type: "json" }], + ["c", { type: "css", hostDefinedImportType: "css" }], + ["i", { "0": "zero" }], + ]); + expect((m.namespace as any).r).toBe("1,1"); +}); + // Several SourceTextModules with one identifier and one source text are several records of the same module. Each reads // the bindings of the module it was linked to, whatever that module's text is, including from functions that were // already hot when the next record was made. @@ -1133,7 +1203,7 @@ describe("a compile from cachedData keeps the payload alive", () => { out.push("inner rejected=" + fn.cachedDataRejected + " call=" + fn()()); } - // vm.Script holds its cachedData in a member the garbage collector owns. + // Running a vm.Script uses the code block compiled from source. { const source = "(function inner() { return 7 })()"; const cachedData = new vm.Script(source).createCachedData(); @@ -1327,6 +1397,158 @@ describe("Script compiles its source once and links that in every context it run }); }); +test.concurrent("corrupted cachedData is rejected", async () => { + const code = /* js */ ` + const vm = require("node:vm"); + const assert = require("node:assert"); + + function damaged(good) { + const variants = []; + for (const cut of [good.length - 1, good.length >> 1, 16, 1]) { + variants.push(["truncate:" + cut, good.subarray(0, cut)]); + } + for (let i = 0; i < 20; i++) { + const copy = Buffer.from(good); + const off = Math.floor((i * (good.length - 1)) / 19); + copy[off] ^= 0xff; + variants.push(["flip@" + off, copy]); + } + variants.push(["extend", Buffer.concat([good, Buffer.from([0xde, 0xad])])]); + variants.push(["empty", Buffer.alloc(0)]); + variants.push(["zeros", Buffer.alloc(good.length)]); + variants.push(["other bytes", Buffer.from("fhqwhgads")]); + return variants; + } + + { + const source = "function f(){return 1234} f()"; + const good = new vm.Script(source).createCachedData(); + for (const [label, cachedData] of damaged(good)) { + const script = new vm.Script(source, { cachedData }); + assert.strictEqual(script.cachedDataRejected, true, label); + assert.strictEqual(script.runInThisContext(), 1234, label); + } + const intact = new vm.Script(source, { cachedData: good }); + assert.strictEqual(intact.cachedDataRejected, false); + assert.strictEqual(intact.runInThisContext(), 1234); + const other = new vm.Script("1 + 1", { cachedData: good }); + assert.strictEqual(other.cachedDataRejected, true); + assert.strictEqual(other.runInThisContext(), 2); + console.log("script ok"); + } + + { + const params = ["a"]; + const source = "return a + 1234;"; + const good = vm.compileFunction(source, params, { produceCachedData: true }).cachedData; + for (const [label, cachedData] of damaged(good)) { + const fn = vm.compileFunction(source, params, { cachedData }); + assert.strictEqual(fn.cachedDataRejected, true, label); + assert.strictEqual(fn(1), 1235, label); + } + const intact = vm.compileFunction(source, params, { cachedData: good }); + assert.strictEqual(intact.cachedDataRejected, false); + assert.strictEqual(intact(1), 1235); + console.log("compileFunction ok"); + } + + { + const source = "export const value = 1234;"; + const good = new vm.SourceTextModule(source).createCachedData(); + for (const [label, cachedData] of damaged(good)) { + assert.throws(() => new vm.SourceTextModule(source, { cachedData }), { + code: "ERR_VM_MODULE_CACHED_DATA_REJECTED", + }, label); + } + new vm.SourceTextModule(source, { cachedData: good }); + console.log("module ok"); + } + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", code], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "script ok\ncompileFunction ok\nmodule ok\n", + stderr: "", + exitCode: 0, + }); +}); + +test.concurrent("2 GiB of cachedData is rejected", async () => { + // The child reserves 2 GiB of address space and never writes to it. + const fixture = ` + const vm = require("node:vm"); + let buffer; + try { + buffer = new ArrayBuffer(2 ** 31); + } catch { + console.log("SKIP"); + process.exit(0); + } + const result = {}; + for (const cachedData of [new Uint8Array(buffer), buffer]) { + const script = new vm.Script("1 + 1", { cachedData }); + const fn = vm.compileFunction("return 2 + 2", [], { cachedData }); + result[cachedData.constructor.name] = { + Script: [script.cachedDataRejected, script.runInThisContext()], + compileFunction: [fn.cachedDataRejected, fn()], + }; + } + try { + result.SourceTextModule = new vm.SourceTextModule("export default 1", { cachedData: new Uint8Array(buffer) }).status; + } catch (e) { + result.SourceTextModule = e.code; + } + console.log(JSON.stringify(result)); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: { + ...bunEnv, + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "allocator_may_return_null=1"].filter(Boolean).join(":"), + }, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + if (stdout.trim() !== "SKIP") { + const rejected = { Script: [true, 2], compileFunction: [true, 4] }; + expect({ stdout: stdout && JSON.parse(stdout), stderr }).toEqual({ + stdout: { + Uint8Array: rejected, + ArrayBuffer: rejected, + SourceTextModule: "ERR_VM_MODULE_CACHED_DATA_REJECTED", + }, + stderr: "", + }); + } + expect(exitCode).toBe(0); +}); + +test.concurrent("doesn't produce cachedData for a source with a 2^27-character string", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { Script } = require("node:vm"); + const script = new Script("'" + "x".repeat(2 ** 27) + "'.length", { produceCachedData: true }); + console.log(script.cachedDataProduced, script.runInThisContext()); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("false 134217728\n"); + expect(exitCode).toBe(0); +}); + describe("the file: URL origin made from a filename", () => { // The last filename made into a URL is kept per VM. The import() tests alternate two filenames, so every // compile finds the URL of the other filename in the cache. @@ -1577,6 +1799,18 @@ describe("the options argument", () => { expect(runInNewContext("1 + 1;", {}, options as any)).toBe(2); } }); + + test("treats an undefined codeGeneration member as absent, like Node", () => { + const undefinedMembers = { strings: undefined, wasm: undefined }; + expect({ + runInNewContext: runInNewContext("eval('1')", {}, { contextCodeGeneration: undefinedMembers }), + scriptRunInNewContext: new Script("eval('1')").runInNewContext({}, { contextCodeGeneration: undefinedMembers }), + createContext: runInContext("eval('1')", createContext({}, { codeGeneration: undefinedMembers })), + }).toEqual({ runInNewContext: 1, scriptRunInNewContext: 1, createContext: 1 }); + expect(() => createContext({}, { codeGeneration: { strings: null } } as any)).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + }); }); describe("a run option rejected with a vm context's global", () => { @@ -1676,8 +1910,8 @@ describe("context options with throwing getters", () => { // the process, so run the matrix in a subprocess. test.concurrent("the getter's exception propagates to the caller", async () => { // Each entry point tests the context-option keys it actually reads: - // createContext takes codeGeneration, Script#runInNewContext takes - // contextCodeGeneration, and vm.runInNewContext goes through both. + // createContext reads codeGeneration. Script#runInNewContext and + // vm.runInNewContext read contextCodeGeneration. // A dotted key puts the throwing getter on the nested object. const codeGenerationKeys = (key: string) => [key, `${key}.strings`, `${key}.wasm`]; const contextKeys = (...codeGenerationKeyNames: string[]) => [ @@ -1689,7 +1923,7 @@ describe("context options with throwing getters", () => { ]; const matrix = { createContext: contextKeys("codeGeneration"), - runInNewContext: contextKeys("codeGeneration", "contextCodeGeneration"), + runInNewContext: contextKeys("contextCodeGeneration"), scriptRunInNewContext: contextKeys("contextCodeGeneration"), }; const code = ` @@ -1736,6 +1970,27 @@ describe("context options with throwing getters", () => { }); }); +test.concurrent("runInContext accepts a context's own global proxy as the context", async () => { + const code = ` + const vm = require("node:vm"); + const ctx = vm.createContext({ fromSandbox: 1 }); + const inner = vm.runInContext("this", ctx); + console.log(vm.runInContext("typeof missing + ':' + fromSandbox", inner)); + console.log(new vm.Script("fromSandbox + 1").runInContext(inner)); + console.log(vm.runInContext("fromSandbox", vm.createContext(inner))); + console.log(vm.runInContext("fromSandbox", ctx)); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", code], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe("undefined:1\n2\n1\n1\n"); + expect(exitCode).toBe(0); +}); + describe("DONT_CONTEXTIFY", () => { test("globalThis prototype chain stays inside the sandbox realm", () => { const ctx = createContext(constants.DONT_CONTEXTIFY); @@ -1793,6 +2048,123 @@ describe("DONT_CONTEXTIFY", () => { ctx.fromOutside = 456; expect(runInContext("fromOutside", ctx)).toBe(456); }); + + test("top-level `this` is globalThis, and var/function declarations are defined on the real global", () => { + const ctx = createContext(constants.DONT_CONTEXTIFY); + + expect(runInContext("this === globalThis", ctx)).toBe(true); + expect(runInContext("this", ctx)).toBe(ctx); + + runInContext("var vv = 3; function ff() { return 99; }", ctx); + runInContext("(0, eval)('var ie = 7')", ctx); + expect({ + varPersists: runInContext("typeof vv", ctx), + fnPersists: runInContext("typeof ff", ctx), + varValue: runInContext("vv", ctx), + fnCall: runInContext("ff()", ctx), + handleVar: ctx.vv, + handleFnType: typeof ctx.ff, + sameScript: runInContext("var zz = 9; typeof zz", ctx), + indirectEvalVarPersists: runInContext("ie", ctx), + handleIndirectEvalVar: ctx.ie, + }).toEqual({ + varPersists: "number", + fnPersists: "function", + varValue: 3, + fnCall: 99, + handleVar: 3, + handleFnType: "function", + sameScript: "number", + indirectEvalVarPersists: 7, + handleIndirectEvalVar: 7, + }); + + // Script#runInContext uses the same path. + const script = new Script("var sv = 42; sv"); + expect(script.runInContext(ctx)).toBe(42); + expect(runInContext("sv", ctx)).toBe(42); + expect(ctx.sv).toBe(42); + }); + + test("vm.runInNewContext honors contextCodeGeneration with DONT_CONTEXTIFY", () => { + // The errors come from the context's own realm, so compare by name. + const thrownName = (code: string, contextCodeGeneration: object) => { + try { + runInNewContext(code, constants.DONT_CONTEXTIFY, { contextCodeGeneration }); + } catch (e) { + return (e as Error).name; + } + return "did not throw"; + }; + const wasmModule = "new WebAssembly.Module(new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]))"; + expect({ + strings: thrownName("eval('1')", { strings: false }), + wasm: thrownName(wasmModule, { wasm: false }), + wasmAllowedByDefault: thrownName(wasmModule, {}), + }).toEqual({ + strings: "EvalError", + wasm: "CompileError", + wasmAllowedByDefault: "did not throw", + }); + }); + + test("var/function declarations work via runInNewContext", () => { + expect( + runInNewContext( + "var a = 1; function b(){}; [this === globalThis, typeof a, typeof b]", + constants.DONT_CONTEXTIFY, + ), + ).toEqual([true, "number", "function"]); + expect( + new Script("var a = 1; function b(){}; [this === globalThis, typeof a, typeof b]").runInNewContext( + constants.DONT_CONTEXTIFY, + ), + ).toEqual([true, "number", "function"]); + + // Passing an existing DONT_CONTEXTIFY handle runs in that context, so the + // declarations are visible through the handle and to later scripts. + const ctx = createContext(constants.DONT_CONTEXTIFY); + runInNewContext("var q = 1; function qf() { return 2; }", ctx); + new Script("var r = 3").runInNewContext(ctx); + expect({ q: ctx.q, qf: typeof ctx.qf, r: ctx.r, later: runInContext("[q, qf(), r]", ctx) }).toEqual({ + q: 1, + qf: "function", + r: 3, + later: [1, 2, 3], + }); + }); + + test("compileFunction accepts a DONT_CONTEXTIFY context as parsingContext", () => { + const ctx = createContext(constants.DONT_CONTEXTIFY); + const fn = compileFunction("return [globalThis === g, Array]", ["g"], { parsingContext: ctx }); + const [isContextGlobal, ctxArray] = fn(ctx); + expect(isContextGlobal).toBe(true); + expect(ctxArray).toBe(ctx.Array); + expect(ctxArray).not.toBe(Array); + }); + + test("a private field stamped on the context handle is visible on `this` inside the context", () => { + // jsdom 30.1.0 brands its window this way, then uses runInContext("this", window) as the window. + class ReturnValue { + constructor(value: object) { + return value; + } + } + class Brand extends ReturnValue { + #stamped = true; + static has(value: object) { + return #stamped in value; + } + } + + const ctx = createContext(constants.DONT_CONTEXTIFY); + new Brand(ctx); + expect({ + handle: Brand.has(ctx), + this: Brand.has(runInContext("this", ctx)), + globalThis: Brand.has(runInContext("globalThis", ctx)), + }).toEqual({ handle: true, this: true, globalThis: true }); + }); }); describe("defineProperty errors use vm-realm global", () => { @@ -2363,6 +2735,14 @@ test.skipIf(isWindows)("a SIGINT interrupts only the innermost of nested breakOn expect(exitCode).toBe(0); }); +test("Script methods called without a receiver throw the same error as .call(undefined)", () => { + for (const name of ["runInThisContext", "runInContext", "createCachedData"] as const) { + // A bare call to a captured variable passes its scope object as `this`. + const callee: Function = Script.prototype[name]; + expect(() => callee()).toThrow("The argument 'this' must be a Script. Received undefined"); + } +}); + test("nested vm runs each keep their own deadline", async () => { const code = ` const vm = require("node:vm"); @@ -2666,13 +3046,19 @@ test.concurrent("a FinalizationRegistry cleanup job is dropped when its context const nextTurn = () => new Promise(resolve => setImmediate(resolve)); const liveContextCleanedUp = Promise.withResolvers(); let liveContext; - let deadContextCleanups = 0; + const unreferenced = []; function setup() { // A collection sweeps the first 8 cells of a type itself and leaves the rest for later. ~JSGlobalObject // cancels the job too, so these contexts are past the first 8 and their registries are not. const swept = Array.from({ length: 8 }, () => vm.createContext({})); - const contexts = Array.from({ length: 4 }, () => vm.createContext({ onCleanup: () => deadContextCleanups++ })); + const contexts = Array.from({ length: 4 }, () => { + const entry = { cleanups: 0 }; + const context = vm.createContext({ onCleanup: () => entry.cleanups++ }); + entry.context = new WeakRef(context); + unreferenced.push(entry); + return context; + }); liveContext = vm.createContext({ onCleanup: liveContextCleanedUp.resolve }); contexts.push(liveContext); for (const context of contexts) vm.runInContext("globalThis.registry = new FinalizationRegistry(onCleanup)", context); @@ -2686,12 +3072,17 @@ test.concurrent("a FinalizationRegistry cleanup job is dropped when its context fullGC(); // All contexts but one are dead, and their registries are destroyed. await liveContextCleanedUp.promise; await nextTurn(); // A job posted after the live context's has run by now too. - console.log({ deadContextCleanups }); + // A context that fullGC() didn't collect keeps its job, so only the ones that died count. + const dead = unreferenced.filter(entry => !entry.context.deref()); + console.log({ + someContextsDied: dead.length >= 2, + deadContextCleanups: dead.reduce((sum, entry) => sum + entry.cleanups, 0), + }); `; await using proc = Bun.spawn({ cmd: [bunExe(), "-e", fixture], env: bunEnv, stdout: "pipe", stderr: "pipe" }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stdout, stderr, exitCode }).toEqual({ - stdout: "{\n deadContextCleanups: 0,\n}\n", + stdout: "{\n someContextsDied: true,\n deadContextCleanups: 0,\n}\n", stderr: "", exitCode: 0, }); @@ -2730,3 +3121,55 @@ test.concurrent("Atomics.notify does not wake the Atomics.waitAsync of a context exitCode: 0, }); }); + +describe.concurrent("an exception thrown by an EventTarget listener from a vm context", () => { + const dispatch = ` + const target = new EventTarget(); + target.addEventListener("ping", require("node:vm").runInNewContext('(function () { throw new Error("thrown in vm context"); })')); + target.dispatchEvent(new Event("ping")); + `; + + test.each([ + [ + "reaches process.on('uncaughtException')", + `process.on("uncaughtException", (err, origin) => console.log(err.message, origin)); ${dispatch}`, + "thrown in vm context uncaughtException\n", + ], + [ + "reaches the Worker's 'error' event when the worker has no handler", + ` + const worker = new (require("node:worker_threads").Worker)(${JSON.stringify(dispatch)}, { eval: true }); + worker.on("error", err => console.log("error event:", err.message)); + worker.on("exit", code => console.log("exit:", code)); + `, + "error event: thrown in vm context\nexit: 1\n", + ], + ])("%s", async (_, code, expected) => { + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", code], env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: expected, stderr: "", exitCode: 0 }); + }); +}); + +describe("import attributes with an array-index key", () => { + test("SourceTextModule.moduleRequests[].attributes includes the key", () => { + const mod = new SourceTextModule('import x from "./y" with { "0": "a", type: "json" };'); + const { attributes } = mod.moduleRequests[0]; + expect(attributes).toEqual({ 0: "a", type: "json" }); + expect(attributes[0]).toBe("a"); + }); + + test("importModuleDynamically receives the key in its attributes argument", async () => { + const { promise, resolve } = Promise.withResolvers<Record<string, string>>(); + const script = new Script('import("x", { with: { "0": "a", custom: "b" } })', { + importModuleDynamically(_specifier, _referrer, attributes) { + resolve(attributes); + return new SourceTextModule("export default 1;"); + }, + }); + await script.runInThisContext().catch(() => {}); + const attributes = await promise; + expect(attributes).toEqual({ 0: "a", custom: "b" }); + expect(attributes[0]).toBe("a"); + }); +}); diff --git a/test/js/node/watch/fs.watch.test.ts b/test/js/node/watch/fs.watch.test.ts index 24917618677f..282aabc4dc80 100644 --- a/test/js/node/watch/fs.watch.test.ts +++ b/test/js/node/watch/fs.watch.test.ts @@ -958,6 +958,99 @@ describe("fs.watch", () => { ]); expect(exitCode).toBe(0); }); + + // The inotify reader thread walks a directory that's moved into a recursive watch. + test.concurrent.skipIf(!isLinux).each([ + { title: "deeper than the open-file limit", limit: "ulimit -n 64; ", levels: 100 }, + { title: "300 levels deep", limit: "", levels: 300 }, + ])("recursive watch covers a moved-in tree that's $title", async ({ limit, levels }) => { + using dir = tempDir("fs-watch-deep-move", { "watched": {} }); + const root = String(dir); + fs.mkdirSync(path.join(root, "src", ...Array(levels).fill("a")), { recursive: true }); + + // inotify delivers events in order, so an event for a later action means the earlier step is done. + const fixture = /* js */ ` + const fs = require("fs"), path = require("path"); + const root = process.env.WATCH_ROOT; + const watched = path.join(root, "watched"); + const seen = new Set(), errors = [], dirs = []; + for (let dir = "moved"; dirs.length < ${levels}; ) dirs.push((dir = path.join(dir, "a"))); + const bottomFile = path.join(dirs.at(-1), "f.txt"); + const watcher = fs.watch(watched, { recursive: true }, (type, name) => { + seen.add(name); + if (type !== "rename") return; + if (name === "walked.txt") { + fs.writeFileSync(path.join(watched, bottomFile), "x"); + fs.writeFileSync(path.join(watched, "last.txt"), "x"); + } else if (name === "last.txt") { + watcher.close(); + console.log(JSON.stringify({ + unreported: dirs.flatMap((dir, i) => (seen.has(dir) ? [] : [i + 1])), + bottomFile: seen.has(bottomFile), + errors, + })); + } + }); + watcher.on("error", error => errors.push(error.code)); + fs.renameSync(path.join(root, "src"), path.join(watched, "moved")); + fs.writeFileSync(path.join(watched, "walked.txt"), "x"); + `; + + await using proc = Bun.spawn({ + cmd: ["/bin/sh", "-c", limit + 'exec "$0" "$@"', bunExe(), "-e", fixture], + env: { ...bunEnv, WATCH_ROOT: root }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: JSON.stringify({ unreported: [], bottomFile: true, errors: [] }), + stderr: "", + exitCode: 0, + }); + }); + + test.concurrent.skipIf(!isLinux)( + "recursive watch reports a moved-in tree's files and directories in walk order", + async () => { + using dir = tempDir("fs-watch-mixed-tree", { "watched": {} }); + const root = String(dir); + + // Creates three levels that each mix files and directories. + function makeTree(top: string, depth: number) { + fs.mkdirSync(top, { recursive: true }); + for (const name of ["f0.txt", "f1.txt", "f2.txt"]) fs.writeFileSync(path.join(top, name), ""); + fs.mkdirSync(path.join(top, "empty")); + if (depth < 2) for (const name of ["sub0", "sub1"]) makeTree(path.join(top, name), depth + 1); + for (const name of ["g0.txt", "g1.txt"]) fs.writeFileSync(path.join(top, name), ""); + } + // Returns the entries under `top` in readdir order, each directory before its contents. + function listTree(top: string, rel: string): string[] { + return fs.readdirSync(top, { withFileTypes: true }).flatMap(entry => { + const name = path.join(rel, entry.name); + return entry.isDirectory() ? [name, ...listTree(path.join(top, entry.name), name)] : [name]; + }); + } + makeTree(path.join(root, "mixed"), 0); + const expected = ["mixed", ...listTree(path.join(root, "mixed"), "mixed")]; + + const events: string[] = []; + const { promise, resolve, reject } = Promise.withResolvers<void>(); + const watcher = fs.watch(path.join(root, "watched"), { recursive: true }, (_type, name) => { + if (name === "last.txt") resolve(); + else events.push(name!); + }); + watcher.on("error", reject); + try { + fs.renameSync(path.join(root, "mixed"), path.join(root, "watched", "mixed")); + fs.writeFileSync(path.join(root, "watched", "last.txt"), "x"); + await promise; + } finally { + watcher.close(); + } + expect(events).toEqual(expected); + }, + ); }); describe("fs.promises.watch", () => { diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index f70492742b5b..654d54aac7c3 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -138,6 +138,24 @@ test("markAsUncloneable and markAsUntransferable markers are private, unforgeabl expectDataCloneError(() => structuredClone(unmarkAttempt)); }); +test("markAsUntransferable and markAsUncloneable ignore a WebAssembly GC reference", () => { + // (module (type $s (struct (field (mut i32)))) + // (func (export "mk") (result (ref null $s)) struct.new_default $s)) + // prettier-ignore + const bytes = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + 0x01, 0x0a, 0x02, 0x5f, 0x01, 0x7f, 0x01, 0x60, 0x00, 0x01, 0x63, 0x00, + 0x03, 0x02, 0x01, 0x01, + 0x07, 0x06, 0x01, 0x02, 0x6d, 0x6b, 0x00, 0x00, + 0x0a, 0x07, 0x01, 0x05, 0x00, 0xfb, 0x01, 0x00, 0x0b, + ]); + const reference = (new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports.mk as () => object)(); + expect(markAsUntransferable(reference)).toBeUndefined(); + expect(wt.markAsUncloneable(reference)).toBeUndefined(); + expect(wt.isMarkedAsUntransferable(reference)).toBe(false); + expect(() => structuredClone(reference)).toThrow(expect.objectContaining({ name: "DataCloneError" })); +}); + test("all worker_threads worker instance properties are present", async () => { const worker = new Worker(new URL("./worker.js", import.meta.url)); expect(worker).toHaveProperty("threadId"); @@ -717,6 +735,54 @@ describe("error event", () => { }); }); +// Debug and ASAN builds assert on `vm.hasTerminationRequest()` if startup continues to its GC after the stop. +const spinUntilTerminated = `require("node:worker_threads").parentPort.postMessage("in the handler"); for (;;) {}`; +test.concurrent.each([ + [ + "process.exit() in an uncaughtException capture callback", + `process.setUncaughtExceptionCaptureCallback(() => process.exit(42)); throw new Error("boom");`, + "", + ["exit 42"], + ], + [ + "terminate() during an uncaughtException capture callback", + `process.setUncaughtExceptionCaptureCallback(() => { ${spinUntilTerminated} }); throw new Error("boom");`, + `worker.on("message", () => worker.terminate());`, + ["exit 1"], + ], + // The GC formats the stack of an Error whose `stack` hasn't been read yet. + [ + "process.exit() in a 'workerMessage' listener", + `globalThis.unreadStack = new Error("kept"); process.on("workerMessage", () => process.exit(7)); setInterval(() => {}, 1000);`, + `postMessageToThread(worker.threadId, "hello").catch(() => {});`, + ["exit 7"], + ], + [ + "a pending getHeapSnapshot() rejects", + `process.setUncaughtExceptionCaptureCallback(() => process.exit(42)); throw new Error("boom");`, + `worker.getHeapSnapshot().then(() => console.log("snapshot resolved"), err => console.log("snapshot rejected", err.code));`, + ["exit 42", "snapshot rejected ERR_WORKER_NOT_RUNNING"], + ], +])("a worker stopped in a handler during startup exits cleanly: %s", async (_name, source, parentCode, lines) => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Worker, postMessageToThread } = require("node:worker_threads"); + const worker = new Worker(${JSON.stringify(source)}, { eval: true }); + worker.on("error", err => console.log("error", err.message)); + worker.on("exit", code => console.log("exit", code)); + ${parentCode}`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ lines: stdout.trim().split("\n").sort(), stderr }).toEqual({ lines, stderr: "" }); + expect(exitCode).toBe(0); +}); + describe("getHeapSnapshot", () => { test("throws if the wrong options are passed", () => { const worker = new Worker("", { eval: true }); @@ -1888,6 +1954,40 @@ describe("env: SHARE_ENV shares the spawning thread's env, not a process-wide on }); }); + // A partial descriptor moves the entry from the shared store onto the JS object. + it("keeps an array-index env var readable after a partial defineProperty on the shared map", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Worker, SHARE_ENV } = require("worker_threads"); + new Worker("1", { eval: true, env: SHARE_ENV }).on("exit", () => { + process.env[0] = "zero"; + Object.defineProperty(process.env, "0", { enumerable: true }); + console.log(JSON.stringify({ + byIndex: process.env[0], + byString: process.env["0"], + listed: Object.keys(process.env).includes("0"), + descriptor: Object.getOwnPropertyDescriptor(process.env, "0"), + })); + });`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ parsed: stdout ? JSON.parse(stdout) : stdout, stderr, exitCode }).toEqual({ + parsed: { + byIndex: "zero", + byString: "zero", + listed: true, + descriptor: { value: "zero", writable: true, enumerable: true, configurable: true }, + }, + stderr: "", + exitCode: 0, + }); + }); + // node roots a main-founded SHARE_ENV tree at its RealEnvStore, so a worker writing // through it reaches the real environment a child process inherits; a snapshot // worker's store is private and never does. (child_process enumerates the JS @@ -3158,3 +3258,85 @@ describe("no JS entry after a worker's termination has been thrown", () => { }); } }); + +test("the main thread never reads a torn cwd while a Web Worker calls process.chdir()", async () => { + using dir = tempDir("worker-chdir-cwd-readers", { + "x/.keep": "", + "yyyyyyyyyyyyyyyy/.keep": "", + "flip.cjs": /* js */ ` + const path = require("node:path"); + const A = path.join(__dirname, "x"); + const B = path.join(__dirname, "yyyyyyyyyyyyyyyy"); + let i = 0; + const tick = () => { + for (let k = 0; k < 200; k++) process.chdir(i++ & 1 ? A : B); + if (i === 200) postMessage("flipping"); + setImmediate(tick); + }; + tick(); + `, + "run.mjs": /* js */ ` + import fs from "node:fs"; + import path from "node:path"; + const base = path.dirname(import.meta.filename); + const A = fs.realpathSync(path.join(base, "x")), B = fs.realpathSync(path.join(base, "yyyyyyyyyyyyyyyy")); + process.chdir(A); + const ok = new Set([ + A, B, + Bun.pathToFileURL(path.join(A, "rel")).href, Bun.pathToFileURL(path.join(B, "rel")).href, + path.join(A, "f"), path.join(B, "f"), + ]); + const workers = Array.from({ length: 4 }, () => new Worker(path.join(base, "flip.cjs"))); + await Promise.all(workers.map(w => new Promise((resolve, reject) => { + w.onmessage = resolve; + w.onerror = e => reject(new Error("worker failed: " + e.message)); + }))); + const torn = new Set(); + const check = value => { if (!ok.has(value)) torn.add(value); }; + for (let i = 0; i < 2000; i++) { + try { check(fs.realpathSync(".")); } catch (e) { check("realpath: " + e.code); } + check(Bun.pathToFileURL("rel").href); + check(path.resolve("f")); + } + console.log(JSON.stringify([...torn])); + process.exit(0); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "run.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout).toBe("[]\n"); + expect(exitCode).toBe(0); +}); + +test("a ShadowRealm in a worker can import node:worker_threads", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + /* js */ ` + const { Worker } = require("node:worker_threads"); + const worker = new Worker( + 'new ShadowRealm().importValue("node:worker_threads", "threadId").then(id => require("node:worker_threads").parentPort.postMessage(id))', + { eval: true }, + ); + worker.once("message", id => console.log(id === worker.threadId)); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + + expect(stdout).toBe("true\n"); + expect(exitCode).toBe(0); +}); diff --git a/test/js/node/zlib/libdeflate-racing-input-fixture.ts b/test/js/node/zlib/libdeflate-racing-input-fixture.ts new file mode 100644 index 000000000000..0cca22c71f32 --- /dev/null +++ b/test/js/node/zlib/libdeflate-racing-input-fixture.ts @@ -0,0 +1,50 @@ +// Calls Bun.deflateSync and Bun.gzipSync on a shared mapping of argv[2] while a worker rewrites it. +// libdeflate sizes a block in one pass over the input and emits it in a second pass. +import { isMainThread, parentPort, Worker } from "node:worker_threads"; + +const view = Bun.mmap(process.argv[2], { shared: true }); + +if (isMainThread) { + const worker = new Worker(new URL(import.meta.url), { argv: [process.argv[2]] }); + await new Promise(resolve => worker.on("message", resolve)); + worker.unref(); + + for (let i = 0; i < 200; i++) { + for (const compress of [Bun.deflateSync, Bun.gzipSync]) { + let output: Uint8Array; + try { + output = compress(view, { library: "libdeflate" }); + } catch (error) { + if (!/insufficient space/.test((error as Error).message)) throw error; + continue; + } + if (output.length === 0) { + console.log(`${compress.name} returned an empty result on call ${i}`); + process.exit(1); + } + } + } + console.log("done"); + process.exit(0); +} else { + // `costly`'s bytes are rare in `cheap`, so a Huffman table built from `cheap` gives them the longest codes. + let state = 88172645; + const next = () => ((state ^= state << 13), (state ^= state >>> 17), (state ^= state << 5), state >>> 0); + const cheap = new Uint8Array(view.length); + const costly = new Uint8Array(view.length); + for (let i = 0; i < view.length; i++) { + cheap[i] = next() & 0x7f; + costly[i] = 0xf0 + (next() & 0x0f); + } + for (let i = 0; i < 16; i++) cheap[next() % view.length] = 0xf0 + i; + + parentPort!.postMessage("ready"); + + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + view.set(cheap); + for (let i = next() % 50000; i > 0; i--); + view.set(costly); + for (let i = next() % 5000; i > 0; i--); + } +} diff --git a/test/js/node/zlib/zlib-handle-bounds-check.test.ts b/test/js/node/zlib/zlib-handle-bounds-check.test.ts index 8e345664be0f..6dade8b50748 100644 --- a/test/js/node/zlib/zlib-handle-bounds-check.test.ts +++ b/test/js/node/zlib/zlib-handle-bounds-check.test.ts @@ -466,7 +466,8 @@ describe.concurrent("zlib native handle argument validation", () => { catch (e) { console.log("threw " + e.code + ": " + e.message); }`, ), ).toEqual({ - stdout: 'threw ERR_INVALID_ARG_VALUE: The "out" argument must not be backed by a resizable ArrayBuffer', + stdout: + 'threw ERR_INVALID_ARG_VALUE: The "out" argument must not be backed by a resizable ArrayBuffer or a WebAssembly.Memory', exitCode: 0, }); }); @@ -531,3 +532,44 @@ describe.concurrent("zlib native handle argument validation", () => { ).toEqual({ stdout: "ok hello world", exitCode: 0 }); }); }); + +// With ZSTD_c_nbWorkers >= 1, a write posts a job to zstd's worker threads and returns. A frame's first +// job reads the stream's dictionary. Level 19 keeps the job running after the write callback. +describe("zstd compressor with workers and a dictionary", () => { + const prelude = /* js */ ` + const zlib = require("node:zlib"); + const { pbkdf2Sync } = require("node:crypto"); + const { finished } = require("node:stream/promises"); + const { ZSTD_c_compressionLevel, ZSTD_c_jobSize, ZSTD_c_nbWorkers, ZSTD_c_overlapLog } = zlib.constants; + + const hex = (seed, length) => Buffer.from(pbkdf2Sync(seed, "", 1, length / 2, "sha256").toString("hex")); + // Large enough that the allocator returns the freed dictionary to the OS. + const dictionary = hex("dictionary", 192 * 1024); + const input = hex("input", 512 * 1024); + const stream = zlib + .createZstdCompress({ + dictionary, + params: { + [ZSTD_c_nbWorkers]: 2, + [ZSTD_c_jobSize]: input.length, + [ZSTD_c_overlapLog]: 1, + [ZSTD_c_compressionLevel]: 19, + }, + }) + .on("data", () => {}); + await new Promise(done => stream.write(input, done)); + `; + + test.each([ + ["destroy()", `stream.destroy(); console.log("ok");`], + ["reset() and the next frame", `stream.reset(); stream.end("x"); await finished(stream); console.log("ok");`], + ])("doesn't crash on %s while a job is running", async (_, body) => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", prelude + body], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), exitCode }).toEqual({ stdout: "ok", exitCode: 0 }); + }); +}); diff --git a/test/js/node/zlib/zlib-reset-race.test.ts b/test/js/node/zlib/zlib-reset-race.test.ts index bf625d686fea..c5fe6dfded0a 100644 --- a/test/js/node/zlib/zlib-reset-race.test.ts +++ b/test/js/node/zlib/zlib-reset-race.test.ts @@ -186,7 +186,8 @@ const resizableWriteFixture = (which: "in" | "out") => /* js */ ` test("async write() rejects an output buffer backed by a resizable ArrayBuffer for zlib, brotli, and zstd", async () => { const { stdout, exitCode } = await run(resizableWriteFixture("out")); - const expected = 'threw ERR_INVALID_ARG_VALUE: The "out" argument must not be backed by a resizable ArrayBuffer'; + const expected = + 'threw ERR_INVALID_ARG_VALUE: The "out" argument must not be backed by a resizable ArrayBuffer or a WebAssembly.Memory'; expect(stdout.trim()).toBe([expected, expected, expected].join("\n")); expect(exitCode).toBe(0); }); diff --git a/test/js/node/zlib/zlib.test.js b/test/js/node/zlib/zlib.test.js index f44ea21f98fd..aed01a705564 100644 --- a/test/js/node/zlib/zlib.test.js +++ b/test/js/node/zlib/zlib.test.js @@ -1,6 +1,6 @@ import { deflateSync, gunzipSync, gzipSync, inflateSync } from "bun"; import { describe, expect, it } from "bun:test"; -import { isDebug, tmpdirSync } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, tempDir, tmpdirSync } from "harness"; import * as buffer from "node:buffer"; import { randomFillSync } from "node:crypto"; import * as fs from "node:fs"; @@ -136,6 +136,71 @@ describe("zlib", () => { } }); + // libdeflate never emits a block bigger than a stored one, so output longer than the input plus + // its overhead (5 bytes, plus 18 for gzip) means it wrote past the allocation. + it.concurrent.each(["gzip", "deflate"])( + "libdeflate %s doesn't write past its output buffer while another thread writes to the shared input", + async kind => { + const fixture = ` + import { isMainThread, parentPort, Worker, workerData } from "node:worker_threads"; + + const size = 4096; + if (isMainThread) { + const input = new SharedArrayBuffer(size); + const worker = new Worker(new URL(import.meta.url), { workerData: input }); + const ready = Promise.withResolvers(); + worker.on("message", ready.resolve); + await ready.promise; + worker.unref(); + + const raw = process.argv[2] === "deflate"; + const reserved = size + (raw ? 5 : 23); + const compress = raw ? Bun.deflateSync : Bun.gzipSync; + const decompress = raw ? Bun.inflateSync : Bun.gunzipSync; + const view = new Uint8Array(input); + for (let i = 0; i < 500; i++) { + const out = compress(view, { library: "libdeflate", level: 1 + (i % 12) }); + if (out.length > reserved) throw new Error("wrote " + out.length + " bytes into " + reserved); + const back = decompress(out, { library: "libdeflate" }); + if (back.length !== size) throw new Error("round trip returned " + back.length + " bytes"); + } + console.log("ok"); + process.exit(0); + } else { + // Alternate between bytes that encode in a few bits and bytes that need more. + const view = new Uint8Array(workerData); + let seed = 88172645; + const next = () => ((seed ^= seed << 13), (seed ^= seed >>> 17), (seed ^= seed << 5), seed >>> 0); + const cheap = new Uint8Array(size); + const costly = new Uint8Array(size); + for (let i = 0; i < size; i++) { + cheap[i] = next() & 127; + costly[i] = 0xf0 + (next() & 15); + } + for (let i = 0; i < 16; i++) cheap[next() % size] = 0xf0 + i; + parentPort.postMessage("ready"); + for (;;) { + view.set(cheap); + for (let i = next() % 50000; i > 0; i--); + view.set(costly); + for (let i = next() % 5000; i > 0; i--); + } + } + `; + using dir = tempDir("libdeflate-shared", { "fixture.mjs": fixture }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.mjs", kind], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr }).toEqual({ stdout: "ok\n", stderr: "" }); + expect(exitCode).toBe(0); + }, + ); + describe("memLevel and strategy", () => { const data = Buffer.from(Array.from({ length: 4000 }, (_, i) => Math.imul(i, 2654435761).toString(36)).join(" ")); for (const [compress, decompress, nodeCompress] of [ @@ -474,6 +539,19 @@ describe("zlib.brotli", () => { expect(all.length).toBeGreaterThanOrEqual(7); }, 15_000); + it.each(["createBrotliCompress", "createBrotliDecompress"])( + "%s().flush() rejects a zlib-only flush value", + factory => { + const stream = zlib[factory](); + stream.write(Buffer.alloc(128, 0x61)); + expect(() => stream.flush(zlib.constants.Z_FINISH)).toThrow( + expect.objectContaining({ code: "ERR_OUT_OF_RANGE" }), + ); + expect(() => stream.flush(zlib.constants.Z_BLOCK)).toThrow(expect.objectContaining({ code: "ERR_OUT_OF_RANGE" })); + stream.destroy(); + }, + ); + it("should accept params", async () => { const ZLIB = zlib.constants; const inputString2 = @@ -1011,3 +1089,70 @@ describe("crc32", () => { ); }); }); + +// Only ASAN can detect the out-of-bounds write. +it.skipIf(!isASAN)( + "libdeflate doesn't write past its output buffer when another thread rewrites a mapped input", + async () => { + using dir = tempDir("libdeflate-race", { "bytes.bin": Buffer.alloc(4096) }); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + resolve(import.meta.dir, "libdeflate-racing-input-fixture.ts"), + resolve(String(dir), "bytes.bin"), + ], + env: { ...bunEnv, ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "symbolize=0"].filter(Boolean).join(":") }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ stdout: "done", stderr: "", exitCode: 0 }); + }, + 20_000, +); + +// `grow()` detaches a `WebAssembly.Memory`'s buffer and frees the old block even if the buffer is pinned. +it("zstdCompress compresses the bytes the caller passed when a WebAssembly.Memory grows after the call", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + /* js */ ` + import { zstdCompress, zstdDecompressSync, constants } from "node:zlib"; + import { randomFillSync } from "node:crypto"; + const PAGES = 16; // 1 MB + const newMemory = () => new WebAssembly.Memory({ initial: PAGES, maximum: PAGES + 4 }); + + const mem = newMemory(); + const input = new Uint8Array(mem.buffer); + // Incompressible input at a high level keeps the job reading until after the block is freed. + randomFillSync(input); + const original = Buffer.from(input); + + const { promise, resolve } = Promise.withResolvers(); + const options = { chunkSize: 12 << 20, params: { [constants.ZSTD_c_compressionLevel]: 12 } }; + zstdCompress(input, options, (err, out) => + resolve(err ? String(err) : Buffer.compare(zstdDecompressSync(out), original) === 0), + ); + mem.grow(2); + // Reuse the freed block so a stale read can't see the caller's bytes. + const claim = Array.from({ length: 4 }, () => { + const memory = newMemory(); + new Uint8Array(memory.buffer).fill(0xee); + return memory; + }); + + console.log(JSON.stringify({ detachedAfterGrow: input.byteLength === 0, roundTripMatches: await promise })); + `, + ], + // Without fast memory, `grow()` moves the block on every platform. With system malloc, the freed block is unmapped. + env: { ...bunEnv, BUN_JSC_useWasmFastMemory: "0", Malloc: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.trim()).toBe(JSON.stringify({ detachedAfterGrow: true, roundTripMatches: true })); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); +}); diff --git a/test/js/valkey/reliability/resp-nesting-depth.test.ts b/test/js/valkey/reliability/resp-nesting-depth.test.ts index e9edbb21a44e..4c0f45d07667 100644 --- a/test/js/valkey/reliability/resp-nesting-depth.test.ts +++ b/test/js/valkey/reliability/resp-nesting-depth.test.ts @@ -357,4 +357,49 @@ describe("Valkey: RESP push frame routing", () => { server.close(); } }); + + describe("an unsubscribe ack that arrives outside subscriber mode", () => { + const bulk = (s: string) => `$${Buffer.byteLength(s)}\r\n${s}\r\n`; + const ack = (kind: string, subject: string, remaining: number) => + Buffer.from(`>3\r\n${bulk(kind)}${bulk(subject)}:${remaining}\r\n`); + const ok = Buffer.from("+OK\r\n"); + + async function withMockClient(payloads: Buffer[], body: (client: Bun.RedisClient) => Promise<void>) { + const { server, port } = await createMockRedisServer(payloads); + const client = new Bun.RedisClient(`redis://127.0.0.1:${port}`, { + autoReconnect: false, + connectionTimeout: 2000, + }); + try { + await body(client); + } finally { + client.close(); + server.close(); + } + } + + test("resolves the second of two in-flight UNSUBSCRIBEs", async () => { + // unsubscribe() drops the channel's handlers immediately, so the first ack already exits subscriber mode. + const payloads = [ + ack("subscribe", "a", 1), + ack("subscribe", "b", 2), + ack("unsubscribe", "a", 1), + ack("unsubscribe", "b", 0), + ok, + ]; + await withMockClient(payloads, async client => { + await client.subscribe("a", () => {}); + await client.subscribe("b", () => {}); + expect(await Promise.all([client.unsubscribe("a"), client.unsubscribe("b")])).toEqual([undefined, undefined]); + expect(await client.set("key", "value")).toBe("OK"); + }); + }); + + test("resolves punsubscribe() on a client that never subscribed", async () => { + await withMockClient([ack("punsubscribe", "news.*", 0), ok], async client => { + expect(await client.punsubscribe("news.*")).toEqual({ type: "punsubscribe", data: ["news.*", 0] }); + expect(await client.set("key", "value")).toBe("OK"); + }); + }); + }); }); diff --git a/test/js/valkey/valkey-gc.test.ts b/test/js/valkey/valkey-gc.test.ts index 09bacc45c6f3..1b0b1e0fb6f3 100644 --- a/test/js/valkey/valkey-gc.test.ts +++ b/test/js/valkey/valkey-gc.test.ts @@ -463,6 +463,120 @@ test.concurrent("RedisClient survives GC after a command throws during argument expect(exitCode).toBe(0); }); +test.concurrent( + "set and send write the original key bytes when a later argument's toString() detaches the key's buffer", + async () => { + const src = ` + const net = require("node:net"); + + const tail = Buffer.from("$1\\r\\nv\\r\\n"); + let wire = Buffer.alloc(0); + let want = 0; + let resolveFrame; + let saidHello = false; + function tails() { + let n = 0, i = -1; + while ((i = wire.indexOf(tail, i + 1)) !== -1) n++; + return n; + } + const server = net.createServer(socket => { + socket.on("data", data => { + if (!saidHello) { + if (data.includes("HELLO")) { saidHello = true; socket.write("+OK\\r\\n"); } + return; + } + wire = Buffer.concat([wire, data]); + if (resolveFrame && tails() >= want) { + socket.write("+OK\\r\\n"); + const r = resolveFrame; resolveFrame = null; r(); + } + }); + socket.on("error", () => {}); + }); + await new Promise((resolve, reject) => { + server.listen(0, "127.0.0.1", resolve); + server.on("error", reject); + }); + + const client = new Bun.RedisClient("redis://127.0.0.1:" + server.address().port, { autoReconnect: false }); + await client.connect(); + + const keep = []; + function value(key) { + return Object.assign(new String("v"), { + toString() { + key.buffer.transfer(0); + for (let i = 0; i < 24; i++) keep.push(new Uint8Array(4096).fill(0x5a)); + return "v"; + }, + }); + } + function key(name) { + const key = Buffer.from(new ArrayBuffer(4096)); + key.write(name); + return key; + } + + for (const [name, run] of [ + ["first-key", key => client.set(key, value(key))], + ["second-key", key => client.send("LPUSH", [key, value(key)])], + ]) { + want++; + const done = new Promise(r => (resolveFrame = r)); + run(key(name)).catch(() => {}); + await done; + } + + client.close(); + server.close(); + console.log(JSON.stringify([wire.includes("first-key"), wire.includes("second-key")])); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + + expect(stdout.trim()).toBe("[true,true]"); + expect(exitCode).toBe(0); + }, +); + +test.concurrent.each(["null", "undefined", "{}", "42"])( + "RedisClient ignores onconnect and onclose handlers set to %s", + async handler => { + const src = ` + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { data(socket) { socket.write("%0\\r\\n"); } }, + }); + const client = new Bun.RedisClient("redis://127.0.0.1:" + server.port, { autoReconnect: false }); + client.onconnect = ${handler}; + client.onclose = ${handler}; + await client.connect(); + client.close(); + console.log("OK"); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + + expect(stdout.trim()).toBe("OK"); + expect(exitCode).toBe(0); + }, +); + // Fuzzer found heap corruption (zapped cell during GC marking) when a custom // setter on a generated class was invoked with a receiver that is not an // instance of that class (e.g. through a Proxy wrapping the instance, or an diff --git a/test/js/web/broadcastchannel/broadcast-channel.test.ts b/test/js/web/broadcastchannel/broadcast-channel.test.ts index 42944372713e..0ac98426cc94 100644 --- a/test/js/web/broadcastchannel/broadcast-channel.test.ts +++ b/test/js/web/broadcastchannel/broadcast-channel.test.ts @@ -261,3 +261,12 @@ test("user options are forwarded through custom inspect", () => { "BroadcastChannel { name:\n 'hello',\n active:\n true }", ); }); + +test("custom inspect handles options with numeric keys", () => { + const bc = new BroadcastChannel("hello"); + bc.unref(); + const inspectCustom = bc[Symbol.for("nodejs.util.inspect.custom")]; + const opts = { 0: "a", 1: "b", depth: 2 }; + expect(inspectCustom.call(bc, 2, opts, util.inspect)).toBe("BroadcastChannel { name: 'hello', active: true }"); + bc.close(); +}); diff --git a/test/js/web/console/console-log.test.ts b/test/js/web/console/console-log.test.ts index 1152b1cb6cbc..956cfb5de58f 100644 --- a/test/js/web/console/console-log.test.ts +++ b/test/js/web/console/console-log.test.ts @@ -64,6 +64,29 @@ it("long arrays get cutoff at a nested indent", () => { ); }); +it("console.dir treats a BigInt depth like the same number", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `let o = {}, c = o; for (let i = 0; i < 40; i++) { c.n = {}; c = c.n; } + for (const depth of [7n, 7, -1n, -1]) { + console.dir(o, { depth, colors: false }); + console.log("--"); + }`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + const [big7, num7, bigNegative, numNegative] = stdout.split("--\n"); + expect(big7).toBe(num7); + expect(bigNegative).toBe(numNegative); + expect(exitCode).toBe(0); +}); + it("console.group", async () => { const filepath = join(import.meta.dir, "console-group.fixture.js").replaceAll("\\", "/"); const proc = Bun.spawnSync({ diff --git a/test/js/web/crypto/web-crypto.test.ts b/test/js/web/crypto/web-crypto.test.ts index 88d7bdfcd214..efa5d8f3d49f 100644 --- a/test/js/web/crypto/web-crypto.test.ts +++ b/test/js/web/crypto/web-crypto.test.ts @@ -348,6 +348,97 @@ describe("oversized inputs", () => { }); }); +describe("oversized outputs", () => { + // The output can be larger than the input because of a tag, padding, 8 bytes for AES-KW, or base64 for a JWK. + // The synthetic limit lowers the cap to 1 MiB. + it.concurrent("rejects an output over the buffer limit and accepts one at the limit", async () => { + const script = ` + import { setSyntheticAllocationLimitForTesting } from "bun:internal-for-testing"; + const limit = 1024 * 1024; + setSyntheticAllocationLimitForTesting(limit); + const { subtle } = crypto; + + const gcmKey = await subtle.importKey("raw", new Uint8Array(32).fill(1), "AES-GCM", false, ["encrypt", "decrypt"]); + const cbcKey = await subtle.importKey("raw", new Uint8Array(32).fill(2), "AES-CBC", false, ["encrypt"]); + const chachaKey = await subtle.importKey("raw-secret", new Uint8Array(32).fill(3), "ChaCha20-Poly1305", false, ["encrypt"]); + const kek = await subtle.importKey("raw", new Uint8Array(32).fill(4), "AES-KW", false, ["wrapKey"]); + const ctrKey = await subtle.importKey("raw", new Uint8Array(32).fill(5), "AES-CTR", false, ["encrypt"]); + const cfbKey = await subtle.importKey("raw", new Uint8Array(32).fill(6), "AES-CFB-8", false, ["encrypt"]); + const iv12 = new Uint8Array(12); + const iv16 = new Uint8Array(16); + + const results = {}; + const record = async (label, promise) => { + try { + results[label] = (await promise).byteLength; + } catch (e) { + results[label] = e.name; + } + }; + const hmac = size => subtle.importKey("raw", new Uint8Array(size), { name: "HMAC", hash: "SHA-256" }, true, ["sign"]); + const wrap = async (format, size) => subtle.wrapKey(format, await hmac(size), kek, "AES-KW"); + const exportJwk = async size => { + try { + results["HMAC jwk export " + (size * 4 > limit * 3 ? "over" : "at") + " limit"] = (await subtle.exportKey("jwk", await hmac(size))).k.length; + } catch (e) { + results["HMAC jwk export over limit"] = e.name; + } + }; + + await record("AES-GCM at limit", subtle.encrypt({ name: "AES-GCM", iv: iv12 }, gcmKey, new Uint8Array(limit - 16))); + await record("AES-GCM over limit", subtle.encrypt({ name: "AES-GCM", iv: iv12 }, gcmKey, new Uint8Array(limit - 15))); + await record("ChaCha20-Poly1305 at limit", subtle.encrypt({ name: "ChaCha20-Poly1305", iv: iv12 }, chachaKey, new Uint8Array(limit - 16))); + await record("ChaCha20-Poly1305 over limit", subtle.encrypt({ name: "ChaCha20-Poly1305", iv: iv12 }, chachaKey, new Uint8Array(limit - 15))); + await record("AES-CBC at limit", subtle.encrypt({ name: "AES-CBC", iv: iv16 }, cbcKey, new Uint8Array(limit - 1))); + await record("AES-CBC over limit", subtle.encrypt({ name: "AES-CBC", iv: iv16 }, cbcKey, new Uint8Array(limit))); + await record("AES-CTR at limit", subtle.encrypt({ name: "AES-CTR", counter: iv16, length: 64 }, ctrKey, new Uint8Array(limit))); + await record("AES-CTR over limit", subtle.encrypt({ name: "AES-CTR", counter: iv16, length: 64 }, ctrKey, new Uint8Array(limit + 1))); + await record("AES-CFB-8 at limit", subtle.encrypt({ name: "AES-CFB-8", iv: iv16 }, cfbKey, new Uint8Array(limit))); + await record("AES-CFB-8 over limit", subtle.encrypt({ name: "AES-CFB-8", iv: iv16 }, cfbKey, new Uint8Array(limit + 1))); + await record("AES-KW at limit", wrap("raw", limit - 8)); + await record("AES-KW over limit", wrap("raw", limit)); + // base64url encodes 3 bytes as 4 characters, so a 768 KiB key encodes to exactly 1 MiB. + await exportJwk((limit / 4) * 3); + await exportJwk((limit / 4) * 3 + 3); + await record("AES-KW jwk over limit", wrap("jwk", (limit / 4) * 3)); + + // The process is still usable after a rejection. + const small = await subtle.encrypt({ name: "AES-GCM", iv: iv12 }, gcmKey, new Uint8Array(16).fill(5)); + const roundTrip = new Uint8Array(await subtle.decrypt({ name: "AES-GCM", iv: iv12 }, gcmKey, small)); + results["small round-trip"] = roundTrip.every(b => b === 5) ? "ok" : "mismatch"; + + console.log(JSON.stringify(results)); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + "AES-GCM at limit": 1024 * 1024, + "AES-GCM over limit": "OperationError", + "ChaCha20-Poly1305 at limit": 1024 * 1024, + "ChaCha20-Poly1305 over limit": "OperationError", + "AES-CBC at limit": 1024 * 1024, + "AES-CBC over limit": "OperationError", + "AES-CTR at limit": 1024 * 1024, + "AES-CTR over limit": "OperationError", + "AES-CFB-8 at limit": 1024 * 1024, + "AES-CFB-8 over limit": "OperationError", + "AES-KW at limit": 1024 * 1024, + "AES-KW over limit": "OperationError", + "HMAC jwk export at limit": 1024 * 1024, + "HMAC jwk export over limit": "OperationError", + "AES-KW jwk over limit": "OperationError", + "small round-trip": "ok", + }); + expect(exitCode).toBe(0); + }); +}); + describe("RSA-PSS saltLength", () => { // saltLength is an unsigned long, but the OpenSSL setter takes an int whose // negative values select a salt length: -1 is the digest length and -2 means diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index d1d5a037ac52..0d913dd5c15f 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -1818,6 +1818,37 @@ it("fetch() file:// rejects a host that is not this machine", async () => { expect(await outcome(`file://${pathname.replace(/\/([^/]*)$/, "/sub/..%2f$1")}`)).toBe(encoded); }); +it("fetch() file:// rejects a path with an encoded NUL", async () => { + using dir = tempDir("fetch-file-nul", { "note.txt": "NOTE-TXT" }); + const url = Bun.pathToFileURL(join(String(dir), "note.txt")).href; + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `for (const url of process.argv.slice(1)) { + const outcome = fetch(url).then( + async r => r.status + " " + (await r.text()), + e => e.name + " " + e.code + ": " + e.message, + ); + console.log(await outcome); + }`, + url, + url + "%00.png", + url + "%00", + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + // Bun.file(url) and node:fs report the same error for this URL. + const rejected = (suffix: string) => + "TypeError ERR_INVALID_ARG_VALUE: The argument 'path' must be a string, Uint8Array, or URL without null bytes. " + + `Received ${JSON.stringify(decodeURIComponent(new URL(url).pathname) + suffix)}`; + expect(stdout.split("\n")).toEqual(["200 NOTE-TXT", rejected("\0.png"), rejected("\0"), ""]); + expect(exitCode).toBe(0); +}); + it("proxy: true is rejected, since it names no proxy", async () => { expect(await fetch("http://example.invalid/", { proxy: true } as any).catch(e => e.code)).toBe( "ERR_INVALID_ARG_TYPE", diff --git a/test/js/web/fetch/headers.test.ts b/test/js/web/fetch/headers.test.ts index a069a21a7b26..ea83e97435bd 100644 --- a/test/js/web/fetch/headers.test.ts +++ b/test/js/web/fetch/headers.test.ts @@ -1,4 +1,5 @@ import { beforeAll, describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isDebug } from "harness"; // Namespace import so a missing binding fails only the kernel tests below // (accessing an absent export is `undefined`), not the whole file. import * as internalForTesting from "bun:internal-for-testing"; @@ -255,6 +256,46 @@ describe("Headers", () => { // @ts-expect-error expect(() => headers.append("expires")).toThrow(TypeError); }); + test.each(["accept", "cookie", "x-repeated"])("throws when the joined %s value is too long", name => { + const half = Buffer.alloc(512 * 1024, "a").toString(); + const headers = new Headers([[name, half]]); + const originalLimit = internalForTesting.setSyntheticAllocationLimitForTesting(1024 * 1024); + try { + expect(() => headers.append(name, half)).toThrow("Out of memory"); + expect(headers.get(name)).toHaveLength(half.length); + } finally { + internalForTesting.setSyntheticAllocationLimitForTesting(originalLimit); + } + }); + test("throws when the set-cookie list can't grow", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + // With a 64 KiB limit the list is capped at 8192 values. + const headers = new Headers(); + for (let i = 0; i < 8192; i++) headers.append("set-cookie", "a"); + try { + headers.append("set-cookie", "a"); + console.log("no error"); + } catch (e) { + console.log(e.name, e.message); + } + console.log(headers.getSetCookie().length); + `, + ], + env: { ...bunEnv, BUN_FEATURE_FLAG_SYNTHETIC_MEMORY_LIMIT: String(64 * 1024) }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "RangeError Out of memory\n8192\n", + stderr: "", + exitCode: 0, + }); + }); }); describe("set()", () => { test("can set header", () => { @@ -369,6 +410,28 @@ describe("Headers", () => { // @ts-expect-error expect(() => headers.get()).toThrow(TypeError); }); + test("throws when the joined set-cookie values are too long", () => { + const half = Buffer.alloc(512 * 1024, "a").toString(); + const headers = new Headers([ + ["set-cookie", half], + ["set-cookie", half], + ]); + const originalLimit = internalForTesting.setSyntheticAllocationLimitForTesting(1024 * 1024); + try { + expect(() => headers.get("set-cookie")!.length).toThrow("Out of memory"); + expect(headers.getSetCookie()).toHaveLength(2); + } finally { + internalForTesting.setSyntheticAllocationLimitForTesting(originalLimit); + } + }); + test("joins many set-cookie values after a long first value", () => { + const headers = new Headers(); + headers.append("set-cookie", Buffer.alloc(2 ** 20, "x").toString()); + for (let i = 0; i < 2047; i++) headers.append("set-cookie", "a"); + const joined = headers.get("set-cookie")!; + expect(joined.length).toBe(2 ** 20 + 3 * 2047); + expect(joined.slice(-4)).toBe("a, a"); + }); }); describe("has()", () => { test("can check header", () => { @@ -386,6 +449,12 @@ describe("Headers", () => { // @ts-expect-error expect(() => headers.has()).toThrow(TypeError); }); + test("quotes an invalid name in the error, like get() and delete()", () => { + const headers = new Headers(); + for (const method of ["has", "get", "delete"] as const) { + expect(() => headers[method]("a b")).toThrow(new TypeError("Invalid header name: 'a b'")); + } + }); }); describe("entries()", () => { test("can get header entries when empty", () => { @@ -1022,6 +1091,66 @@ describe("Headers", () => { }); }); + // Debug builds need minutes to validate the 4 GiB of values the child appends. + test.skipIf(isDebug)("fetch() and Bun.serve() reject headers that total 4 GiB or more", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + /* js */ ` + // The 16 values share one 256 MiB string. Names and values total 2 ** 32 - 1 bytes, + // which is the maximum. + const value = Buffer.alloc(2 ** 28, "a").toString("latin1"); + const headers = new Headers(); + for (let i = 0; i < 15; i++) headers.append("set-cookie", value); + headers.append("set-cookie", value.slice(0, 2 ** 28 - 161)); + + async function outcome(fn) { + try { + await fn(); + return "no error"; + } catch (e) { + return e.name + ": " + e.message; + } + } + function serve(response) { + Bun.serve({ port: 0, routes: { "/": response } }).stop(true); + } + + await using server = Bun.serve({ port: 0, fetch: () => new Response("ok") }); + const url = server.url.href; + const file = Bun.file(process.execPath); + + // The headers fit by themselves, but not with the Content-Type that fetch() adds for the body. + const body = new Blob(["x"], { type: "text/plain" }); + console.log("content-type:", await outcome(() => fetch(url, { method: "POST", body, headers }))); + + // This brings the total to 2 ** 32 + 10 bytes. + headers.append("set-cookie", "x"); + console.log("fetch:", await outcome(() => fetch(url, { headers }))); + console.log("proxy:", await outcome(() => fetch(url, { proxy: { url, headers } }))); + console.log("static route:", await outcome(() => serve(new Response("body", { headers })))); + console.log("file route:", await outcome(() => serve(new Response(file, { headers })))); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + + const error = "RangeError: Headers exceed the maximum total size of 4294967295 bytes"; + expect(stdout.split("\n")).toEqual([ + `content-type: ${error}`, + `fetch: ${error}`, + `proxy: ${error}`, + `static route: ${error}`, + `file route: ${error}`, + "", + ]); + expect(exitCode).toBe(0); + }); + // Header-name lowercasing on iteration (Object.fromEntries / spread / toJSON / // keys()) runs through a SIMD kernel on the 8-bit path. Sweep name lengths // across the vector-block boundaries and include every ASCII printable that is diff --git a/test/js/web/html/FormData.test.ts b/test/js/web/html/FormData.test.ts index b95654151eae..0151a81459f6 100644 --- a/test/js/web/html/FormData.test.ts +++ b/test/js/web/html/FormData.test.ts @@ -379,6 +379,65 @@ describe("FormData", () => { } }); + it("rejects a URL-encoded name that is too long to decode", async () => { + const { setSyntheticAllocationLimitForTesting } = require("bun:internal-for-testing"); + const body = Buffer.alloc(600_000, "é"); + const headers = { "content-type": "application/x-www-form-urlencoded" }; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(body); + controller.close(); + }, + }); + // With a 1 MiB limit the decoder is capped at 524288 UTF-8 bytes. + const originalLimit = setSyntheticAllocationLimitForTesting(1024 * 1024); + try { + // @ts-expect-error - FormData.from is a Bun extension + expect(() => FormData.from(body)).toThrow("Out of memory"); + await expect(new Response(body, { headers }).formData()).rejects.toThrow("Out of memory"); + await expect(new Response(stream, { headers }).formData()).rejects.toThrow("Out of memory"); + await expect(new Blob([body], { type: headers["content-type"] }).formData()).rejects.toThrow("Out of memory"); + } finally { + setSyntheticAllocationLimitForTesting(originalLimit); + } + }); + + it("throws when the entry list can't grow", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + // With a 64 KiB limit the list is capped at 2730 entries. + const form = FormData.from(Buffer.alloc(5460, "a&").toString()); + const part = '--b\\r\\nContent-Disposition: form-data; name="a"\\r\\n\\r\\n\\r\\n'; + const multipart = Buffer.from(part.repeat(2731) + "--b--\\r\\n"); + for (const grow of [ + () => form.append("a", ""), + () => form.set("b", new Blob([])), + () => FormData.from(multipart, "b"), + ]) { + try { + grow(); + console.log("no error"); + } catch (e) { + console.log(e.name, e.message); + } + } + `, + ], + env: { ...bunEnv, BUN_FEATURE_FLAG_SYNTHETIC_MEMORY_LIMIT: String(64 * 1024) }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "RangeError Out of memory\n".repeat(3), + stderr: "", + exitCode: 0, + }); + }); + it("should throw on bad boundary", async () => { const response = new Response('foo\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbar\r\n', { headers: { diff --git a/test/js/web/html/URLSearchParams.test.ts b/test/js/web/html/URLSearchParams.test.ts index 8ec04b629fda..3204a01ecf4d 100644 --- a/test/js/web/html/URLSearchParams.test.ts +++ b/test/js/web/html/URLSearchParams.test.ts @@ -1,4 +1,6 @@ +import { setSyntheticAllocationLimitForTesting } from "bun:internal-for-testing"; import { describe, expect, it } from "bun:test"; +import { bunEnv, bunExe } from "harness"; describe("URLSearchParams", () => { it("does not crash when calling .toJSON() on a URLSearchParams object with a large number of properties", () => { @@ -305,3 +307,57 @@ it(".has second argument", () => { expect(params.has("b", 3 as any)).toBe(true); expect(params.has("b", 4 as any)).toBe(false); }); + +it("throws for a name or value that is too long to decode", () => { + const long = Buffer.alloc(600_000, "é").toString(); + const ascii = Buffer.alloc(600_000, "a").toString(); + const url = new URL("http://x/?a=1"); + const params = url.searchParams; + // With a 1 MiB limit the decoder is capped at 524288 UTF-8 bytes. + const originalLimit = setSyntheticAllocationLimitForTesting(1024 * 1024); + try { + expect(() => new URLSearchParams(long)).toThrow("Out of memory"); + expect(() => new URLSearchParams("a=1&b=" + long)).toThrow("Out of memory"); + expect(() => new URL("http://x/?" + long).searchParams).toThrow("Out of memory"); + expect(() => { + url.href = "http://x/?" + long; + }).toThrow("Out of memory"); + expect(params.size).toBe(0); + // ASCII without escapes doesn't need decoding. + expect(new URLSearchParams(ascii).size).toBe(1); + } finally { + setSyntheticAllocationLimitForTesting(originalLimit); + } +}); + +it("throws when the pair list can't grow", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + // With a 64 KiB limit the list is capped at 4096 pairs. + const full = Buffer.alloc(8192, "a&").toString(); + const params = new URLSearchParams(full); + for (const grow of [() => params.append("a", ""), () => params.set("b", ""), () => new URLSearchParams(full + "a")]) { + try { + grow(); + console.log("no error"); + } catch (e) { + console.log(e.name, e.message); + } + } + console.log(params.size); + `, + ], + env: { ...bunEnv, BUN_FEATURE_FLAG_SYNTHETIC_MEMORY_LIMIT: String(64 * 1024) }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "RangeError Out of memory\n".repeat(3) + "4096\n", + stderr: "", + exitCode: 0, + }); +}); diff --git a/test/js/web/streams/streams-string-limit.test.ts b/test/js/web/streams/streams-string-limit.test.ts index 956b81189b62..43da50e2c68e 100644 --- a/test/js/web/streams/streams-string-limit.test.ts +++ b/test/js/web/streams/streams-string-limit.test.ts @@ -184,6 +184,23 @@ test.skipIf(!enoughMemory)("arrayBuffer() and bytes() reject mixed chunks summin }); }); +// A 16-bit string can't exceed 2,147,483,635 characters. The consumer rejects longer text before allocating. +test("text() rejects string chunks that sum to a 16-bit string of 2,147,483,636 characters", async () => { + const { stdout, stderr, exitCode } = await run(` + const megabyte = Buffer.alloc(2 ** 20, "x").toString("latin1"); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue("\\u20AC"); + for (let i = 0; i < 2047; i++) controller.enqueue(megabyte); + controller.enqueue(megabyte.slice(0, 2 ** 20 - 13)); + controller.close(); + }, + }); + console.log(await Bun.readableStreamToText(stream).then(text => text.length, e => e.name + ": " + e.message)); + `); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "RangeError: Out of memory\n", stderr: "", exitCode: 0 }); +}); + // TextDecoderStream joins a chunk with the bytes it carried over from an incomplete UTF-8 // sequence. The join sized a WTF::Vector that aborts past 2^31-1 bytes, so a 2^31-byte chunk // after a carried byte killed the process before it read a byte. The child reserves the diff --git a/test/js/web/streams/streams.test.js b/test/js/web/streams/streams.test.js index f9502948efc5..7c36597611a1 100644 --- a/test/js/web/streams/streams.test.js +++ b/test/js/web/streams/streams.test.js @@ -7,9 +7,20 @@ import { readableStreamToText, } from "bun"; import { describe, expect, it, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isDebug, isLinux, isMacOS, isWindows, tempDir, tmpdirSync } from "harness"; +import { + bunEnv, + bunExe, + isASAN, + isDebug, + isLinux, + isMacOS, + isWindows, + libcPathForDlopen, + tempDir, + tmpdirSync, +} from "harness"; import { mkfifo } from "mkfifo"; -import { closeSync, createReadStream, openSync, realpathSync, unlinkSync, writeFileSync } from "node:fs"; +import { closeSync, createReadStream, openSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { Duplex, PassThrough, Readable, Writable, finished, pipeline } from "node:stream"; import { @@ -317,6 +328,121 @@ describe("ReadableStream.prototype.tee", () => { }); }); +// Repeating tee() on a branch or textStream() on a text stream builds a chain of streams that call each other natively. +describe("a deep chain of natively linked streams doesn't overflow the stack", () => { + // Gives every link a tick to start, do its initial pull, and queue a read on its parent. + const settle = `await new Promise(resolve => setImmediate(resolve));`; + + // Each returns code that adds one link to the chain. `tee` also runs `side` on `b`, its second branch. + const tee = side => `const [a, b] = stream.tee(); ${side} stream = a;`; + const textStream = () => `stream = new Response(stream).textStream();`; + const kinds = { + "tee()": { type: undefined, link: tee }, + "tee() of a byte stream": { type: "bytes", link: tee }, + "Response.textStream()": { type: undefined, link: textStream }, + }; + + // Deep enough to overflow the native stack. About 29,000 links fill a release build's 8 MB main thread stack, + // and about 3,000 fill a debug build's. macOS and Windows have 18 MB. + const depth = (isDebug ? 4_000 : isASAN ? 30_000 : 50_000) * (isMacOS || isWindows ? 2 : 1); + + // Reads from the end of the chain and, for a tee, from four second branches along the way. + const settleAllScript = ({ type, link }, act) => ` + let controller; + let stream = new ReadableStream({ type: ${JSON.stringify(type)}, start(c) { controller = c; } }); + const ends = []; + for (let i = 0; i < ${depth}; i++) { ${link(`if (i % ${depth / 4} === 0) ends.push(b);`)} } + ends.push(stream); + const reads = ends.map(end => end.getReader().read().then(result => result, error => error.message)); + ${settle} + controller.${act}; + console.log(JSON.stringify(await Promise.all(reads))); + `; + + // A tee cancels its source only after both branches are canceled, so cancel every second branch too. + // Each tee wraps the reason from the link below it: [[["a", "b"], "b"], "b"] and so on. + const cancelScript = ({ type, link }) => ` + let reason; + let stream = new ReadableStream({ type: ${JSON.stringify(type)}, cancel(r) { reason = r; } }); + for (let i = 0; i < ${depth}; i++) { ${link(`b.cancel("b");`)} } + ${settle} + await stream.cancel("a"); + let nesting = 0; + for (; Array.isArray(reason); reason = reason[0]) nesting++; + console.log(JSON.stringify({ nesting, innermost: reason })); + `; + + // The source closes in its first pull. + const readScript = ({ type, link }, readerOptions, view) => ` + let pulls = 0; + let stream = new ReadableStream( + { type: ${JSON.stringify(type)}, pull(c) { pulls++; c.close(); c.byobRequest?.respond(0); } }, + { highWaterMark: 0 }, + ); + for (let i = 0; i < ${depth}; i++) { ${link("")} } + ${settle} + const { done } = await stream.getReader(${readerOptions}).read(${view}); + console.log(JSON.stringify({ pulls, done })); + `; + + const cases = []; + for (const [name, kind] of Object.entries(kinds)) { + const ends = kind.link === tee ? 5 : 1; + cases.push( + [ + `${name} ${depth} deep when the source closes`, + settleAllScript(kind, `close()`), + Array(ends).fill({ done: true }), + ], + [ + `${name} ${depth} deep when the end is canceled`, + cancelScript(kind), + { nesting: kind.link === tee ? depth : 0, innermost: "a" }, + ], + ); + } + // A tee forwards an error through reader.closed, one microtask per link, so it doesn't recurse. + cases.push([ + `Response.textStream() ${depth} deep when the source errors`, + settleAllScript(kinds["Response.textStream()"], `error(new Error("boom"))`), + ["boom"], + ]); + const pulled = { pulls: 1, done: true }; + cases.push( + [ + `tee() of a byte stream ${depth} deep when the end is read`, + readScript(kinds["tee() of a byte stream"], "", ""), + pulled, + ], + [ + `tee() of a byte stream ${depth} deep when the end is read into a view`, + readScript(kinds["tee() of a byte stream"], `{ mode: "byob" }`, "new Uint8Array(4)"), + pulled, + ], + [ + `Response.textStream() ${depth} deep when the end is read`, + readScript(kinds["Response.textStream()"], "", ""), + pulled, + ], + ); + + test.concurrent.each(cases)("%s", async (_name, script, expected) => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode, signalCode: proc.signalCode }).toEqual({ + stdout: JSON.stringify(expected) + "\n", + stderr: "", + exitCode: 0, + signalCode: null, + }); + }); +}); + it("ReadableStream.prototype[Symbol.asyncIterator]", async () => { const stream = new ReadableStream({ start(controller) { @@ -1895,6 +2021,23 @@ describe("multi-chunk consumers produce exactly the concatenated bytes", () => { await expect(new Response(source([42])).text()).rejects.toThrow(expect.objectContaining({ name: "TypeError" })); }); + const unresolvedRopes = () => { + const part = Buffer.alloc(40, "p").toString(); + return [1, 2, 3].map(i => part + i + (i === 2 ? "\u20AC" : "\u00E9") + part); + }; + it.each([ + ["Latin-1 chunks", () => ["caf\u00E9", "", " au lait"], "caf\u00E9 au lait"], + ["a 16-bit chunk after Latin-1 chunks", () => ["abc", "d\u00E9", "\u20AC"], "abcd\u00E9\u20AC"], + ["a Latin-1 chunk after a 16-bit chunk", () => ["\u4F60\u597D", "abc"], "\u4F60\u597Dabc"], + ["only empty chunks", () => ["", ""], ""], + ["rope chunks", unresolvedRopes, unresolvedRopes().join("")], + ["two BOMs before the text", () => ["\uFEFF", "\uFEFFabc"], "abc"], + ["three BOMs before the text", () => ["\uFEFF\uFEFF", "\uFEFFabc"], "\uFEFFabc"], + ])("text: joins string chunks in order (%s)", async (_name, chunks, text) => { + expect(await Bun.readableStreamToText(source(chunks()))).toBe(text); + expect(await new Response(source(chunks())).text()).toBe(text); + }); + it("a detached chunk throws", () => { const chunk = new Uint8Array([1, 2, 3]); structuredClone(chunk.buffer, { transfer: [chunk.buffer] }); @@ -2783,6 +2926,38 @@ describe.skipIf(isWindows)("Bun.file().stream() surfaces read() errors", () => { }); }); +// Each read returns at most 256 KiB, so the first read doesn't drain everything the closed peer sent. +it.skipIf(!isLinux)( + "Bun.write(file, stream) writes every byte when the peer hung up with more than one read's worth buffered", + async () => { + const { dlopen, ptr } = require("bun:ffi"); + const libc = dlopen(libcPathForDlopen(), { + socketpair: { args: ["i32", "i32", "i32", "ptr"], returns: "i32" }, + setsockopt: { args: ["i32", "i32", "i32", "ptr", "u32"], returns: "i32" }, + send: { args: ["i32", "ptr", "usize", "i32"], returns: "i64" }, + }).symbols; + const [AF_UNIX, SOCK_STREAM, SOL_SOCKET, SO_SNDBUF, MSG_DONTWAIT] = [1, 1, 1, 7, 0x40]; + const fds = new Int32Array(2); + expect(libc.socketpair(AF_UNIX, SOCK_STREAM, 0, ptr(fds))).toBe(0); + const [source, peer] = fds; + using dir = tempDir("native-sink-hung-up", {}); + const out = join(String(dir), "out.bin"); + const body = Buffer.alloc(270_000, "0123456789abcdefghijklmnopqrstuvwxyz"); + try { + libc.setsockopt(peer, SOL_SOCKET, SO_SNDBUF, ptr(new Int32Array([212_992])), 4); + expect(Number(libc.send(peer, ptr(body), body.length, MSG_DONTWAIT))).toBe(body.length); + } finally { + closeSync(peer); + } + try { + expect(await Bun.write(out, Bun.file(source).stream())).toBe(body.length); + } finally { + closeSync(source); + } + expect(readFileSync(out).equals(body)).toBe(true); + }, +); + it("fs.createReadStream(filename) should be able to break inside async loop", async () => { for (let i = 0; i < 10; i++) { const fileStream = createReadStream(join(import.meta.dir, "..", "fetch", "fixture.png")); @@ -3633,6 +3808,90 @@ describe("script-sized stream containers throw when they cannot grow", () => { }); }); }); +// The child runs with a 64 KiB synthetic allocation limit, so text can't be longer than 64 KiB. +describe("a direct stream's text sink rejects a write that would make the text too long", () => { + const LIMIT_BYTES = 64 * 1024; + const CHUNK = LIMIT_BYTES / 4; + const runInSubprocess = async source => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const describeError = e => e.name + ": " + e.message; + const chunk = letter => Buffer.alloc(${CHUNK}, letter).toString(); + const settle = promise => promise.then(text => ({ length: text.length }), e => ({ rejected: describeError(e) })); + ${source}`, + ], + env: { ...bunEnv, BUN_FEATURE_FLAG_SYNTHETIC_MEMORY_LIMIT: String(LIMIT_BYTES) }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout: JSON.parse(stdout || "null"), stderr, exitCode }; + }; + + test.concurrent("pull() can catch the error, and the text written so far is kept", async () => { + const result = await runInSubprocess(` + let refused; + const stream = new ReadableStream({ + type: "direct", + pull(controller) { + for (const letter of "abc") controller.write(chunk(letter)); + try { + controller.write(chunk("z") + chunk("z")); + } catch (e) { + refused = describeError(e); + } + controller.end(); + }, + }); + console.log(JSON.stringify({ ...(await settle(stream.text())), refused })); + `); + expect(result).toEqual({ + stdout: { length: 3 * CHUNK, refused: "RangeError: Out of memory" }, + stderr: "", + exitCode: 0, + }); + }); + + test.concurrent("an error that pull() doesn't catch rejects the consumer", async () => { + const result = await runInSubprocess(` + const stream = new ReadableStream({ + type: "direct", + pull(controller) { + for (const letter of "abc") controller.write(chunk(letter)); + controller.write(chunk("z") + chunk("z")); + }, + }); + console.log(JSON.stringify(await settle(stream.text()))); + `); + expect(result).toEqual({ stdout: { rejected: "RangeError: Out of memory" }, stderr: "", exitCode: 0 }); + }); + + test.concurrent("a stream that errors with its sink over the limit rejects with its own error", async () => { + const result = await runInSubprocess(` + const bytes = new Uint8Array(${CHUNK}).fill(97); + let closedWith = "close() not called"; + const stream = new ReadableStream({ + type: "direct", + pull(controller) { + for (let i = 0; i < 6; i++) controller.write(bytes); + controller.error(new Error("boom")); + }, + close(reason) { + closedWith = describeError(reason); + }, + }); + console.log(JSON.stringify({ ...(await settle(Bun.readableStreamToText(stream))), closedWith })); + `); + expect(result).toEqual({ + stdout: { rejected: "Error: boom", closedWith: "Error: boom" }, + stderr: "", + exitCode: 0, + }); + }); +}); // A source pull() that runs inside the pipe's in-place drain and synchronously errors the // destination and aborts the pipe's signal must not touch the released writer afterwards. @@ -5236,3 +5495,49 @@ describe("direct stream edge cases", () => { }); }); }); + +// node:vm's `timeout` stops a run with a termination request, and the process keeps running. Each loop spins +// on one operation under a timeout, so some rounds terminate in the middle of it. +describe.concurrent("a node:vm timeout inside a streams operation", () => { + test.each([ + [ + "getWriter() on an erroring stream", + `for (;;) { + let controller; + const stream = new WritableStream({ start(c) { controller = c; } }); + controller.error(new Error("boom")); + stream.getWriter(); + }`, + ], + ["a writer's releaseLock()", `for (;;) new WritableStream({}).getWriter().releaseLock();`], + ])("doesn't crash during %s", async (_name, loop) => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const vm = require("node:vm"); + globalThis.spin = () => { ${loop} }; + let timeouts = 0; + for (let i = 0; i < 100; i++) { + try { + vm.runInThisContext("spin()", { timeout: 2 }); + } catch (e) { + if (e?.code !== "ERR_SCRIPT_EXECUTION_TIMEOUT") throw e; + timeouts++; + } + await new Promise(resolve => setImmediate(resolve)); + await new Promise(resolve => setImmediate(resolve)); + } + console.log("timeouts", timeouts); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr }).toEqual({ stdout: "timeouts 100\n", stderr: "" }); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/web/structured-clone-blob-file.test.ts b/test/js/web/structured-clone-blob-file.test.ts index e69a3a146abf..34add29b2e1f 100644 --- a/test/js/web/structured-clone-blob-file.test.ts +++ b/test/js/web/structured-clone-blob-file.test.ts @@ -1,6 +1,7 @@ import { deserialize, serialize } from "bun:jsc"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isWindows, rss } from "harness"; +import { bunEnv, bunExe, isASAN, isWindows, rss, tempDir } from "harness"; +import { join } from "node:path"; import v8 from "node:v8"; describe("structuredClone with Blob and File", () => { @@ -522,6 +523,74 @@ describe("structuredClone with Blob and File", () => { }); }); + test("a file-backed Blob sized past MAX_SIZE deserializes with unknown size and can be sliced", async () => { + using dir = tempDir("blob-deser-size", { "probe.txt": "0123456789abcdefghij" }); + const path = join(String(dir), "probe.txt"); + // Plant a distinctive slice end so the u64 size field can be located + // in whatever framing the current build emits, then overwrite it. + const sentinel = 0x0badf00d1234; + const good = Buffer.from(serialize(Bun.file(path).slice(0, sentinel))); + const needle = Buffer.alloc(8); + needle.writeBigUInt64LE(BigInt(sentinel)); + const at = good.indexOf(needle); + expect(at).toBeGreaterThan(0); + const bad = Buffer.from(good); + bad.writeBigUInt64LE(1n << 63n, at); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { deserialize } = require("bun:jsc"); + const v8 = require("node:v8"); + const bad = Buffer.from(process.argv[1], "base64"); + for (const de of [deserialize, b => v8.deserialize(b)]) { + const blob = de(bad); + const head = await blob.slice(0, 10).text(); + process.stdout.write(JSON.stringify({ head, size: blob.size }) + "\\n"); + } + `, + bad.toString("base64"), + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const lines = stdout + .split("\n") + .filter(Boolean) + .map(l => JSON.parse(l)); + expect({ lines, stderr, exitCode, signalCode: proc.signalCode }).toEqual({ + lines: [ + { head: "0123456789", size: 20 }, + { head: "0123456789", size: 20 }, + ], + stderr: "", + exitCode: 0, + signalCode: null, + }); + }); + + test("file-backed Blob path longer than PATH_MAX is rejected at deserialize", () => { + const probe = Buffer.alloc(16, 0x5a); + const good = Buffer.from(serialize(Bun.file(probe.toString("latin1")))); + const at = good.indexOf(probe); + expect(at).toBeGreaterThan(4); + expect(good.readUInt32LE(at - 4)).toBe(probe.length); + + // Longer than MAX_PATH_BYTES on every platform (Windows is ~96 KiB). + const long = Buffer.concat([Buffer.from("/"), Buffer.alloc(100_000, "a")]); + const len = Buffer.alloc(4); + len.writeUInt32LE(long.length); + const bad = Buffer.concat([good.subarray(0, at - 4), len, long, good.subarray(at + probe.length)]); + + expect(() => deserialize(bad)).toThrow("Unable to deserialize data."); + expect(() => v8.deserialize(bad)).toThrow("Unable to deserialize data."); + }); + test("in-process: offset at store boundary yields empty view", async () => { // offset == store length stays within the allocation on any build, so // this is safe to assert in-process and covers the boundary directly. diff --git a/test/js/web/structured-clone-shared-array-buffer.test.ts b/test/js/web/structured-clone-shared-array-buffer.test.ts index 0fe1e595957c..51c9958afc8e 100644 --- a/test/js/web/structured-clone-shared-array-buffer.test.ts +++ b/test/js/web/structured-clone-shared-array-buffer.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { isASAN } from "harness"; // structuredClone stays inside the agent cluster, so SharedArrayBuffers share // their backing store like node and the HTML spec's StructuredSerialize. @@ -49,3 +50,21 @@ describe("structuredClone(SharedArrayBuffer)", () => { } }); }); + +test.skipIf(isASAN /* WebAssembly.Memory ignores `shared` */)( + "cloning a shared WebAssembly.Memory with a maximum of zero keeps it zero-sized and shared", + async () => { + const zero = () => new WebAssembly.Memory({ initial: 0, maximum: 0, shared: true }); + const { port1, port2 } = new MessageChannel(); + const { promise, resolve } = Promise.withResolvers<WebAssembly.Memory>(); + port2.onmessage = event => resolve(event.data); + port1.postMessage(zero()); + for (const memory of [structuredClone(zero()), await promise]) { + expect(memory).toBeInstanceOf(WebAssembly.Memory); + expect(memory.buffer).toBeInstanceOf(SharedArrayBuffer); + expect(memory.buffer.byteLength).toBe(0); + } + port1.close(); + port2.close(); + }, +); diff --git a/test/js/web/timers/performance-entries.test.ts b/test/js/web/timers/performance-entries.test.ts index 1a6ebaf540d5..d1936f6ad9a2 100644 --- a/test/js/web/timers/performance-entries.test.ts +++ b/test/js/web/timers/performance-entries.test.ts @@ -15,3 +15,38 @@ test("memory usage of Performance", () => { expect(final2).toBeGreaterThan(final); expect(final).toBeGreaterThan(initial); }); + +test("getEntries() omits entries removed by clearMarks(name) and clearMeasures(name)", () => { + performance.clearMarks(); + performance.clearMeasures(); + + performance.mark("kept-mark"); + performance.mark("dropped-mark"); + performance.mark("dropped-mark"); + performance.clearMarks("dropped-mark"); + performance.clearMarks("missing-mark"); + + performance.measure("kept-measure"); + performance.measure("dropped-measure"); + performance.measure("dropped-measure"); + performance.clearMeasures("dropped-measure"); + performance.clearMeasures("missing-measure"); + + const names = (entries: PerformanceEntryList) => entries.map(entry => `${entry.entryType}:${entry.name}`).sort(); + expect(names(performance.getEntries())).toEqual(["mark:kept-mark", "measure:kept-measure"]); + expect(names(performance.getEntriesByType("mark"))).toEqual(["mark:kept-mark"]); + expect(names(performance.getEntriesByType("measure"))).toEqual(["measure:kept-measure"]); + + performance.mark("kept-mark"); + performance.measure("kept-measure"); + expect(names(performance.getEntries())).toEqual([ + "mark:kept-mark", + "mark:kept-mark", + "measure:kept-measure", + "measure:kept-measure", + ]); + + performance.clearMarks(); + performance.clearMeasures(); + expect(performance.getEntries()).toEqual([]); +}); diff --git a/test/js/web/timers/setTimeout.test.js b/test/js/web/timers/setTimeout.test.js index 48b15c418225..8d130d38728f 100644 --- a/test/js/web/timers/setTimeout.test.js +++ b/test/js/web/timers/setTimeout.test.js @@ -697,6 +697,77 @@ it("clearTimeout with a numeric id is a no-op after a timeout promoted to an int expect(exitCode).toBe(0); }); +it.concurrent("timer ids past 2^31 and 2^32 are positive and unique", async () => { + const src = ` + import { timerInternals } from "bun:internal-for-testing"; + const result = {}; + + { + timerInternals.setNextTimerId(2 ** 31 - 1); + const timers = [0, 1, 2].map(() => setTimeout(() => {}, 1e6)); + const ids = timers.map(Number); + clearTimeout(String(ids[0])); + clearTimeout(String(ids[1])); + clearTimeout(ids[2]); + result.pastInt32 = { ids, destroyed: timers.map(t => t._destroyed) }; + } + + { + const first = setInterval(() => {}, 1e6); + const id = +first; + timerInternals.setNextTimerId(2 ** 32 + id); + const second = setInterval(() => {}, 1e6); + const idDistance = +second - id; + clearInterval(id); + result.pastUint32 = { idDistance, destroyed: [first._destroyed, second._destroyed] }; + clearInterval(second); + } + + // A timeout that _repeat turned into an interval is still tracked in the timeout id map. + { + let other; + const id = await (async () => { + let fires = 0; + const { promise: secondFire, resolve } = Promise.withResolvers(); + const timer = setTimeout(() => { + if (++fires === 2) resolve(); + }, 1); + const id = +timer; + timer._repeat = 1; + await secondFire; + timerInternals.setNextTimerId(2 ** 32 + id); + other = setInterval(() => {}, 1e6); + +other; + clearInterval(timer); + return id; + })(); + for (let i = 0; i < 3; i++) { + Bun.gc(true); + await new Promise(resolve => setImmediate(resolve)); + } + clearTimeout(id); + clearInterval(id); + result.promotedTimeout = { otherIsAlive: !other._destroyed }; + clearInterval(other); + } + + console.log(JSON.stringify(result)); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(JSON.parse(stdout)).toEqual({ + pastInt32: { ids: [2147483647, 2147483648, 2147483649], destroyed: [true, true, true] }, + pastUint32: { idDistance: 2 ** 32, destroyed: [true, false] }, + promotedTimeout: { otherIsAlive: true }, + }); + expect(exitCode).toBe(0); +}); + it("setTimeout(1) is not quantized to the ~15.6ms Windows system tick", async () => { // Subprocess so no other in-process work has raised the Windows tick // resolution; median of 50 so a single scheduler hiccup on a busy CI diff --git a/test/js/web/urlpattern/urlpattern.test.ts b/test/js/web/urlpattern/urlpattern.test.ts index 599d39bb9e5f..cd6052e003a9 100644 --- a/test/js/web/urlpattern/urlpattern.test.ts +++ b/test/js/web/urlpattern/urlpattern.test.ts @@ -1,6 +1,8 @@ // Test data from Web Platform Tests // https://github.com/web-platform-tests/wpt/blob/master/LICENSE.md +import { setSyntheticAllocationLimitForTesting } from "bun:internal-for-testing"; import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; import testData from "./urlpatterntestdata.json"; const kComponents = ["protocol", "username", "password", "hostname", "port", "pathname", "search", "hash"] as const; @@ -206,4 +208,58 @@ describe("URLPattern", () => { expect(new URLPattern({ pathname: "/a/:foo/:baz([a-z]+)?/b/*" }).hasRegExpGroups).toBe(true); }); }); + + // The tokenizer emits one token per code point, and the synthetic allocation limit caps the token list. + test("throws for a pattern with more tokens than the token list allows", () => { + const dots = (length: number) => Buffer.alloc(length, ".").toString(); + const message = "URLPattern constructor: Failed to create URLPattern (from input string)"; + const originalLimit = setSyntheticAllocationLimitForTesting(1024 * 1024); + try { + expect(() => new URLPattern({ pathname: dots(64 * 1024) })).toThrow(message); + expect(() => new URLPattern("https://example.com/" + dots(64 * 1024))).toThrow(message); + expect(new URLPattern({ pathname: dots(8 * 1024) }).pathname).toBe(dots(8 * 1024)); + } finally { + setSyntheticAllocationLimitForTesting(originalLimit); + } + }); + + test("throws when a generated string exceeds the string length limit", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const long = "x".repeat(64 * 1024); + const overHalf = "x".repeat(32 * 1024 + 16); + const any = new URLPattern({}); + for (const init of [ + // The regular expression contains the group's value twice. + { pathname: "{a(" + overHalf + ")b}*" }, + // The pattern string contains the group's name, but the regular expression doesn't. + { pathname: ":" + long }, + // Escaping doubles each of these base URL characters. + { baseURL: "https://e.com/" + overHalf.replaceAll("x", "(") }, + ]) { + try { + new URLPattern(init); + console.log("no error"); + } catch (e) { + console.log(e.name, e.message); + } + } + // An input that can't be processed doesn't match. + console.log(any.test({ protocol: long }), any.test({ pathname: long }), any.test({ protocol: "data", pathname: long })); + `, + ], + env: { ...bunEnv, BUN_FEATURE_FLAG_SYNTHETIC_MEMORY_LIMIT: String(64 * 1024) }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "RangeError Out of memory\n".repeat(3) + "false false false\n", + stderr: "", + exitCode: 0, + }); + }); }); diff --git a/test/js/web/web-globals.test.js b/test/js/web/web-globals.test.js index bf7051f18578..d83ba557f3f4 100644 --- a/test/js/web/web-globals.test.js +++ b/test/js/web/web-globals.test.js @@ -419,6 +419,31 @@ test("confirm (no) windows newline", async () => { expect(await proc.stderr.text()).toBe("No\n"); }); +// Only the CRLF terminator's `\r` is stripped. An earlier `\r` is part of the answer. +test.concurrent.each([ + ["hello\r\n", "hello"], + ["x\r\r\n", "x\r"], + ["\r\r\n", "\r"], +])("prompt with stdin %j returns %j", async (stdin, expected) => { + await using proc = spawn({ + cmd: [bunExe(), "-e", `console.error(JSON.stringify(prompt("Q?")));`], + stdio: ["pipe", "pipe", "pipe"], + env: bunEnv, + }); + + proc.stdin.write(stdin); + await proc.stdin.flush(); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "Q? ", + stderr: JSON.stringify(expected) + "\n", + exitCode: 0, + }); +}); + test("globalThis.self = 123 works", () => { expect(Object.getOwnPropertyDescriptor(globalThis, "self")).toMatchObject({ configurable: true, @@ -539,3 +564,23 @@ describe("new.target.prototype", () => { expect(await response.text()).toBe("hello"); }); }); + +test("dispatchEvent skips a listener that is a WebAssembly GC object", () => { + // (module (type $s (struct (field (mut i32)))) + // (func (export "mk") (result (ref null $s)) struct.new_default $s)) + // prettier-ignore + const bytes = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + 0x01, 0x0a, 0x02, 0x5f, 0x01, 0x7f, 0x01, 0x60, 0x00, 0x01, 0x63, 0x00, + 0x03, 0x02, 0x01, 0x01, + 0x07, 0x06, 0x01, 0x02, 0x6d, 0x6b, 0x00, 0x00, + 0x0a, 0x07, 0x01, 0x05, 0x00, 0xfb, 0x01, 0x00, 0x0b, + ]); + const listener = new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports.mk(); + const target = new EventTarget(); + let calls = 0; + target.addEventListener("a", listener); + target.addEventListener("a", () => calls++); + target.dispatchEvent(new Event("a")); + expect(calls).toBe(1); +}); diff --git a/test/js/web/workers/message-port-context-destroy-leak.test.ts b/test/js/web/workers/message-port-context-destroy-leak.test.ts index baf659e69210..851c3939e63e 100644 --- a/test/js/web/workers/message-port-context-destroy-leak.test.ts +++ b/test/js/web/workers/message-port-context-destroy-leak.test.ts @@ -79,3 +79,34 @@ test.skipIf(isWindows)( }, 120_000, ); + +// Once the wrapper and the peer are collected, .ref()'s self-reference is the last reference to +// the native port, and close() drops it partway through. +// Malloc=1 routes bmalloc to the system allocator so ASAN can catch a read of the freed port. +test.skipIf(!isASAN)("closing a ref()'d port during context destruction doesn't free it mid-close", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { heapStats } = require("bun:jsc"); + const globals = () => heapStats().objectTypeCounts.GlobalObject; + const baseline = globals(); + let realm = new ShadowRealm(); + realm.evaluate("(() => { const { port1 } = new MessageChannel(); port1.ref(); })()"); + // Collect the wrapper and the peer while the realm is still alive. + for (let i = 0; i < 5; i++) Bun.gc(true); + realm = null; + Bun.gc(true); + console.log(globals() === baseline ? "PASS" : "the realm's global was never collected");`, + ], + env: { ...bunEnv, ...(isWindows ? {} : { Malloc: "1" }) }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout).toBe("PASS\n"); + expect(exitCode).toBe(0); +}); diff --git a/test/js/web/workers/structured-clone.test.ts b/test/js/web/workers/structured-clone.test.ts index 44682487d80b..d0f3f72e53b7 100644 --- a/test/js/web/workers/structured-clone.test.ts +++ b/test/js/web/workers/structured-clone.test.ts @@ -5,6 +5,7 @@ import { createPrivateKey, createPublicKey, createSecretKey, KeyObject, X509Cert import { BlockList } from "node:net"; import type { ReadableStreamDefaultReader } from "node:stream/web"; import { deserialize as v8Deserialize } from "node:v8"; +import { receiveMessageOnPort } from "node:worker_threads"; import { deflate } from "node:zlib"; import { join } from "path"; @@ -856,6 +857,100 @@ describe("deserializing a version 13 payload", () => { }); }); +async function describeKey(key: CryptoKey) { + return { + type: key.type, + algorithm: key.algorithm, + usages: key.usages, + extractable: key.extractable, + material: Buffer.from(await crypto.subtle.exportKey(key.type === "public" ? "spki" : "pkcs8", key)).toString("hex"), + }; +} + +test("receiveMessageOnPort returns an X25519 CryptoKey", async () => { + const { privateKey } = (await crypto.subtle.generateKey("X25519", true, ["deriveBits"])) as CryptoKeyPair; + const { port1, port2 } = new MessageChannel(); + port1.postMessage(privateKey); + expect(await describeKey(receiveMessageOnPort(port2)!.message)).toEqual(await describeKey(privateKey)); + port1.close(); +}); + +// An OKP CryptoKey record has these fields, in order: +// format version, CryptoKeyTag, record length, format version, key format, extractable, +// usage count, usage tags, OKP, algorithm, curve, [key type], key size, key bytes. +// Only an X25519 record has the key type. +describe("X25519 CryptoKey records", () => { + const compact = (spaced: string) => spaced.replaceAll(" ", ""); + const read = (spaced: string) => deserialize(Buffer.from(compact(spaced), "hex")); + const written = (key: CryptoKey) => Buffer.from(serialize(key)).toString("hex"); + + // The X25519 key pair from Node's test-webcrypto-derivebits-cfrg.js. + const x25519Private = "c8838e76d057dfb7d8c95a69e138160add6373fd71a4d276bb56e3a81b64ff61"; + const x25519Public = "1cf2b1e6022ec537371ed7f53e54fa1154d83e98eb64ea51fae5b3307cfe9706"; + const importX25519 = () => + Promise.all([ + crypto.subtle.importKey( + "pkcs8", + Buffer.from(`302e020100300506032b656e04220420${x25519Private}`, "hex"), + "X25519", + true, + ["deriveBits"], + ), + crypto.subtle.importKey( + "spki", + Buffer.from(`302a300506032b656e032100${x25519Public}`, "hex"), + "X25519", + true, + [], + ), + ]); + + test("an X25519 record includes the key type", async () => { + const [privateKey, publicKey] = await importX25519(); + const privateRecord = `0e000000 21 39000000 0e000000 02000000 01000000 01000000 05 05 17 00 01 20000000 ${x25519Private}`; + const publicRecord = `0e000000 21 38000000 0e000000 02000000 01000000 00000000 05 17 00 00 20000000 ${x25519Public}`; + + expect(written(privateKey)).toBe(compact(privateRecord)); + expect(written(publicKey)).toBe(compact(publicRecord)); + expect(await describeKey(read(privateRecord))).toEqual(await describeKey(privateKey)); + expect(await describeKey(read(publicRecord))).toEqual(await describeKey(publicKey)); + }); + + // No release could ever read these records. + test("rejects the X25519 records without a key type that Bun 1.4.3 wrote", () => { + const privateRecord = `0e000000 21 38000000 0e000000 02000000 01000000 01000000 05 05 17 00 20000000 ${x25519Private}`; + const publicRecord = `0e000000 21 37000000 0e000000 02000000 01000000 00000000 05 17 00 20000000 ${x25519Public}`; + + expect(() => read(privateRecord)).toThrow(new TypeError("Unable to deserialize data.")); + expect(() => read(publicRecord)).toThrow(new TypeError("Unable to deserialize data.")); + }); + + test.each([ + [ + "an X25519 key type that isn't public or private", + `0e000000 21 38000000 0e000000 02000000 01000000 00000000 05 17 00 02 20000000 ${x25519Public}`, + ], + [ + "a 31-byte X25519 key", + `0e000000 21 37000000 0e000000 02000000 01000000 00000000 05 17 00 00 1f000000 ${x25519Public.slice(2)}`, + ], + [ + "the X25519 algorithm with the Ed25519 curve", + `0e000000 21 37000000 0e000000 02000000 01000000 00000000 05 17 01 20000000 ${x25519Public}`, + ], + [ + "the Ed25519 algorithm with the X25519 curve", + `0e000000 21 38000000 0e000000 02000000 01000000 00000000 05 16 00 00 20000000 ${x25519Public}`, + ], + [ + "the AES-GCM algorithm in an OKP record", + `0e000000 21 38000000 0e000000 02000000 01000000 00000000 05 09 00 00 20000000 ${x25519Public}`, + ], + ])("%s is rejected", (_what, malformed) => { + expect(() => read(malformed)).toThrow(new TypeError("Unable to deserialize data.")); + }); +}); + // https://github.com/oven-sh/bun/issues/32981 // %Object.prototype% is an immutable prototype exotic object that the structured // serialization spec carves out of the exotic-object rejection, so it clones to diff --git a/test/js/web/workers/worker-late-completion-napi-fixture.c b/test/js/web/workers/worker-late-completion-napi-fixture.c new file mode 100644 index 000000000000..da4482c72f1a --- /dev/null +++ b/test/js/web/workers/worker-late-completion-napi-fixture.c @@ -0,0 +1,38 @@ +// An experimental addon runs its finalizers inline during garbage collection, +// and napi_queue_async_work is one of the functions a finalizer may call. +#define NAPI_EXPERIMENTAL +#include <node_api.h> +#include <stdio.h> + +static napi_async_work work; + +static void execute(napi_env env, void *data) {} + +static void complete(napi_env env, napi_status status, void *data) { + napi_delete_async_work(env, work); +} + +static void finalize(node_api_basic_env env, void *data, void *hint) { + napi_status status = napi_queue_async_work(env, work); + printf("queued from finalizer: %d\n", (int)status); + fflush(stdout); + if (status != napi_ok) { + napi_delete_async_work((napi_env)env, work); + } +} + +// napi_create_async_work isn't allowed in a finalizer, so create the work here. +static napi_value queue_from_finalizer(napi_env env, napi_callback_info info) { + napi_value name, external; + napi_create_string_utf8(env, "late", NAPI_AUTO_LENGTH, &name); + napi_create_async_work(env, NULL, name, execute, complete, NULL, &work); + napi_create_external(env, NULL, finalize, NULL, &external); + return external; +} + +NAPI_MODULE_INIT() { + napi_value function; + napi_create_function(env, "queueFromFinalizer", NAPI_AUTO_LENGTH, queue_from_finalizer, NULL, &function); + napi_set_named_property(env, exports, "queueFromFinalizer", function); + return exports; +} diff --git a/test/js/web/workers/worker-late-completion.test.ts b/test/js/web/workers/worker-late-completion.test.ts index e2684d0c089c..c7b032a1c095 100644 --- a/test/js/web/workers/worker-late-completion.test.ts +++ b/test/js/web/workers/worker-late-completion.test.ts @@ -17,7 +17,8 @@ // release paths are also checked for use-after-free and leaks. Builds with // debug assertions only (debug, ASAN): the gate does not exist in release. import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isAndroid, isASAN, isDebug, isLinux, isWindows, tempDir } from "harness"; +import { bunEnv, bunExe, isAndroid, isASAN, isDebug, isLinux, isMacOS, isWindows, tempDir } from "harness"; +import { mkfifo } from "mkfifo"; import fs from "node:fs"; import path from "node:path"; import type { ReadableStreamDefaultReader } from "node:stream/web"; @@ -269,6 +270,45 @@ describe.skipIf(!isDebug && !isASAN)("work that comes back after its worker bega } }); +const cc = isWindows ? null : process.env.CC || Bun.which("cc") || Bun.which("gcc") || Bun.which("clang"); +test.concurrent.skipIf(!cc)("napi_queue_async_work fails in a finalizer during a worker's final GC", async () => { + using dir = tempDir("worker-late-napi", {}); + const addon = path.join(String(dir), "addon.node"); + await using build = Bun.spawn({ + cmd: [ + cc!, + "-shared", + "-fPIC", + ...(isMacOS ? ["-undefined", "dynamic_lookup"] : []), + `-I${path.resolve(import.meta.dir, "../../../../src/runtime/napi")}`, + path.join(import.meta.dir, "worker-late-completion-napi-fixture.c"), + "-o", + addon, + ], + env: bunEnv, + stderr: "inherit", + }); + expect(await build.exited).toBe(0); + // The worker keeps the external reachable, so it's only finalized when the worker's heap is destroyed. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `new (require("node:worker_threads").Worker)( + "globalThis.keep = require(" + JSON.stringify(process.argv[1]) + ").queueFromFinalizer();", + { eval: true }, + );`, + addon, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // 23 is napi_cannot_run_js. + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "queued from finalizer: 23\n", stderr: "", exitCode: 0 }); +}); + // A `Bun.file()` / `Bun.stdin` read of a pipe or tty that has no data yet // parks on the io loop instead of blocking a pool thread, so terminate() can // and does cancel it: the worker goes away promptly and the read never settles. @@ -416,3 +456,73 @@ describe.skipIf(isWindows)("terminate() waits for work that cannot be cancelled" expect(await proc.exited).toBe(0); }); }); + +// The upload has a ref on the fetch. Destroying the heap drops it for a JS-pumped body, but the stop phase has to +// drop it for a body streamed from a native source. Otherwise LeakSanitizer reports the leaked request. +describe.skipIf(!isASAN || isWindows)("terminating a worker while a request body is still uploading", () => { + const host = (body: string, fifo: string) => ` + const { Worker } = require("node:worker_threads"); + const fs = require("node:fs"); + const fifo = ${JSON.stringify(fifo)}; + const fifoFd = fs.openSync(fifo, "r+"); + fs.writeSync(fifoFd, Buffer.alloc(64, 1)); + const firstChunk = Promise.withResolvers(); + const target = Bun.serve({ + port: 0, + async fetch(req) { + await req.body.getReader().read(); + firstChunk.resolve(); + return new Promise(() => {}); + }, + }); + const upstream = Bun.serve({ + port: 0, + fetch: () => new Response(new ReadableStream({ + start(c) { c.enqueue(new Uint8Array(1024)); }, + pull() { return new Promise(() => {}); }, + })), + }); + const worker = new Worker( + 'const { workerData } = require("node:worker_threads");' + + '(async () => { const body = ' + ${JSON.stringify(body)} + '; fetch(workerData.target, { method: "POST", body }).catch(() => {}); })();', + { eval: true, workerData: { target: target.url.href, upstream: upstream.url.href, fifo } }, + ); + const exited = new Promise(resolve => worker.on("exit", resolve)); + await firstChunk.promise; + worker.terminate(); + console.log("worker exit code:", await exited); + fs.closeSync(fifoFd); + target.stop(true); + upstream.stop(true); + `; + + test.concurrent.each([ + [ + "a JS pull stream", + `new ReadableStream({ pull(c) { c.enqueue(new Uint8Array(16)); return new Promise(() => {}); } })`, + ], + ["another response's body", `(await fetch(workerData.upstream)).body`], + ["Bun.file(fifo).stream()", `Bun.file(workerData.fifo).stream()`], + ])("doesn't leak the request when the body is %s", async (_, body) => { + using dir = tempDir("worker-upload-teardown", {}); + const fifo = path.join(String(dir), "fifo"); + mkfifo(fifo); + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", host(body, fifo)], + env: { + ...bunEnv, + BUN_DESTRUCT_VM_ON_EXIT: "1", + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=1"].filter(Boolean).join(":"), + LSAN_OPTIONS: `print_suppressions=0:suppressions=${path.join(import.meta.dirname, "../../../leaksan.supp")}`, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, leak: stderr.includes("LeakSanitizer") ? stderr : "" }).toEqual({ + stdout: "worker exit code: 1\n", + leak: "", + }); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/web/workers/worker.test.ts b/test/js/web/workers/worker.test.ts index 65574d030c4f..b1355f6225e3 100644 --- a/test/js/web/workers/worker.test.ts +++ b/test/js/web/workers/worker.test.ts @@ -29,6 +29,42 @@ describe("web worker", () => { ).toThrow(/Invalid file URL/); }); + // Reaching the real limit needs 2.5 GB, so the child runs with a 64 KiB synthetic allocation limit + // (2730 24-byte entries). + test("throws a RangeError for more entries than the native list allows", async () => { + const LIMIT_BYTES = 64 * 1024; + const MAX_ENTRIES = Math.floor(LIMIT_BYTES / 24); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + // The last entry is an invalid file URL, so a list within the limit still throws and never starts a thread. + const construct = count => { + const preload = new Array(count).fill("./preload.js"); + preload[count - 1] = "file://:!:!:!!!!"; + try { + new Worker("./entry.js", { preload }); + return "constructed"; + } catch (e) { + return e.name + ": " + e.message; + } + }; + console.log(JSON.stringify({ atLimit: construct(${MAX_ENTRIES}), onePast: construct(${MAX_ENTRIES + 1}) })); + `, + ], + env: { ...bunEnv, BUN_FEATURE_FLAG_SYNTHETIC_MEMORY_LIMIT: String(LIMIT_BYTES) }, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout || "null")).toEqual({ + atLimit: 'TypeError: Invalid file URL: "file://:!:!:!!!!"', + onePast: "RangeError: Out of memory", + }); + expect(exitCode).toBe(0); + }); + test("string", async () => { const worker = new Worker(new URL("worker-fixture-preload-entry.js", import.meta.url).href, { preload: new URL("worker-fixture-preload.js", import.meta.url).href, diff --git a/test/js/workerd/html-rewriter.test.js b/test/js/workerd/html-rewriter.test.js index 4e2dcfd7cd8f..fb942033b79f 100644 --- a/test/js/workerd/html-rewriter.test.js +++ b/test/js/workerd/html-rewriter.test.js @@ -819,6 +819,55 @@ describe("HTMLRewriter", () => { await expect(second.text()).rejects.toThrow("inner boom"); }); + it.concurrent("an element can be modified while the output before it is being delivered", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + /* js */ ` + let release = null; + // The document arrives after both rewriters exist, so the first pipes its output straight into the second. + const wired = Promise.withResolvers(); + const input = new ReadableStream({ + async pull(controller) { + await wired.promise; + controller.enqueue(new TextEncoder().encode("<p>intro</p><b>one</b><p>tail</p>")); + controller.close(); + }, + }); + const first = new HTMLRewriter().on("b", { + async element(el) { + await new Promise(resolve => { + release = resolve; + setImmediate(resolve); + }); + el.setAttribute("seen", "1"); + }, + }); + // The first rewriter's handler resumes before this one returns. + const second = new HTMLRewriter().on("p", { + async element() { + if (!release) return; + release(); + release = null; + await 0; + }, + }); + const out = second.transform(first.transform(new Response(input))); + wired.resolve(); + console.log(await out.text()); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe('<p>intro</p><b seen="1">one</b><p>tail</p>\n'); + expect(exitCode).toBe(0); + }); + // The resume path's own `handler_callback` sees a Fulfilled promise only // when a microtask-only handler follows a real-await one. it("a microtask-only handler after a suspending one", async () => { diff --git a/test/napi/napi-app/binding.gyp b/test/napi/napi-app/binding.gyp index 656c4c48c722..7fefdc948323 100644 --- a/test/napi/napi-app/binding.gyp +++ b/test/napi/napi-app/binding.gyp @@ -373,5 +373,53 @@ "NAPI_VERSION=8", ], }, + # The Windows link shapes of an addon that names node.exe in its import tables without + # node-gyp's win_delay_load_hook. On other platforms they are ordinary addons. + { + "target_name": "no_delay_load_hook_addon", + "sources": ["no_delay_load_hook_addon.c"], + "win_delay_load_hook": "false", + "conditions": [ + ["OS=='win'", { + "msvs_settings": { + "VCLinkerTool": { + "DelayLoadDLLs": ["node.exe"], + }, + }, + }], + ], + }, + { + "target_name": "regular_node_exe_import_addon", + "sources": ["no_delay_load_hook_addon.c"], + "win_delay_load_hook": "false", + }, + { + "target_name": "no_delay_load_hook_ctor_addon", + "sources": ["no_delay_load_hook_addon.c"], + "defines": ["REGISTER_VIA_CONSTRUCTOR"], + "win_delay_load_hook": "false", + "conditions": [ + ["OS=='win'", { + "msvs_settings": { + "VCLinkerTool": { + "DelayLoadDLLs": ["node.exe"], + }, + }, + }], + ], + }, + { + "target_name": "regular_node_exe_import_ctor_addon", + "sources": ["no_delay_load_hook_addon.c"], + "defines": ["REGISTER_VIA_CONSTRUCTOR"], + "win_delay_load_hook": "false", + }, + { + "target_name": "regular_node_exe_import_missing_addon", + "sources": ["no_delay_load_hook_addon.c"], + "defines": ["IMPORT_MISSING_FROM_HOST"], + "win_delay_load_hook": "false", + }, ] } diff --git a/test/napi/napi-app/no_delay_load_hook_addon.c b/test/napi/napi-app/no_delay_load_hook_addon.c new file mode 100644 index 000000000000..52aac4f1c7e8 --- /dev/null +++ b/test/napi/napi-app/no_delay_load_hook_addon.c @@ -0,0 +1,65 @@ +// An N-API addon whose Windows build imports from node.exe without node-gyp's +// win_delay_load_hook. binding.gyp builds this source in several variants: +// (default) exports napi_register_module_v1 +// REGISTER_VIA_CONSTRUCTOR calls napi_module_register from a static initializer, inside DllMain, +// as NAPI_MODULE() did in node_api.h before v18.17.0 +// IMPORT_MISSING_FROM_HOST also imports a node.exe export that bun.exe doesn't have +#include <node_api.h> +#include <stdio.h> + +#if defined(_WIN32) && defined(IMPORT_MISSING_FROM_HOST) +// Exported by node.exe (OpenSSL) but not by bun. +__declspec(dllimport) unsigned long OpenSSL_version_num(void); +#endif + +static napi_value hello(napi_env env, napi_callback_info info) { + (void)info; + napi_value out; + const char *text = "hello"; +#if defined(_WIN32) && defined(IMPORT_MISSING_FROM_HOST) + char buf[64]; + snprintf(buf, sizeof(buf), "hello %lx", OpenSSL_version_num()); + text = buf; +#endif + if (napi_create_string_utf8(env, text, NAPI_AUTO_LENGTH, &out) != napi_ok) + return NULL; + return out; +} + +static napi_value init(napi_env env, napi_value exports) { + napi_value fn; + if (napi_create_function(env, "hello", NAPI_AUTO_LENGTH, hello, NULL, &fn) != + napi_ok) + return NULL; + if (napi_set_named_property(env, exports, "hello", fn) != napi_ok) + return NULL; + return exports; +} + +#ifdef REGISTER_VIA_CONSTRUCTOR + +static napi_module module_description = { + NAPI_MODULE_VERSION, 0, __FILE__, init, "no_delay_load_hook_addon", NULL, {0}, +}; + +// Copied from NAPI_C_CTOR in node_api.h v18.16.0, with different names. +#if defined(_MSC_VER) +#pragma section(".CRT$XCU", read) +static void __cdecl register_module(void); +__declspec(dllexport, allocate(".CRT$XCU")) void(__cdecl *register_module_)(void) = + register_module; +static void __cdecl register_module(void) { +#else +static void register_module(void) __attribute__((constructor)); +static void register_module(void) { +#endif + napi_module_register(&module_description); +} + +#else + +NAPI_MODULE_EXPORT napi_value napi_register_module_v1(napi_env env, napi_value exports) { + return init(env, exports); +} + +#endif diff --git a/test/napi/napi-app/standalone_tests.cpp b/test/napi/napi-app/standalone_tests.cpp index 494a58daeb56..6f90cc375549 100644 --- a/test/napi/napi-app/standalone_tests.cpp +++ b/test/napi/napi-app/standalone_tests.cpp @@ -11,6 +11,12 @@ #include <string> #include <thread> +#ifdef _WIN32 +#include <windows.h> +#else +#include <sys/mman.h> +#endif + #include "utils.h" namespace napitests { @@ -724,6 +730,42 @@ static napi_value test_napi_run_script(const Napi::CallbackInfo &info) { return ret; } +static napi_value +test_napi_run_script_exception_value(const Napi::CallbackInfo &info) { + napi_env env = info.Env(); + // info[0] is the GC callback + napi_value script = info[1]; + + napi_value ret = nullptr; + napi_status run_status = napi_run_script(env, script, &ret); + bool pending = false; + NODE_API_CALL(env, napi_is_exception_pending(env, &pending)); + printf("run status=%d pending=%d\n", (int)run_status, (int)pending); + + napi_value exc = nullptr; + NODE_API_CALL(env, napi_get_and_clear_last_exception(env, &exc)); + + napi_valuetype type = (napi_valuetype)99; + napi_status typeof_status = napi_typeof(env, exc, &type); + bool is_error = false; + NODE_API_CALL(env, napi_is_error(env, exc, &is_error)); + printf("typeof status=%d type=%d is_error=%d\n", (int)typeof_status, + (int)type, (int)is_error); + + if (type == napi_object) { + napi_value message = nullptr; + NODE_API_CALL(env, + napi_get_named_property(env, exc, "message", &message)); + char buf[128] = {0}; + size_t len = 0; + NODE_API_CALL(env, napi_get_value_string_utf8(env, message, buf, + sizeof buf, &len)); + printf("message=%s\n", buf); + } + + return exc; +} + static napi_value test_napi_throw_with_nullptr(const Napi::CallbackInfo &info) { napi_env env = info.Env(); const napi_status status = napi_throw(env, nullptr); @@ -4067,6 +4109,132 @@ test_node_api_sharedarraybuffer(const Napi::CallbackInfo &info) { return ok(env); } +// Reserved but never committed, so it doesn't use any memory. +static constexpr size_t kReservedBytes = (size_t{1} << 32) + 4096; + +static void *reserve_address_space() { +#ifdef _WIN32 + return VirtualAlloc(nullptr, kReservedBytes, MEM_RESERVE, PAGE_NOACCESS); +#else + void *reserved = mmap(nullptr, kReservedBytes, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + return reserved == MAP_FAILED ? nullptr : reserved; +#endif +} + +static void release_address_space(void *reserved) { +#ifdef _WIN32 + VirtualFree(reserved, 0, MEM_RELEASE); +#else + munmap(reserved, kReservedBytes); +#endif +} + +static std::atomic<int> reserved_finalize_count{0}; + +static void finalize_reserved(napi_env, void *data, void *) { + reserved_finalize_count++; + release_address_space(data); +} + +static void finalize_reserved_noenv(void *data, void *) { + reserved_finalize_count++; + release_address_space(data); +} + +static std::string string_property(napi_env env, napi_value object, + const char *name) { + napi_value value; + char buf[256]; + size_t len = 0; + if (napi_get_named_property(env, object, name, &value) != napi_ok || + napi_get_value_string_utf8(env, value, buf, sizeof(buf), &len) != + napi_ok) { + return "<none>"; + } + return std::string(buf, len); +} + +// Prints whether an exception is pending. If one is, clears it and prints its +// constructor name and code. +static void print_pending_exception(napi_env env) { + bool pending = false; + napi_is_exception_pending(env, &pending); + printf(" pending=%s", pending ? "true" : "false"); + if (!pending) { + return; + } + napi_value exception; + napi_value constructor; + napi_get_and_clear_last_exception(env, &exception); + napi_get_named_property(env, exception, "constructor", &constructor); + printf(" error=%s code=%s", string_property(env, constructor, "name").c_str(), + string_property(env, exception, "code").c_str()); +} + +// test_external_buffer_length_limit(gc, length) calls each external buffer API +// with `length` on a fresh reservation and prints one line per API. +static napi_value +test_external_buffer_length_limit(const Napi::CallbackInfo &info) { + napi_env env = info.Env(); + double length = 0; + NODE_API_CALL(env, napi_get_value_double(env, info[1], &length)); + + static const char *const names[] = { + "napi_create_external_arraybuffer", + "napi_create_external_buffer", + "node_api_create_external_sharedarraybuffer", + }; + // Never dereferenced. A failed call must not write to *result. + const napi_value untouched = + reinterpret_cast<napi_value>(&reserved_finalize_count); + + for (int api = 0; api < 3; api++) { + void *reserved = reserve_address_space(); + if (reserved == nullptr) { + printf("FAIL: could not reserve %zu bytes of address space\n", + kReservedBytes); + return ok(env); + } + reserved_finalize_count = 0; + napi_value result = untouched; + napi_status status; + if (api == 0) { + status = napi_create_external_arraybuffer( + env, reserved, static_cast<size_t>(length), finalize_reserved, + nullptr, &result); + } else if (api == 1) { + status = napi_create_external_buffer(env, static_cast<size_t>(length), + reserved, finalize_reserved, nullptr, + &result); + } else { + status = node_api_create_external_sharedarraybuffer( + env, reserved, static_cast<size_t>(length), finalize_reserved_noenv, + nullptr, &result); + } + + printf("%s: status=%d", names[api], static_cast<int>(status)); + print_pending_exception(env); + printf(" finalized=%d result=%s", reserved_finalize_count.load(), + result == untouched ? "untouched" : "set"); + if (status == napi_ok) { + napi_value byte_length_value; + double byte_length = -1; + NODE_API_CALL(env, napi_get_named_property(env, result, "byteLength", + &byte_length_value)); + NODE_API_CALL( + env, napi_get_value_double(env, byte_length_value, &byte_length)); + printf(" byteLength=%.0f", byte_length); + } else if (reserved_finalize_count == 0) { + // The call failed and the finalizer didn't run, so the caller still owns + // the bytes. + release_address_space(reserved); + } + printf("\n"); + } + return ok(env); +} + static void noop_tsfn_cb(napi_env, napi_value, void *, void *) {} // Each case below returns a different napi_status in Bun than in Node.js 26 @@ -4623,6 +4791,7 @@ void register_standalone_tests(Napi::Env env, Napi::Object exports) { REGISTER_FUNCTION(env, exports, test_napi_handle_scope_many_args); REGISTER_FUNCTION(env, exports, test_napi_ref); REGISTER_FUNCTION(env, exports, test_napi_run_script); + REGISTER_FUNCTION(env, exports, test_napi_run_script_exception_value); REGISTER_FUNCTION(env, exports, test_napi_throw_with_nullptr); REGISTER_FUNCTION(env, exports, test_extended_error_messages); REGISTER_FUNCTION(env, exports, bigint_to_i64); @@ -4670,6 +4839,7 @@ void register_standalone_tests(Napi::Env env, Napi::Object exports) { REGISTER_FUNCTION(env, exports, test_node_api_set_prototype); REGISTER_FUNCTION(env, exports, test_node_api_create_object_with_properties); REGISTER_FUNCTION(env, exports, test_node_api_sharedarraybuffer); + REGISTER_FUNCTION(env, exports, test_external_buffer_length_limit); REGISTER_FUNCTION(env, exports, test_napi_status_codes_node26); REGISTER_FUNCTION(env, exports, test_tsfn_null_js_callback); REGISTER_FUNCTION(env, exports, test_tsfn_null_js_callback_ran); diff --git a/test/napi/napi.test.ts b/test/napi/napi.test.ts index 9379b2e0b34d..581ec79b35c3 100644 --- a/test/napi/napi.test.ts +++ b/test/napi/napi.test.ts @@ -10,10 +10,11 @@ import { isMacOS, isMusl, isWindows, + mergeWindowEnvs, nodeExeMatchingAbi, tempDir, } from "harness"; -import { join } from "path"; +import { dirname, join } from "path"; // The napi-app addons don't link against bun, so existing binaries stay valid // across bun builds. `bun install` runs a full `node-gyp rebuild` (clean + build @@ -456,6 +457,18 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { }); }); + // Node wraps this length around, so this only runs on Bun. + it("creating an external buffer larger than 2 ** 32 bytes fails with ERR_BUFFER_TOO_LARGE", async () => { + const result = await runOn(bunExe(), "test_external_buffer_length_limit", [2 ** 32 + 1]); + expect(result.trim().split(/\r?\n/)).toEqual( + [ + "napi_create_external_arraybuffer", + "napi_create_external_buffer", + "node_api_create_external_sharedarraybuffer", + ].map(api => `${api}: status=9 pending=true error=Error code=ERR_BUFFER_TOO_LARGE finalized=0 result=untouched`), + ); + }); + // The finalizer of an external buffer belongs to the env that created it, and that env's // teardown frees the bytes, so the bytes must not be transferred to another thread. describe("external buffers are untransferable", () => { @@ -1237,6 +1250,12 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { it("propagates exceptions", async () => { await checkSameOutput("test_napi_run_script", ["(()=>{ throw new TypeError('oops'); })()"]); }); + it("passes the thrown value to napi_get_and_clear_last_exception", async () => { + const result = await checkSameOutput("test_napi_run_script_exception_value", ['throw new RangeError("boom")']); + expect(result).toContain("run status=9 pending=1"); + expect(result).toContain("typeof status=0 type=6 is_error=1"); + expect(result).toContain("message=boom"); + }); it("cannot see locals from around its invocation", async () => { // variable should_not_exist is declared on main.js:18, but it should not be in scope for the eval'd code // this doesn't use await checkSameOutput because V8 and JSC use different error messages for a missing variable @@ -1782,6 +1801,41 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { expect(exitCode).toBe(0); }); + it("runs the outer module's init function when the module.exports getter loads another addon", async () => { + // process.dlopen reads module.exports a second time to pass it to the init function. + const script = ` + const build = ${JSON.stringify(join(__dirname, "napi-app", "build", "Debug"))}; + const exports = {}; + let reads = 0; + const outer = { + get exports() { + if (++reads === 2) process.dlopen({ exports: {} }, build + "/reentrant_register_addon.node"); + return exports; + }, + }; + process.dlopen(outer, build + "/constructor_order_addon.node"); + `; + await using proc = spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + // On Windows each addon has its own stdout buffer, so line order is only guaranteed within one addon. + const lines = stdout.split(/\r?\n/).filter(Boolean); + const outer = ["call_register", "init_static", "register_cb"]; + expect({ + outer: lines.filter(line => outer.includes(line)), + inner: lines.filter(line => !outer.includes(line)), + }).toEqual({ + outer, + inner: ["register_cb_a", "register_cb_b", "register_cb_reentrant x 64"], + }); + expect(exitCode).toBe(0); + }); + it("behaves as expected when performing operations with an exception pending", async () => { await checkSameOutput("test_deferred_exceptions", []); }); @@ -2062,6 +2116,59 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { }, 25_000, ); + + // Off Windows these fixtures are ordinary addons, so this only checks that they load. + describe("an addon that imports node.exe without win_delay_load_hook", () => { + const fixtures = { + no_delay_load_hook_addon: { delayLoad: true, missing: false }, + regular_node_exe_import_addon: { delayLoad: false, missing: false }, + no_delay_load_hook_ctor_addon: { delayLoad: true, missing: false }, + regular_node_exe_import_ctor_addon: { delayLoad: false, missing: false }, + regular_node_exe_import_missing_addon: { delayLoad: false, missing: true }, + } as const; + type Fixture = keyof typeof fixtures; + const fixtureNames = Object.keys(fixtures) as Fixture[]; + + // PATH is a single directory and the cwd is empty, so the DLL search path contains at most one node.exe. + async function load(target: Fixture, nodeOnPath: boolean) { + using dir = tempDir("napi-node-exe-import", {}); + const addon = join(__dirname, `napi-app/build/Debug/${target}.node`); + await using proc = spawn({ + cmd: [ + bunExe(), + "-e", + `try { console.log(require(${JSON.stringify(addon)}).hello()); } catch (e) { console.log(e.code + ": " + e.message); process.exit(1); }`, + ], + env: mergeWindowEnvs([bunEnv, { PATH: nodeOnPath ? dirname(await nodeExeMatchingAbi()) : String(dir) }]), + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout: stdout.trim(), stderr, exitCode }; + } + const loaded = { stdout: "hello", stderr: "", exitCode: 0 }; + + it.each(fixtureNames)("%s loads with node.exe on PATH", async target => { + const result = await load(target, true); + if (isWindows && fixtures[target].missing) { + expect(result.stdout).toStartWith("ERR_DLOPEN_FAILED: Cannot load native addon "); + expect(result.stdout).toEndWith( + `${target}.node imports 'OpenSSL_version_num' from node.exe, which Bun does not provide`, + ); + expect(result.exitCode).toBe(1); + } else { + expect(result).toEqual(loaded); + } + }); + + it.skipIf(!isWindows).each(fixtureNames.filter(target => fixtures[target].delayLoad))( + "%s loads without a node.exe on the DLL search path", + async target => { + expect(await load(target, false)).toEqual(loaded); + }, + ); + }); }); // Kept outside describe.concurrent("napi") so RSS measurement isn't skewed by diff --git a/test/napi/node-napi-tests/test/node-api/test_callback_scope/do.test.ts b/test/napi/node-napi-tests/test/node-api/test_callback_scope/do.test.ts index b415716c512f..769814ef6a44 100644 --- a/test/napi/node-napi-tests/test/node-api/test_callback_scope/do.test.ts +++ b/test/napi/node-napi-tests/test/node-api/test_callback_scope/do.test.ts @@ -7,12 +7,11 @@ test("build", async () => { }); for (const file of Array.from(new Bun.Glob("*.js").scanSync(import.meta.dir))) { - // crash inside uv_queue_work - // https://github.com/oven-sh/bun/issues/12827 is the latter - test.todoIf(["test-resolve-async.js", "test-async-hooks.js"].includes(file) || (file === "test.js" && isWindows))( - file, - () => { - run(dirname(import.meta.dir), basename(import.meta.dir) + sep + file); - }, - ); + // test-async-hooks.js: https://github.com/oven-sh/bun/issues/12827 + // test-resolve-async.js on Windows: the process exits before uv_queue_work's after_work_cb runs. + test.todoIf( + file === "test-async-hooks.js" || (["test.js", "test-resolve-async.js"].includes(file) && isWindows), + )(file, () => { + run(dirname(import.meta.dir), basename(import.meta.dir) + sep + file); + }); } diff --git a/test/napi/uv-stub-stuff/plugin.c b/test/napi/uv-stub-stuff/plugin.c index 932e776ec352..e22c47f629e9 100644 --- a/test/napi/uv-stub-stuff/plugin.c +++ b/test/napi/uv-stub-stuff/plugin.c @@ -45,22 +45,6 @@ napi_value call_uv_func(napi_env env, napi_callback_info info) { return NULL; } - if (strcmp(buffer, "uv_async_init") == 0) { - uv_loop_t *arg0 = {0}; - uv_async_t *arg1 = {0}; - uv_async_cb arg2 = NULL; - - uv_async_init(arg0, arg1, arg2); - return NULL; - } - - if (strcmp(buffer, "uv_async_send") == 0) { - uv_async_t *arg0 = {0}; - - uv_async_send(arg0); - return NULL; - } - if (strcmp(buffer, "uv_available_parallelism") == 0) { uv_available_parallelism(); @@ -111,13 +95,6 @@ napi_value call_uv_func(napi_env env, napi_callback_info info) { return NULL; } - if (strcmp(buffer, "uv_cancel") == 0) { - uv_req_t *arg0 = {0}; - - uv_cancel(arg0); - return NULL; - } - if (strcmp(buffer, "uv_chdir") == 0) { const char *arg0 = {0}; @@ -156,14 +133,6 @@ napi_value call_uv_func(napi_env env, napi_callback_info info) { return NULL; } - if (strcmp(buffer, "uv_close") == 0) { - uv_handle_t *arg0 = {0}; - uv_close_cb arg1 = NULL; - - uv_close(arg0, arg1); - return NULL; - } - if (strcmp(buffer, "uv_cond_broadcast") == 0) { uv_cond_t *arg0 = {0}; @@ -223,20 +192,6 @@ napi_value call_uv_func(napi_env env, napi_callback_info info) { return NULL; } - if (strcmp(buffer, "uv_cwd") == 0) { - char *arg0 = {0}; - size_t *arg1 = {0}; - - uv_cwd(arg0, arg1); - return NULL; - } - - if (strcmp(buffer, "uv_default_loop") == 0) { - - uv_default_loop(); - return NULL; - } - if (strcmp(buffer, "uv_disable_stdio_inheritance") == 0) { uv_disable_stdio_inheritance(); @@ -867,13 +822,6 @@ napi_value call_uv_func(napi_env env, napi_callback_info info) { return NULL; } - if (strcmp(buffer, "uv_get_osfhandle") == 0) { - int arg0 = {0}; - - uv_get_osfhandle(arg0); - return NULL; - } - if (strcmp(buffer, "uv_get_process_title") == 0) { char *arg0 = {0}; size_t arg1 = {0}; @@ -932,56 +880,6 @@ napi_value call_uv_func(napi_env env, napi_callback_info info) { return NULL; } - if (strcmp(buffer, "uv_handle_get_data") == 0) { - const uv_handle_t *arg0 = {0}; - - uv_handle_get_data(arg0); - return NULL; - } - - if (strcmp(buffer, "uv_handle_get_loop") == 0) { - const uv_handle_t *arg0 = {0}; - - uv_handle_get_loop(arg0); - return NULL; - } - - if (strcmp(buffer, "uv_handle_get_type") == 0) { - const uv_handle_t *arg0 = {0}; - - uv_handle_get_type(arg0); - return NULL; - } - - if (strcmp(buffer, "uv_handle_set_data") == 0) { - uv_handle_t *arg0 = {0}; - void *arg1 = {0}; - - uv_handle_set_data(arg0, arg1); - return NULL; - } - - if (strcmp(buffer, "uv_handle_size") == 0) { - uv_handle_type arg0 = {0}; - - uv_handle_size(arg0); - return NULL; - } - - if (strcmp(buffer, "uv_handle_type_name") == 0) { - uv_handle_type arg0 = {0}; - - uv_handle_type_name(arg0); - return NULL; - } - - if (strcmp(buffer, "uv_has_ref") == 0) { - const uv_handle_t *arg0 = {0}; - - uv_has_ref(arg0); - return NULL; - } - if (strcmp(buffer, "uv_idle_init") == 0) { uv_loop_t *arg0 = {0}; uv_idle_t *arg1 = {0}; @@ -1095,20 +993,6 @@ napi_value call_uv_func(napi_env env, napi_callback_info info) { return NULL; } - if (strcmp(buffer, "uv_is_active") == 0) { - const uv_handle_t *arg0 = {0}; - - uv_is_active(arg0); - return NULL; - } - - if (strcmp(buffer, "uv_is_closing") == 0) { - const uv_handle_t *arg0 = {0}; - - uv_is_closing(arg0); - return NULL; - } - if (strcmp(buffer, "uv_is_readable") == 0) { const uv_stream_t *arg0 = {0}; @@ -1218,13 +1102,6 @@ napi_value call_uv_func(napi_env env, napi_callback_info info) { return NULL; } - if (strcmp(buffer, "uv_loop_get_data") == 0) { - const uv_loop_t *arg0 = {0}; - - uv_loop_get_data(arg0); - return NULL; - } - if (strcmp(buffer, "uv_loop_init") == 0) { uv_loop_t *arg0 = {0}; @@ -1238,14 +1115,6 @@ napi_value call_uv_func(napi_env env, napi_callback_info info) { return NULL; } - if (strcmp(buffer, "uv_loop_set_data") == 0) { - uv_loop_t *arg0 = {0}; - void *arg1 = {0}; - - uv_loop_set_data(arg0, arg1); - return NULL; - } - if (strcmp(buffer, "uv_loop_size") == 0) { uv_loop_size(); @@ -1274,13 +1143,6 @@ napi_value call_uv_func(napi_env env, napi_callback_info info) { return NULL; } - if (strcmp(buffer, "uv_open_osfhandle") == 0) { - uv_os_fd_t arg0 = {0}; - - uv_open_osfhandle(arg0); - return NULL; - } - if (strcmp(buffer, "uv_os_environ") == 0) { uv_env_item_t **arg0 = NULL; int *arg1 = {0}; @@ -1607,16 +1469,6 @@ napi_value call_uv_func(napi_env env, napi_callback_info info) { return NULL; } - if (strcmp(buffer, "uv_queue_work") == 0) { - uv_loop_t *arg0 = {0}; - uv_work_t *arg1 = {0}; - uv_work_cb arg2 = NULL; - uv_after_work_cb arg3 = NULL; - - uv_queue_work(arg0, arg1, arg2, arg3); - return NULL; - } - if (strcmp(buffer, "uv_random") == 0) { uv_loop_t *arg0 = {0}; uv_random_t *arg1 = {0}; @@ -1653,13 +1505,6 @@ napi_value call_uv_func(napi_env env, napi_callback_info info) { return NULL; } - if (strcmp(buffer, "uv_ref") == 0) { - uv_handle_t *arg0 = {0}; - - uv_ref(arg0); - return NULL; - } - if (strcmp(buffer, "uv_replace_allocator") == 0) { uv_malloc_func arg0 = {0}; uv_realloc_func arg1 = {0}; @@ -1670,42 +1515,6 @@ napi_value call_uv_func(napi_env env, napi_callback_info info) { return NULL; } - if (strcmp(buffer, "uv_req_get_data") == 0) { - const uv_req_t *arg0 = {0}; - - uv_req_get_data(arg0); - return NULL; - } - - if (strcmp(buffer, "uv_req_get_type") == 0) { - const uv_req_t *arg0 = {0}; - - uv_req_get_type(arg0); - return NULL; - } - - if (strcmp(buffer, "uv_req_set_data") == 0) { - uv_req_t *arg0 = {0}; - void *arg1 = {0}; - - uv_req_set_data(arg0, arg1); - return NULL; - } - - if (strcmp(buffer, "uv_req_size") == 0) { - uv_req_type arg0 = {0}; - - uv_req_size(arg0); - return NULL; - } - - if (strcmp(buffer, "uv_req_type_name") == 0) { - uv_req_type arg0 = {0}; - - uv_req_type_name(arg0); - return NULL; - } - if (strcmp(buffer, "uv_resident_set_memory") == 0) { size_t *arg0 = {0}; @@ -2406,13 +2215,6 @@ napi_value call_uv_func(napi_env env, napi_callback_info info) { return NULL; } - if (strcmp(buffer, "uv_unref") == 0) { - uv_handle_t *arg0 = {0}; - - uv_unref(arg0); - return NULL; - } - if (strcmp(buffer, "uv_update_time") == 0) { uv_loop_t *arg0 = {0}; @@ -2427,18 +2229,6 @@ napi_value call_uv_func(napi_env env, napi_callback_info info) { return NULL; } - if (strcmp(buffer, "uv_version") == 0) { - - uv_version(); - return NULL; - } - - if (strcmp(buffer, "uv_version_string") == 0) { - - uv_version_string(); - return NULL; - } - if (strcmp(buffer, "uv_walk") == 0) { uv_loop_t *arg0 = {0}; uv_walk_cb arg1 = NULL; diff --git a/test/napi/uv-stub-stuff/uv_impl.c b/test/napi/uv-stub-stuff/uv_impl.c index 6e75a5816866..5e4f2560287b 100644 --- a/test/napi/uv-stub-stuff/uv_impl.c +++ b/test/napi/uv-stub-stuff/uv_impl.c @@ -2,7 +2,11 @@ #include <pthread.h> #include <signal.h> +#include <stdatomic.h> +#include <stdbool.h> +#include <stdint.h> #include <stdio.h> +#include <stdlib.h> #include <string.h> #include <unistd.h> #include <uv.h> @@ -103,6 +107,47 @@ static napi_value test_uv_once(napi_env env, napi_callback_info info) { return ret; } +// Test uv_cwd +static napi_value test_cwd(napi_env env, napi_callback_info info) { + napi_value obj; + napi_create_object(env, &obj); + + napi_value v; + + napi_create_int32(env, UV_EINVAL, &v); + napi_set_named_property(env, obj, "UV_EINVAL", v); + napi_create_int32(env, UV_ENOBUFS, &v); + napi_set_named_property(env, obj, "UV_ENOBUFS", v); + + size_t zero = 0; + size_t one = 1; + char dummy[1]; + napi_create_int32(env, uv_cwd(NULL, &one), &v); + napi_set_named_property(env, obj, "nullBuffer", v); + napi_create_int32(env, uv_cwd(dummy, NULL), &v); + napi_set_named_property(env, obj, "nullSize", v); + napi_create_int32(env, uv_cwd(dummy, &zero), &v); + napi_set_named_property(env, obj, "zeroSize", v); + + char small[2]; + size_t small_size = sizeof(small); + napi_create_int32(env, uv_cwd(small, &small_size), &v); + napi_set_named_property(env, obj, "smallRc", v); + napi_create_int64(env, (int64_t)small_size, &v); + napi_set_named_property(env, obj, "smallRequired", v); + + char buf[4096]; + size_t size = sizeof(buf); + napi_create_int32(env, uv_cwd(buf, &size), &v); + napi_set_named_property(env, obj, "rc", v); + napi_create_string_utf8(env, buf, size, &v); + napi_set_named_property(env, obj, "cwd", v); + napi_create_int64(env, (int64_t)size, &v); + napi_set_named_property(env, obj, "size", v); + + return obj; +} + // Test uv_hrtime static napi_value test_hrtime(napi_env env, napi_callback_info info) { uint64_t time1 = uv_hrtime(); @@ -187,10 +232,1028 @@ static napi_value test_tty_reset_mode_concurrent(napi_env env, return obj; } +// --------------------------------------------------------------------------- +// Tests for the loop-backed functions: uv_default_loop, uv_queue_work, the +// uv_async_t, uv_idle_t, uv_prepare_t, uv_check_t and uv_timer_t handles, and +// their header-only helpers. Each test reports events through a JS callback +// `(event, a, b)` so uv.test.ts can check callback order, thread and loop +// iteration. uv.test.ts runs them in a child process because a stubbed uv_* +// function aborts. +// --------------------------------------------------------------------------- + +struct reporter { + napi_env env; + napi_ref callback; +}; + +static void reporter_init(struct reporter *r, napi_env env, + napi_value callback) { + r->env = env; + napi_create_reference(env, callback, 1, &r->callback); +} + +// Calls the JS callback. Ignores the call's status on purpose, because one test +// throws from the callback to check that the exception is reported as uncaught. +static void report(struct reporter *r, const char *event, int32_t a, + int32_t b) { + napi_env env = r->env; + napi_handle_scope scope; + napi_open_handle_scope(env, &scope); + napi_value callback, undefined, argv[3]; + napi_get_reference_value(env, r->callback, &callback); + napi_get_undefined(env, &undefined); + napi_create_string_utf8(env, event, NAPI_AUTO_LENGTH, &argv[0]); + napi_create_int32(env, a, &argv[1]); + napi_create_int32(env, b, &argv[2]); + napi_call_function(env, undefined, callback, 3, argv, NULL); + napi_close_handle_scope(env, scope); +} + +static void reporter_destroy(struct reporter *r) { + napi_delete_reference(r->env, r->callback); +} + +static uv_loop_t *get_loop(napi_env env, bool use_default_loop) { + if (use_default_loop) + return uv_default_loop(); + uv_loop_t *loop = NULL; + napi_get_uv_event_loop(env, &loop); + return loop; +} + +static napi_value make_int32_array(napi_env env, const int32_t *values, + size_t count) { + napi_value array; + napi_create_array_with_length(env, count, &array); + for (size_t i = 0; i < count; i++) { + napi_value value; + napi_create_int32(env, values[i], &value); + napi_set_element(env, array, (uint32_t)i, value); + } + return array; +} + +static void set_int32(napi_env env, napi_value object, const char *name, + int32_t value) { + napi_value number; + napi_create_int32(env, value, &number); + napi_set_named_property(env, object, name, number); +} + +static void get_args(napi_env env, napi_callback_info info, napi_value *args, + size_t count) { + size_t argc = count; + napi_get_cb_info(env, info, &argc, args, NULL, NULL); +} + +static bool as_bool(napi_env env, napi_value value) { + bool result = false; + napi_get_value_bool(env, value, &result); + return result; +} + +// testVersion() returns { version, versionString }. +static napi_value test_version(napi_env env, napi_callback_info info) { + napi_value result, version, version_string; + napi_create_object(env, &result); + napi_create_uint32(env, uv_version(), &version); + napi_create_string_utf8(env, uv_version_string(), NAPI_AUTO_LENGTH, + &version_string); + napi_set_named_property(env, result, "version", version); + napi_set_named_property(env, result, "versionString", version_string); + return result; +} + +// testSizesAndNames() checks uv_handle_size, uv_req_size and the type names +// against the headers this addon was compiled with. Addons rely on these to +// allocate handles dynamically. +static napi_value test_sizes_and_names(napi_env env, napi_callback_info info) { + napi_value result, value; + napi_create_object(env, &result); + + napi_get_boolean(env, uv_handle_size(UV_ASYNC) == sizeof(uv_async_t), &value); + napi_set_named_property(env, result, "asyncSizeMatches", value); + napi_get_boolean(env, uv_handle_size(UV_TIMER) == sizeof(uv_timer_t), &value); + napi_set_named_property(env, result, "timerSizeMatches", value); + napi_get_boolean(env, uv_handle_size(UV_UNKNOWN_HANDLE) == (size_t)-1, + &value); + napi_set_named_property(env, result, "unknownHandleSizeIsMinusOne", value); + napi_get_boolean(env, uv_req_size(UV_WORK) == sizeof(uv_work_t), &value); + napi_set_named_property(env, result, "workSizeMatches", value); + napi_get_boolean(env, uv_req_size(UV_UNKNOWN_REQ) == (size_t)-1, &value); + napi_set_named_property(env, result, "unknownReqSizeIsMinusOne", value); + napi_create_uint32(env, (uint32_t)sizeof(uv_async_t), &value); + napi_set_named_property(env, result, "asyncSize", value); + + napi_create_string_utf8(env, uv_handle_type_name(UV_ASYNC), NAPI_AUTO_LENGTH, + &value); + napi_set_named_property(env, result, "asyncName", value); + napi_create_string_utf8(env, uv_handle_type_name(UV_NAMED_PIPE), + NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, result, "pipeName", value); + napi_create_string_utf8(env, uv_req_type_name(UV_WORK), NAPI_AUTO_LENGTH, + &value); + napi_set_named_property(env, result, "workName", value); + napi_get_boolean(env, uv_handle_type_name(UV_UNKNOWN_HANDLE) == NULL, &value); + napi_set_named_property(env, result, "unknownHandleNameIsNull", value); + napi_get_boolean(env, uv_req_type_name(UV_UNKNOWN_REQ) == NULL, &value); + napi_set_named_property(env, result, "unknownReqNameIsNull", value); + return result; +} + +// testOsfhandle(fd) returns uv_open_osfhandle(uv_get_osfhandle(fd)), which is +// `fd` on Unix. +static napi_value test_osfhandle(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + napi_get_cb_info(env, info, &argc, args, NULL, NULL); + int32_t fd = -1; + napi_get_value_int32(env, args[0], &fd); + napi_value result; + napi_create_int32(env, uv_open_osfhandle(uv_get_osfhandle(fd)), &result); + return result; +} + +static void *call_uv_default_loop(void *out) { + *(uv_loop_t **)out = uv_default_loop(); + return NULL; +} + +// testLoops() checks how the loops relate. On the main thread, +// napi_get_uv_event_loop returns uv_default_loop(). In a Worker it returns the +// Worker's own loop. uv_default_loop() is the same loop on every thread, and +// its `data` slot belongs to the addon. +static napi_value test_loops(napi_env env, napi_callback_info info) { + uv_loop_t *napi_loop = get_loop(env, false); + uv_loop_t *default_loop = uv_default_loop(); + uv_loop_t *default_loop_from_thread = NULL; + pthread_t thread; + if (pthread_create(&thread, NULL, call_uv_default_loop, + &default_loop_from_thread) != 0) { + napi_throw_error(env, NULL, "pthread_create failed"); + return NULL; + } + pthread_join(thread, NULL); + + int marker; + uv_loop_set_data(napi_loop, &marker); + bool data_round_trips = + uv_loop_get_data(napi_loop) == &marker && napi_loop->data == ▮ + uv_loop_set_data(napi_loop, NULL); + + napi_value result, value; + napi_create_object(env, &result); + napi_get_boolean(env, napi_loop != NULL, &value); + napi_set_named_property(env, result, "napiLoopIsSet", value); + napi_get_boolean(env, napi_loop == default_loop, &value); + napi_set_named_property(env, result, "napiLoopIsDefaultLoop", value); + napi_get_boolean(env, default_loop_from_thread == default_loop, &value); + napi_set_named_property(env, result, "defaultLoopIsSameFromThread", value); + napi_get_boolean(env, data_round_trips, &value); + napi_set_named_property(env, result, "loopDataRoundTrips", value); + return result; +} + +// testErrors() returns the result of each invalid call, all UV_EINVAL (listed +// in uv.test.ts). It closes the two handles it initializes so the process can +// exit. They're static because a handle must outlive its close. +static void unused_async_cb(uv_async_t *handle) { (void)handle; } +static void unused_idle_cb(uv_idle_t *handle) { (void)handle; } +static void unused_timer_cb(uv_timer_t *handle) { (void)handle; } + +static uv_idle_t errors_idle; +static uv_timer_t errors_timer; + +static napi_value test_errors(napi_env env, napi_callback_info info) { + uv_loop_t *loop = get_loop(env, false); + uv_async_t async; + uv_check_t check; + uv_timer_t timer; + uv_work_t work; + uv_req_t not_work; + memset(¬_work, 0, sizeof(not_work)); + if (uv_idle_init(loop, &errors_idle) != 0 || + uv_timer_init(loop, &errors_timer) != 0) { + napi_throw_error(env, NULL, "init failed"); + return NULL; + } + int32_t results[] = { + uv_async_init(NULL, &async, unused_async_cb), + uv_check_init(NULL, &check), + uv_timer_init(NULL, &timer), + uv_queue_work(loop, &work, NULL, NULL), + uv_cancel(¬_work), + // No callback. + uv_idle_start(&errors_idle, NULL), + uv_timer_start(&errors_timer, NULL, 0, 0), + // Never started, so there's nothing to repeat. + uv_timer_again(&errors_timer), + // Wrong handle type. + uv_prepare_start((uv_prepare_t *)&errors_idle, + (uv_prepare_cb)unused_idle_cb), + uv_prepare_stop((uv_prepare_t *)&errors_idle), + uv_idle_start((uv_idle_t *)&errors_timer, unused_idle_cb), + uv_timer_start((uv_timer_t *)&errors_idle, unused_timer_cb, 0, 0), + uv_timer_stop((uv_timer_t *)&errors_idle), + uv_timer_again((uv_timer_t *)&errors_idle), + }; + uv_close((uv_handle_t *)&errors_idle, NULL); + uv_close((uv_handle_t *)&errors_timer, NULL); + return make_int32_array(env, results, sizeof(results) / sizeof(results[0])); +} + +// --- uv_async_t ------------------------------------------------------------- + +struct async_test { + uv_async_t handle; + struct reporter reporter; + int calls; +}; + +static void async_test_close_cb(uv_handle_t *handle) { + struct async_test *test = handle->data; + report(&test->reporter, "close", uv_is_closing(handle), + uv_handle_get_data(handle) == test); + reporter_destroy(&test->reporter); + free(test); +} + +static void async_test_cb(uv_async_t *handle) { + struct async_test *test = handle->data; + test->calls++; + report(&test->reporter, "async", test->calls, + uv_is_active((uv_handle_t *)handle)); + if (test->calls == 1) { + // A send from inside the callback must trigger another callback. + uv_async_send(handle); + return; + } + uv_close((uv_handle_t *)handle, async_test_close_cb); + // The handle is closing immediately, but close_cb runs later. + report(&test->reporter, "closing", uv_is_closing((uv_handle_t *)handle), + uv_is_active((uv_handle_t *)handle)); +} + +static void *send_after_a_while(void *arg) { + usleep(30 * 1000); + uv_async_send(arg); + return NULL; +} + +// Calls uv_async_init and throws if it fails, so the test fails here instead of +// on bogus events from an uninitialized handle. +static bool init_async(napi_env env, uv_loop_t *loop, uv_async_t *handle, + uv_async_cb cb) { + int rc = uv_async_init(loop, handle, cb); + if (rc != 0) { + char message[64]; + snprintf(message, sizeof(message), "uv_async_init returned %d", rc); + napi_throw_error(env, NULL, message); + return false; + } + return true; +} + +// testAsync(useDefaultLoop, sendFromThread, callback) reports these events: +// async 1 1 first callback, for the three coalesced sends +// async 2 1 the callback's own send +// closing 1 0 right after uv_close +// close 1 1 the deferred close callback +// The calling script returns right away, so only the ref'd handle keeps the +// process alive until the events fire. +// Returns [is_active, is_closing, has_ref] right after init. +static napi_value test_async(napi_env env, napi_callback_info info) { + napi_value args[3]; + get_args(env, info, args, 3); + bool use_default_loop = as_bool(env, args[0]); + bool send_from_thread = as_bool(env, args[1]); + + struct async_test *test = calloc(1, sizeof(*test)); + reporter_init(&test->reporter, env, args[2]); + test->handle.data = test; // set before init, as addons commonly do + uv_loop_t *loop = get_loop(env, use_default_loop); + if (!init_async(env, loop, &test->handle, async_test_cb)) + return NULL; + if (test->handle.data != test || + uv_handle_get_loop((uv_handle_t *)&test->handle) != loop || + uv_handle_get_type((uv_handle_t *)&test->handle) != UV_ASYNC) { + napi_throw_error(env, NULL, "uv_async_init didn't set up the handle"); + return NULL; + } + int32_t observed[3] = { + uv_is_active((uv_handle_t *)&test->handle), + uv_is_closing((uv_handle_t *)&test->handle), + uv_has_ref((uv_handle_t *)&test->handle), + }; + + if (send_from_thread) { + pthread_t thread; + if (pthread_create(&thread, NULL, send_after_a_while, &test->handle) != 0) { + napi_throw_error(env, NULL, "pthread_create failed"); + return NULL; + } + pthread_detach(thread); + } else { + // Nothing can run the callback between these, so they coalesce into one. + uv_async_send(&test->handle); + uv_async_send(&test->handle); + uv_async_send(&test->handle); + } + return make_int32_array(env, observed, 3); +} + +// testAsyncCloseWithSendPending(callback) sends and then closes a handle before +// the loop iterates, so only the close callback runs. A send after uv_close +// (allowed until close_cb) doesn't trigger a callback either. +// Returns [is_active, is_closing, send after close] right after uv_close. +static napi_value test_async_close_with_send_pending(napi_env env, + napi_callback_info info) { + napi_value args[1]; + get_args(env, info, args, 1); + struct async_test *test = calloc(1, sizeof(*test)); + reporter_init(&test->reporter, env, args[0]); + if (!init_async(env, get_loop(env, false), &test->handle, async_test_cb)) + return NULL; + uv_handle_set_data((uv_handle_t *)&test->handle, test); + uv_async_send(&test->handle); + uv_close((uv_handle_t *)&test->handle, async_test_close_cb); + uv_close((uv_handle_t *)&test->handle, async_test_close_cb); // ignored + int32_t observed[3] = { + uv_is_active((uv_handle_t *)&test->handle), + uv_is_closing((uv_handle_t *)&test->handle), + uv_async_send(&test->handle), + }; + return make_int32_array(env, observed, 3); +} + +// --- uv_async_send from another thread --------------------------------------- + +#define STRESS_ROUNDS 200 +#define STRESS_BURST 2000 + +struct stress_test { + uv_async_t handle; + struct reporter reporter; + pthread_t sender; + atomic_int callbacks; +}; + +static void *stress_sender(void *arg) { + struct stress_test *test = arg; + // Each round is one send and one callback. A send that arrives after the loop + // consumed the previous one must wake the loop again. A lost wakeup hangs + // here until the test times out. + for (int round = 1; round <= STRESS_ROUNDS; round++) { + uv_async_send(&test->handle); + while (atomic_load(&test->callbacks) < round) + sched_yield(); + } + // The pending flag was cleared before the last round's callback ran, so this + // burst triggers exactly one more callback, which closes the handle. The + // remaining sends arrive during or after uv_close, which is allowed until + // close_cb runs. close_cb joins this thread before it frees the handle. + for (int i = 0; i < STRESS_BURST; i++) + uv_async_send(&test->handle); + return NULL; +} + +static void stress_close_cb(uv_handle_t *handle) { + struct stress_test *test = handle->data; + pthread_join(test->sender, NULL); + report(&test->reporter, "done", atomic_load(&test->callbacks), + uv_is_closing(handle)); + reporter_destroy(&test->reporter); + free(test); +} + +static void stress_async_cb(uv_async_t *handle) { + struct stress_test *test = handle->data; + if (atomic_fetch_add(&test->callbacks, 1) + 1 > STRESS_ROUNDS) + uv_close((uv_handle_t *)handle, stress_close_cb); +} + +// testAsyncStress(callback) reports `done <STRESS_ROUNDS + 1> 1`: one callback +// per round, plus one for the burst, which closes the handle. +static napi_value test_async_stress(napi_env env, napi_callback_info info) { + napi_value args[1]; + get_args(env, info, args, 1); + struct stress_test *test = calloc(1, sizeof(*test)); + reporter_init(&test->reporter, env, args[0]); + test->handle.data = test; + if (!init_async(env, get_loop(env, false), &test->handle, stress_async_cb)) + return NULL; + if (pthread_create(&test->sender, NULL, stress_sender, test) != 0) { + napi_throw_error(env, NULL, "pthread_create failed"); + return NULL; + } + return NULL; +} + +static struct { + uv_async_t handle; + pthread_t sender; + atomic_uint sends; + atomic_bool stop; +} flood; + +static void *flood_sender(void *arg) { + while (!atomic_load(&flood.stop)) { + uv_async_send(&flood.handle); + atomic_fetch_add(&flood.sends, 1); + } + return NULL; +} + +// Returns after a whole send ran during the callback, so the handle is always +// pending again when the callback returns. +static void flood_async_cb(uv_async_t *handle) { + unsigned start = atomic_load(&flood.sends); + while (atomic_load(&flood.sends) - start < 2) + sched_yield(); +} + +// startAsyncFlood() sends from another thread in a tight loop until +// stopAsyncFlood() is called. +static napi_value start_async_flood(napi_env env, napi_callback_info info) { + if (!init_async(env, get_loop(env, false), &flood.handle, flood_async_cb)) + return NULL; + if (pthread_create(&flood.sender, NULL, flood_sender, NULL) != 0) + napi_throw_error(env, NULL, "pthread_create failed"); + return NULL; +} + +static napi_value stop_async_flood(napi_env env, napi_callback_info info) { + atomic_store(&flood.stop, true); + pthread_join(flood.sender, NULL); + uv_close((uv_handle_t *)&flood.handle, NULL); + return NULL; +} + +static uv_async_t unref_test_handle; + +// testAsyncRef() checks that uv_ref and uv_unref are idempotent. Returns +// uv_has_ref after each step. The handle is never closed, only unref'd, so the +// process must still exit. +static napi_value test_async_ref(napi_env env, napi_callback_info info) { + uv_handle_t *handle = (uv_handle_t *)&unref_test_handle; + if (!init_async(env, get_loop(env, false), &unref_test_handle, + unused_async_cb)) + return NULL; + int32_t observed[6]; + observed[0] = uv_has_ref(handle); + uv_unref(handle); + observed[1] = uv_has_ref(handle); + uv_unref(handle); + observed[2] = uv_has_ref(handle); + uv_ref(handle); + observed[3] = uv_has_ref(handle); + uv_ref(handle); + observed[4] = uv_has_ref(handle); + uv_unref(handle); + observed[5] = uv_has_ref(handle); + return make_int32_array(env, observed, 6); +} + +// --- uv_queue_work ---------------------------------------------------------- + +struct work_test { + uv_work_t req; + struct reporter reporter; + pthread_t loop_thread; + int work_ran; + int work_ran_off_the_loop_thread; +}; + +static void work_test_work_cb(uv_work_t *req) { + struct work_test *test = req->data; + test->work_ran = 1; + test->work_ran_off_the_loop_thread = + !pthread_equal(pthread_self(), test->loop_thread); + // The script has returned by now, so the request must keep the process alive. + usleep(20 * 1000); +} + +// Reports "after" with the status and one bit per check that passed: +// 1 work_cb ran 2 work_cb ran off the loop thread +// 4 after_work_cb is on the loop thread +// 8 data is unchanged 16 req->loop is the loop it was queued on +// 32 uv_req_get_type returns UV_WORK +// 64 uv_cancel on a finished request returns UV_EBUSY +static void work_test_after_work_cb(uv_work_t *req, int status) { + struct work_test *test = uv_req_get_data((uv_req_t *)req); + uv_loop_t *loop = NULL; + napi_get_uv_event_loop(test->reporter.env, &loop); + int32_t passed = 0; + if (test->work_ran) + passed |= 1; + if (test->work_ran_off_the_loop_thread) + passed |= 2; + if (pthread_equal(pthread_self(), test->loop_thread)) + passed |= 4; + if (req->data == test) + passed |= 8; + if (req->loop == loop) + passed |= 16; + if (uv_req_get_type((uv_req_t *)req) == UV_WORK) + passed |= 32; + if (uv_cancel((uv_req_t *)req) == UV_EBUSY) + passed |= 64; + report(&test->reporter, "after", status, passed); + reporter_destroy(&test->reporter); + free(test); +} + +static struct work_test *queue_work(napi_env env, napi_value callback) { + struct work_test *test = calloc(1, sizeof(*test)); + reporter_init(&test->reporter, env, callback); + test->loop_thread = pthread_self(); + uv_req_set_data((uv_req_t *)&test->req, test); // set before queueing + int rc = uv_queue_work(get_loop(env, false), &test->req, work_test_work_cb, + work_test_after_work_cb); + if (rc != 0) { + napi_throw_error(env, NULL, "uv_queue_work failed"); + return NULL; + } + return test; +} + +// testQueueWork(callback) reports `after 0 127`. +static napi_value test_queue_work(napi_env env, napi_callback_info info) { + napi_value args[1]; + get_args(env, info, args, 1); + queue_work(env, args[0]); + return NULL; +} + +// testCancelWork(callback) queues work and cancels it immediately. Returns +// uv_cancel's result: 0 if the request was still queued (the event is then +// `after UV_ECANCELED` with the work_cb bits clear), or UV_EBUSY if a pool +// thread had already started it (then `after 0` with the work_cb bits set). +static napi_value test_cancel_work(napi_env env, napi_callback_info info) { + napi_value args[1]; + get_args(env, info, args, 1); + struct work_test *test = queue_work(env, args[0]); + if (test == NULL) + return NULL; + napi_value result; + napi_create_int32(env, uv_cancel((uv_req_t *)&test->req), &result); + return result; +} + +// --- uv_idle_t / uv_prepare_t / uv_check_t ----------------------------------- + +struct watcher_test { + uv_idle_t idle; + uv_prepare_t prepare; + uv_check_t check; + struct reporter reporter; + int iterations; + bool close_requested; + int open; +}; + +// Reports "close <type name>", using the type that the init function stored. +static void watcher_test_close_cb(uv_handle_t *handle) { + struct watcher_test *test = uv_handle_get_data(handle); + char event[32]; + snprintf(event, sizeof(event), "close %s", + uv_handle_type_name(uv_handle_get_type(handle))); + report(&test->reporter, event, uv_is_closing(handle), uv_is_active(handle)); + if (--test->open == 0) { + reporter_destroy(&test->reporter); + free(test); + } +} + +// Called from a check callback, so this closes the handles, including the check +// handle itself, while the loop is iterating over them. +static void watcher_test_close_all(struct watcher_test *test) { + if (test->open == 3) + uv_close((uv_handle_t *)&test->idle, watcher_test_close_cb); + uv_close((uv_handle_t *)&test->prepare, watcher_test_close_cb); + uv_close((uv_handle_t *)&test->check, watcher_test_close_cb); + report(&test->reporter, "closing", uv_is_closing((uv_handle_t *)&test->check), + uv_is_active((uv_handle_t *)&test->check)); +} + +static struct watcher_test *init_watchers(napi_env env, napi_value callback, + bool with_idle) { + struct watcher_test *test = calloc(1, sizeof(*test)); + reporter_init(&test->reporter, env, callback); + uv_loop_t *loop = get_loop(env, false); + int rc = + uv_prepare_init(loop, &test->prepare) | uv_check_init(loop, &test->check); + test->open = 2; + if (with_idle) { + rc |= uv_idle_init(loop, &test->idle); + test->open = 3; + uv_handle_set_data((uv_handle_t *)&test->idle, test); + } + if (rc != 0) { + napi_throw_error(env, NULL, "init failed"); + return NULL; + } + uv_handle_set_data((uv_handle_t *)&test->prepare, test); + uv_handle_set_data((uv_handle_t *)&test->check, test); + return test; +} + +static void loop_idle_cb(uv_idle_t *handle) { + struct watcher_test *test = handle->data; + test->iterations++; + report(&test->reporter, "idle", test->iterations, + uv_is_active((uv_handle_t *)handle)); +} + +static void loop_prepare_cb(uv_prepare_t *handle) { + struct watcher_test *test = handle->data; + report(&test->reporter, "prepare", test->iterations, + uv_is_active((uv_handle_t *)handle)); +} + +static void loop_check_cb(uv_check_t *handle) { + struct watcher_test *test = handle->data; + report(&test->reporter, "check", test->iterations, + uv_is_active((uv_handle_t *)handle)); + if (test->iterations == 2) + watcher_test_close_all(test); +} + +// testLoopWatchers(callback) starts an idle, a prepare and a check handle. The +// idle handle counts iterations and keeps the poll from blocking, since nothing +// else would wake the loop. Each iteration runs the three in libuv's order, and +// the second iteration's check callback closes all three. Events: +// idle 1 1 / prepare 1 1 / check 1 1 / idle 2 1 / prepare 2 1 / check 2 1 +// closing 1 0 / close idle 1 0 / close prepare 1 0 / close check 1 0 +static napi_value test_loop_watchers(napi_env env, napi_callback_info info) { + napi_value args[1]; + get_args(env, info, args, 1); + struct watcher_test *test = init_watchers(env, args[0], true); + if (test == NULL) + return NULL; + if ((uv_idle_start(&test->idle, loop_idle_cb) | + uv_prepare_start(&test->prepare, loop_prepare_cb) | + uv_check_start(&test->check, loop_check_cb)) != 0) + napi_throw_error(env, NULL, "start failed"); + return NULL; +} + +static struct watcher_test *io_test; + +static void io_prepare_cb(uv_prepare_t *handle) { + struct watcher_test *test = handle->data; + test->iterations++; + report(&test->reporter, "prepare", test->iterations, + uv_is_active((uv_handle_t *)handle)); +} + +static void io_check_cb(uv_check_t *handle) { + struct watcher_test *test = handle->data; + report(&test->reporter, "check", test->iterations, + uv_is_active((uv_handle_t *)handle)); + if (test->close_requested) { + io_test = NULL; + watcher_test_close_all(test); + } +} + +// testPrepareAndCheckAroundIo(callback) starts a prepare and a check handle +// without an idle handle, so the loop blocks in its poll between the two until +// I/O arrives. The script does some socket I/O and calls requestClose() from +// its I/O callback. That callback runs inside the poll, between one iteration's +// prepare and check events, and that iteration's check callback closes both +// handles. +static napi_value test_prepare_and_check_around_io(napi_env env, + napi_callback_info info) { + napi_value args[1]; + get_args(env, info, args, 1); + struct watcher_test *test = init_watchers(env, args[0], false); + if (test == NULL) + return NULL; + if ((uv_prepare_start(&test->prepare, io_prepare_cb) | + uv_check_start(&test->check, io_check_cb)) != 0) { + napi_throw_error(env, NULL, "start failed"); + return NULL; + } + io_test = test; + return NULL; +} + +static napi_value request_close(napi_env env, napi_callback_info info) { + if (io_test != NULL) + io_test->close_requested = true; + return NULL; +} + +static uv_idle_t state_idle; +static uv_check_t state_check; +static void unused_check_cb(uv_check_t *handle) { (void)handle; } + +// testWatcherStates() checks the start/stop state machine synchronously. It +// closes both handles before the loop iterates, so no callback runs and the +// process exits. Otherwise the started idle handle would spin forever. +static napi_value test_watcher_states(napi_env env, napi_callback_info info) { + uv_loop_t *loop = get_loop(env, false); + uv_handle_t *idle = (uv_handle_t *)&state_idle; + uv_handle_t *check = (uv_handle_t *)&state_check; + napi_value result; + napi_create_object(env, &result); + set_int32(env, result, "init", + uv_idle_init(loop, &state_idle) | + uv_check_init(loop, &state_check)); + set_int32(env, result, "activeAfterInit", uv_is_active(idle)); + set_int32(env, result, "refAfterInit", uv_has_ref(idle)); + set_int32(env, result, "typeIsIdle", uv_handle_get_type(idle) == UV_IDLE); + set_int32(env, result, "typeIsCheck", uv_handle_get_type(check) == UV_CHECK); + set_int32(env, result, "loopIsSet", uv_handle_get_loop(idle) == loop); + set_int32(env, result, "start", uv_idle_start(&state_idle, unused_idle_cb)); + set_int32(env, result, "activeAfterStart", uv_is_active(idle)); + set_int32(env, result, "startAgain", + uv_idle_start(&state_idle, unused_idle_cb)); + set_int32(env, result, "stop", uv_idle_stop(&state_idle)); + set_int32(env, result, "activeAfterStop", uv_is_active(idle)); + set_int32(env, result, "stopAgain", uv_idle_stop(&state_idle)); + set_int32(env, result, "restart", uv_idle_start(&state_idle, unused_idle_cb)); + set_int32(env, result, "activeAfterRestart", uv_is_active(idle)); + uv_unref(idle); + set_int32(env, result, "refAfterUnref", uv_has_ref(idle)); + set_int32(env, result, "activeAfterUnref", uv_is_active(idle)); + uv_ref(idle); + set_int32(env, result, "refAfterRef", uv_has_ref(idle)); + set_int32(env, result, "checkStart", + uv_check_start(&state_check, unused_check_cb)); + uv_close(idle, NULL); + uv_close(check, NULL); + set_int32(env, result, "activeAfterClose", uv_is_active(idle)); + set_int32(env, result, "closingAfterClose", uv_is_closing(idle)); + set_int32(env, result, "refAfterClose", uv_has_ref(idle)); + set_int32(env, result, "startAfterClose", + uv_idle_start(&state_idle, unused_idle_cb)); + set_int32(env, result, "activeAfterStartAfterClose", uv_is_active(idle)); + return result; +} + +// --- uv_timer_t -------------------------------------------------------------- + +struct timer_test { + uv_timer_t handle; + struct reporter reporter; + int fires; +}; + +static void timer_test_close_cb(uv_handle_t *handle) { + struct timer_test *test = handle->data; + report(&test->reporter, "close timer", uv_is_closing(handle), + uv_is_active(handle)); + reporter_destroy(&test->reporter); + free(test); +} + +static struct timer_test *init_timer(napi_env env, napi_value callback) { + struct timer_test *test = calloc(1, sizeof(*test)); + reporter_init(&test->reporter, env, callback); + test->handle.data = test; + if (uv_timer_init(get_loop(env, false), &test->handle) != 0) { + napi_throw_error(env, NULL, "uv_timer_init failed"); + return NULL; + } + return test; +} + +static napi_value timer_state(napi_env env, struct timer_test *test) { + uv_handle_t *handle = (uv_handle_t *)&test->handle; + napi_value result; + napi_create_object(env, &result); + set_int32(env, result, "typeIsTimer", uv_handle_get_type(handle) == UV_TIMER); + set_int32(env, result, "isActive", uv_is_active(handle)); + set_int32(env, result, "hasRef", uv_has_ref(handle)); + set_int32(env, result, "dueIn", (int32_t)uv_timer_get_due_in(&test->handle)); + set_int32(env, result, "repeat", (int32_t)uv_timer_get_repeat(&test->handle)); + return result; +} + +static void timer_once_cb(uv_timer_t *handle) { + struct timer_test *test = handle->data; + // Like libuv, a one-shot timer is already stopped when its callback runs. + report(&test->reporter, "timer", uv_is_active((uv_handle_t *)handle), + (int32_t)uv_timer_get_due_in(handle)); + uv_close((uv_handle_t *)handle, timer_test_close_cb); +} + +// testTimer(callback) starts a 10ms one-shot timer. The script returns right +// away, and the timer keeps the process alive until it fires. Reports +// `timer 0 0`, then `close timer 1 0`. Returns the state right after the start. +static napi_value test_timer(napi_env env, napi_callback_info info) { + napi_value args[1]; + get_args(env, info, args, 1); + struct timer_test *test = init_timer(env, args[0]); + if (test == NULL) + return NULL; + if (uv_timer_start(&test->handle, timer_once_cb, 10, 0) != 0) { + napi_throw_error(env, NULL, "uv_timer_start failed"); + return NULL; + } + return timer_state(env, test); +} + +static void timer_repeat_cb(uv_timer_t *handle) { + struct timer_test *test = handle->data; + test->fires++; + // A repeating timer is already rearmed when its callback runs. + report(&test->reporter, "repeat", test->fires, + uv_is_active((uv_handle_t *)handle)); + if (test->fires < 3) + return; + uv_timer_stop(handle); + report(&test->reporter, "stopped", uv_is_active((uv_handle_t *)handle), + (int32_t)uv_timer_get_repeat(handle)); + uv_close((uv_handle_t *)handle, timer_test_close_cb); +} + +// testTimerRepeat(callback) starts a timer with a 1ms timeout and a 1ms repeat. +// Reports `repeat 1 1`, `repeat 2 1`, `repeat 3 1`, `stopped 0 1`, +// `close timer 1 0`. +static napi_value test_timer_repeat(napi_env env, napi_callback_info info) { + napi_value args[1]; + get_args(env, info, args, 1); + struct timer_test *test = init_timer(env, args[0]); + if (test == NULL) + return NULL; + uv_timer_start(&test->handle, timer_repeat_cb, 1, 1); + return timer_state(env, test); +} + +// testTimerAgain(callback) starts a one-hour timer, sets a 1ms repeat and calls +// uv_timer_again, which restarts it with the repeat. Reports the same events as +// testTimerRepeat. Returns { dueInBefore, again, dueInAfter, repeat }. +static napi_value test_timer_again(napi_env env, napi_callback_info info) { + napi_value args[1]; + get_args(env, info, args, 1); + struct timer_test *test = init_timer(env, args[0]); + if (test == NULL) + return NULL; + uv_timer_start(&test->handle, timer_repeat_cb, 60 * 60 * 1000, 0); + napi_value result; + napi_create_object(env, &result); + set_int32(env, result, "dueInBefore", + (int32_t)uv_timer_get_due_in(&test->handle)); + uv_timer_set_repeat(&test->handle, 1); + set_int32(env, result, "again", uv_timer_again(&test->handle)); + set_int32(env, result, "dueInAfter", + (int32_t)uv_timer_get_due_in(&test->handle)); + set_int32(env, result, "repeat", (int32_t)uv_timer_get_repeat(&test->handle)); + return result; +} + +// testTimerRestart(callback) starts a one-hour timer and starts it again with +// 10ms. uv_timer_start reschedules a started timer, so this reports the same +// events as testTimer. Returns the state after the restart. +static napi_value test_timer_restart(napi_env env, napi_callback_info info) { + napi_value args[1]; + get_args(env, info, args, 1); + struct timer_test *test = init_timer(env, args[0]); + if (test == NULL) + return NULL; + uv_timer_start(&test->handle, timer_once_cb, 60 * 60 * 1000, 0); + uv_timer_start(&test->handle, timer_once_cb, 10, 0); + return timer_state(env, test); +} + +// testTimerClosedBeforeItFires(callback) starts a one-hour timer and closes it +// immediately. The only event is `close timer 1 0`, and the process exits. +// Returns the state right after uv_close. +static napi_value test_timer_closed_before_it_fires(napi_env env, + napi_callback_info info) { + napi_value args[1]; + get_args(env, info, args, 1); + struct timer_test *test = init_timer(env, args[0]); + if (test == NULL) + return NULL; + uv_timer_start(&test->handle, timer_once_cb, 60 * 60 * 1000, 0); + uv_close((uv_handle_t *)&test->handle, timer_test_close_cb); + // Allowed until close_cb runs, but none of these may restart the timer. + uv_timer_start(&test->handle, timer_once_cb, 0, 0); + uv_timer_again(&test->handle); + uv_timer_stop(&test->handle); + return timer_state(env, test); +} + +static void timer_unref_cb(uv_timer_t *handle) { + struct timer_test *test = handle->data; + report(&test->reporter, "fired", 0, 0); +} + +// testTimerUnref(callback) starts a one-hour timer and unrefs it. The process +// must exit even though the timer is never stopped or closed, so `fired` is +// never reported. Returns the state after the unref. +static napi_value test_timer_unref(napi_env env, napi_callback_info info) { + napi_value args[1]; + get_args(env, info, args, 1); + struct timer_test *test = init_timer(env, args[0]); + if (test == NULL) + return NULL; + uv_timer_start(&test->handle, timer_unref_cb, 60 * 60 * 1000, 0); + uv_unref((uv_handle_t *)&test->handle); + return timer_state(env, test); +} + +// testNow() checks that uv_now advances while the process spins for 5ms of +// uv_hrtime. Returns { nowIsPositive, advancedMs }. +static napi_value test_now(napi_env env, napi_callback_info info) { + uv_loop_t *loop = get_loop(env, false); + uv_update_time(loop); + uint64_t before = uv_now(loop); + uint64_t start = uv_hrtime(); + while (uv_hrtime() - start < 5 * 1000 * 1000) + ; + uv_update_time(loop); + uint64_t after = uv_now(loop); + napi_value result; + napi_create_object(env, &result); + set_int32(env, result, "nowIsPositive", before > 0); + set_int32(env, result, "advancedMs", (int32_t)(after - before)); + return result; +} + napi_value Init(napi_env env, napi_value exports) { // Register all test functions napi_value fn; + napi_create_function(env, NULL, 0, test_loop_watchers, NULL, &fn); + napi_set_named_property(env, exports, "testLoopWatchers", fn); + + napi_create_function(env, NULL, 0, test_prepare_and_check_around_io, NULL, + &fn); + napi_set_named_property(env, exports, "testPrepareAndCheckAroundIo", fn); + + napi_create_function(env, NULL, 0, request_close, NULL, &fn); + napi_set_named_property(env, exports, "requestClose", fn); + + napi_create_function(env, NULL, 0, test_watcher_states, NULL, &fn); + napi_set_named_property(env, exports, "testWatcherStates", fn); + + napi_create_function(env, NULL, 0, test_timer, NULL, &fn); + napi_set_named_property(env, exports, "testTimer", fn); + + napi_create_function(env, NULL, 0, test_timer_repeat, NULL, &fn); + napi_set_named_property(env, exports, "testTimerRepeat", fn); + + napi_create_function(env, NULL, 0, test_timer_again, NULL, &fn); + napi_set_named_property(env, exports, "testTimerAgain", fn); + + napi_create_function(env, NULL, 0, test_timer_restart, NULL, &fn); + napi_set_named_property(env, exports, "testTimerRestart", fn); + + napi_create_function(env, NULL, 0, test_timer_closed_before_it_fires, NULL, + &fn); + napi_set_named_property(env, exports, "testTimerClosedBeforeItFires", fn); + + napi_create_function(env, NULL, 0, test_timer_unref, NULL, &fn); + napi_set_named_property(env, exports, "testTimerUnref", fn); + + napi_create_function(env, NULL, 0, test_now, NULL, &fn); + napi_set_named_property(env, exports, "testNow", fn); + + napi_create_function(env, NULL, 0, test_version, NULL, &fn); + napi_set_named_property(env, exports, "testVersion", fn); + + napi_create_function(env, NULL, 0, test_sizes_and_names, NULL, &fn); + napi_set_named_property(env, exports, "testSizesAndNames", fn); + + napi_create_function(env, NULL, 0, test_osfhandle, NULL, &fn); + napi_set_named_property(env, exports, "testOsfhandle", fn); + + napi_create_function(env, NULL, 0, test_loops, NULL, &fn); + napi_set_named_property(env, exports, "testLoops", fn); + + napi_create_function(env, NULL, 0, test_errors, NULL, &fn); + napi_set_named_property(env, exports, "testErrors", fn); + + napi_create_function(env, NULL, 0, test_async, NULL, &fn); + napi_set_named_property(env, exports, "testAsync", fn); + + napi_create_function(env, NULL, 0, test_async_close_with_send_pending, NULL, + &fn); + napi_set_named_property(env, exports, "testAsyncCloseWithSendPending", fn); + + napi_create_function(env, NULL, 0, test_async_stress, NULL, &fn); + napi_set_named_property(env, exports, "testAsyncStress", fn); + + napi_create_function(env, NULL, 0, start_async_flood, NULL, &fn); + napi_set_named_property(env, exports, "startAsyncFlood", fn); + + napi_create_function(env, NULL, 0, stop_async_flood, NULL, &fn); + napi_set_named_property(env, exports, "stopAsyncFlood", fn); + + napi_create_function(env, NULL, 0, test_async_ref, NULL, &fn); + napi_set_named_property(env, exports, "testAsyncRef", fn); + + napi_create_function(env, NULL, 0, test_queue_work, NULL, &fn); + napi_set_named_property(env, exports, "testQueueWork", fn); + + napi_create_function(env, NULL, 0, test_cancel_work, NULL, &fn); + napi_set_named_property(env, exports, "testCancelWork", fn); + napi_create_function(env, NULL, 0, test_mutex_init_destroy, NULL, &fn); napi_set_named_property(env, exports, "testMutexInitDestroy", fn); @@ -206,6 +1269,9 @@ napi_value Init(napi_env env, napi_value exports) { napi_create_function(env, NULL, 0, test_uv_once, NULL, &fn); napi_set_named_property(env, exports, "testUvOnce", fn); + napi_create_function(env, NULL, 0, test_cwd, NULL, &fn); + napi_set_named_property(env, exports, "testCwd", fn); + napi_create_function(env, NULL, 0, test_hrtime, NULL, &fn); napi_set_named_property(env, exports, "testHrtime", fn); diff --git a/test/napi/uv.test.ts b/test/napi/uv.test.ts index bd26f07c80d5..d3f248cb9f24 100644 --- a/test/napi/uv.test.ts +++ b/test/napi/uv.test.ts @@ -96,6 +96,21 @@ describe.if(!isWindows)("uv stubs", () => { expect(nativeModule.testUvOnce()).toBe(1); }); + test("uv_cwd returns the cwd and rejects a missing or too-small buffer", () => { + const result = nativeModule.testCwd(); + const cwdBytes = Buffer.byteLength(process.cwd()); + expect(result.rc).toBe(0); + expect(result.cwd).toBe(process.cwd()); + expect(result.size).toBe(cwdBytes); + + expect(result.nullBuffer).toBe(result.UV_EINVAL); + expect(result.nullSize).toBe(result.UV_EINVAL); + expect(result.zeroSize).toBe(result.UV_EINVAL); + + expect(result.smallRc).toBe(result.UV_ENOBUFS); + expect(result.smallRequired).toBe(cwdBytes + 1); + }); + test("hrtime", () => { const result = nativeModule.testHrtime(); @@ -183,4 +198,507 @@ describe.if(!isWindows)("uv stubs", () => { }); expect(exitCode).toBe(0); }); + + // The loop-backed functions (uv_default_loop, uv_queue_work, and the async, + // idle, prepare, check and timer handles) and their header-only helpers run in + // a child process. The child's exit is part of the test: a ref'd handle or a + // queued request must keep it alive, and an unref'd handle must not. `script` + // can use `addon` and `report`, which prints one line per event from the addon. + async function runInChild(script: string) { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const addon = require(${JSON.stringify(addonPath)}); + const report = (event, a, b) => console.log(event, a, b); + ${script} + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + test.concurrent("uv_version and uv_version_string agree", async () => { + const { stdout, stderr, exitCode } = await runInChild(` + console.log(JSON.stringify(addon.testVersion())); + `); + expect(stderr).toBe(""); + const { version, versionString } = JSON.parse(stdout); + expect(versionString).toMatch(/^\d+\.\d+\.\d+$/); + const [major, minor, patch] = versionString.split(".").map(Number); + expect(version).toBe((major << 16) | (minor << 8) | patch); + expect(exitCode).toBe(0); + }); + + test.concurrent("uv_handle_size, uv_req_size and the type names match the headers", async () => { + const { stdout, stderr, exitCode } = await runInChild(`console.log(JSON.stringify(addon.testSizesAndNames()));`); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + asyncSizeMatches: true, + timerSizeMatches: true, + unknownHandleSizeIsMinusOne: true, + workSizeMatches: true, + unknownReqSizeIsMinusOne: true, + // sizeof(uv_async_t) on 64-bit Unix. Bun's private fields must fit in it. + asyncSize: 128, + asyncName: "async", + pipeName: "pipe", + workName: "work", + unknownHandleNameIsNull: true, + unknownReqNameIsNull: true, + }); + expect(exitCode).toBe(0); + }); + + test.concurrent("uv_get_osfhandle and uv_open_osfhandle return the fd unchanged", async () => { + const { stdout, stderr, exitCode } = await runInChild( + `console.log(addon.testOsfhandle(2), addon.testOsfhandle(41));`, + ); + expect(stderr).toBe(""); + expect(stdout).toBe("2 41\n"); + expect(exitCode).toBe(0); + }); + + test.concurrent("uv_default_loop is the main thread's napi_get_uv_event_loop", async () => { + const { stdout, stderr, exitCode } = await runInChild(`console.log(JSON.stringify(addon.testLoops()));`); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + napiLoopIsSet: true, + napiLoopIsDefaultLoop: true, + defaultLoopIsSameFromThread: true, + loopDataRoundTrips: true, + }); + expect(exitCode).toBe(0); + }); + + test.concurrent("returns UV_EINVAL for a missing loop, a missing callback or the wrong handle type", async () => { + const { stdout, stderr, exitCode } = await runInChild(`console.log(JSON.stringify(addon.testErrors()));`); + expect(stderr).toBe(""); + const EINVAL = -constants.errno.EINVAL; + expect(JSON.parse(stdout)).toEqual([ + EINVAL, // uv_async_init without a loop + EINVAL, // uv_check_init without a loop + EINVAL, // uv_timer_init without a loop + EINVAL, // uv_queue_work without a work_cb + EINVAL, // uv_cancel on a request that isn't a work request + EINVAL, // uv_idle_start without a callback + EINVAL, // uv_timer_start without a callback + EINVAL, // uv_timer_again on a timer that was never started + EINVAL, // uv_prepare_start on an idle handle + EINVAL, // uv_prepare_stop on an idle handle + EINVAL, // uv_idle_start on a timer + EINVAL, // uv_timer_start on an idle handle + EINVAL, // uv_timer_stop on an idle handle + EINVAL, // uv_timer_again on an idle handle + ]); + expect(exitCode).toBe(0); + }); + + // Three sends before the loop iterates coalesce into one callback, and a send + // from inside the callback triggers another. uv_close stops the handle + // immediately and runs close_cb on a later iteration. The script returns right + // after the call, so only the ref'd handle keeps the process alive. + const asyncEvents = ["async 1 1", "async 2 1", "closing 1 0", "close 1 1", ""].join("\n"); + + test.concurrent.each([ + ["napi_get_uv_event_loop", "false"], + ["uv_default_loop", "true"], + ])("uv_async_t on the loop from %s fires when sent from the JS thread", async (_, useDefaultLoop) => { + const { stdout, stderr, exitCode } = await runInChild(` + const observed = addon.testAsync(${useDefaultLoop}, false, report); + console.log("after init", JSON.stringify(observed)); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("after init [1,0,1]\n" + asyncEvents); + expect(exitCode).toBe(0); + }); + + test.concurrent("uv_async_t fires when sent from another thread after the script has returned", async () => { + const { stdout, stderr, exitCode } = await runInChild(`addon.testAsync(false, true, report);`); + expect(stderr).toBe(""); + expect(stdout).toBe(asyncEvents); + expect(exitCode).toBe(0); + }); + + test.concurrent("uv_async_t in a Worker uses the Worker's own loop", async () => { + const { stdout, stderr, exitCode } = await runInChild(` + const { Worker } = require("node:worker_threads"); + const worker = new Worker( + \` + const { parentPort } = require("node:worker_threads"); + const addon = require(${JSON.stringify(addonPath)}); + parentPort.postMessage(JSON.stringify(addon.testLoops())); + addon.testAsync(false, true, (event, a, b) => parentPort.postMessage([event, a, b].join(" "))); + \`, + { eval: true }, + ); + worker.on("message", message => console.log(message)); + worker.on("exit", code => console.log("worker exited", code)); + `); + expect(stderr).toBe(""); + expect(stdout).toBe( + JSON.stringify({ + napiLoopIsSet: true, + napiLoopIsDefaultLoop: false, + defaultLoopIsSameFromThread: true, + loopDataRoundTrips: true, + }) + + "\n" + + asyncEvents + + "worker exited 0\n", + ); + expect(exitCode).toBe(0); + }); + + test.concurrent("uv_async_send from another thread never loses a wakeup, and a burst can race uv_close", async () => { + // A lost wakeup makes the sender thread wait forever, so the child hangs + // and this times out. 201 is STRESS_ROUNDS + 1 in uv_impl.c. + const { stdout, stderr, exitCode } = await runInChild(`addon.testAsyncStress(report);`); + expect(stderr).toBe(""); + expect(stdout).toBe("done 201 1\n"); + expect(exitCode).toBe(0); + }); + + test.concurrent("a thread that calls uv_async_send in a tight loop doesn't starve JS timers", async () => { + // The child hangs if the timer never fires, and this times out. + const { stdout, stderr, exitCode } = await runInChild(` + addon.startAsyncFlood(); + setTimeout(() => { + addon.stopAsyncFlood(); + console.log("timer fired"); + }, 1); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("timer fired\n"); + expect(exitCode).toBe(0); + }); + + test.concurrent("uv_close with a send pending only runs close_cb, and a later close or send is a no-op", async () => { + const { stdout, stderr, exitCode } = await runInChild(` + console.log("after close", JSON.stringify(addon.testAsyncCloseWithSendPending(report))); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("after close [0,1,0]\nclose 1 1\n"); + expect(exitCode).toBe(0); + }); + + test.concurrent("uv_ref and uv_unref toggle uv_has_ref, and an unref'd handle lets the process exit", async () => { + // The handle is still open but unref'd, so the test fails if the child hangs. + const { stdout, stderr, exitCode } = await runInChild(`console.log(JSON.stringify(addon.testAsyncRef()));`); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([1, 0, 0, 1, 1, 0]); + expect(exitCode).toBe(0); + }); + + test.concurrent("an exception thrown by JS called from async_cb is reported as uncaught", async () => { + const { stdout, stderr, exitCode } = await runInChild(` + addon.testAsync(false, false, () => { throw new Error("thrown from inside async_cb"); }); + `); + expect(stdout).toBe(""); + expect(stderr).toContain("thrown from inside async_cb"); + expect(exitCode).toBe(1); + }); + + test.concurrent("uv_queue_work runs work_cb on the pool and after_work_cb on the loop thread", async () => { + const { stdout, stderr, exitCode } = await runInChild(`addon.testQueueWork(report);`); + expect(stderr).toBe(""); + // 127 means every check in work_test_after_work_cb (uv_impl.c) passed. + expect(stdout).toBe("after 0 127\n"); + expect(exitCode).toBe(0); + }); + + test.concurrent("uv_cancel either cancels queued work or returns UV_EBUSY", async () => { + const { stdout, stderr, exitCode } = await runInChild(`console.log("cancel", addon.testCancelWork(report));`); + expect(stderr).toBe(""); + // uv_cancel races with the pool picking up the request, so either outcome + // is valid. 124 is 127 without the two work_cb bits. + const cancelled = `cancel 0\nafter ${-constants.errno.ECANCELED} 124\n`; + const tooLate = `cancel ${-constants.errno.EBUSY}\nafter 0 127\n`; + expect([cancelled, tooLate]).toContain(stdout); + expect(exitCode).toBe(0); + }); + + // The handle tests below print the addon's returned state as one JSON line, + // then one line per event. + function stateAndEvents(stdout: string) { + const [state, ...events] = stdout.split("\n"); + return { state: JSON.parse(state), events: events.join("\n") }; + } + + const loopWatcherEvents = [ + "idle 1 1", + "prepare 1 1", + "check 1 1", + "idle 2 1", + "prepare 2 1", + "check 2 1", + "closing 1 0", + "close idle 1 0", + "close prepare 1 0", + "close check 1 0", + "", + ].join("\n"); + + test.concurrent("idle, prepare and check handles run once per loop iteration, in that order", async () => { + // Only the idle handle keeps the loop iterating. If it let the poll block, + // the child would hang here. See test_loop_watchers in uv_impl.c. + const { stdout, stderr, exitCode } = await runInChild(`addon.testLoopWatchers(report);`); + expect(stderr).toBe(""); + expect(stdout).toBe(loopWatcherEvents); + expect(exitCode).toBe(0); + }); + + test.concurrent("a Worker's watchers run on the Worker's loop", async () => { + const { stdout, stderr, exitCode } = await runInChild(` + const { Worker } = require("node:worker_threads"); + const worker = new Worker( + \` + const { parentPort } = require("node:worker_threads"); + require(${JSON.stringify(addonPath)}).testLoopWatchers((event, a, b) => parentPort.postMessage([event, a, b].join(" "))); + \`, + { eval: true }, + ); + worker.on("message", message => console.log(message)); + worker.on("exit", code => console.log("worker exited", code)); + `); + expect(stderr).toBe(""); + expect(stdout).toBe(loopWatcherEvents + "worker exited 0\n"); + expect(exitCode).toBe(0); + }); + + test.concurrent("prepare runs before the poll and check runs after it", async () => { + // The server's data callback runs inside the poll, and the addon closes both + // handles from that iteration's check callback. The number of iterations + // before the data arrives varies. + const { stdout, stderr, exitCode } = await runInChild(` + addon.testPrepareAndCheckAroundIo(report); + const server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + data(socket) { + console.log("io"); + addon.requestClose(); + socket.end(); + server.stop(true); + }, + }, + }); + Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + socket: { + open(socket) { + socket.write("x"); + }, + data() {}, + }, + }); + `); + expect(stderr).toBe(""); + const lines = stdout.split("\n"); + const io = lines.indexOf("io"); + expect(io).toBeGreaterThan(0); + const iteration = (io + 1) / 2; + const expected: string[] = []; + for (let i = 1; i < iteration; i++) { + expected.push(`prepare ${i} 1`, `check ${i} 1`); + } + expected.push( + `prepare ${iteration} 1`, + "io", + `check ${iteration} 1`, + "closing 1 0", + "close prepare 1 0", + "close check 1 0", + "", + ); + expect(lines).toEqual(expected); + expect(exitCode).toBe(0); + }); + + test.concurrent("a watcher's start/stop state machine matches libuv", async () => { + // The addon closes the handles before the script returns. The child must + // exit, because a started idle handle would otherwise spin forever. + const { stdout, stderr, exitCode } = await runInChild(`console.log(JSON.stringify(addon.testWatcherStates()));`); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + init: 0, + activeAfterInit: 0, + refAfterInit: 1, + typeIsIdle: 1, + typeIsCheck: 1, + loopIsSet: 1, + start: 0, + activeAfterStart: 1, + startAgain: 0, + stop: 0, + activeAfterStop: 0, + stopAgain: 0, + restart: 0, + activeAfterRestart: 1, + refAfterUnref: 0, + activeAfterUnref: 1, + refAfterRef: 1, + checkStart: 0, + activeAfterClose: 0, + closingAfterClose: 1, + // The ref flag is independent of closing, as in libuv. + refAfterClose: 1, + // Starting a closing handle is a no-op that reports success, as in libuv. + startAfterClose: 0, + activeAfterStartAfterClose: 0, + }); + expect(exitCode).toBe(0); + }); + + test.concurrent("an exception thrown by JS called from a watcher callback is reported as uncaught", async () => { + // All six callbacks throw. The loop must still reach the check callback that + // closes the handles, or the child never exits. + const { stdout, stderr, exitCode } = await runInChild(` + addon.testLoopWatchers(() => { throw new Error("thrown from inside a watcher callback"); }); + `); + expect(stdout).toBe(""); + expect(stderr).toContain("thrown from inside a watcher callback"); + expect(exitCode).toBe(1); + }); + + const oneShotTimerEvents = "timer 0 0\nclose timer 1 0\n"; + // The third callback stops and closes the timer (timer_repeat_cb in uv_impl.c). + const repeatingTimerEvents = "repeat 1 1\nrepeat 2 1\nrepeat 3 1\nstopped 0 1\nclose timer 1 0\n"; + const HOUR = 60 * 60 * 1000; + + test.concurrent("a one-shot uv_timer_t keeps the process alive and fires once", async () => { + const { stdout, stderr, exitCode } = await runInChild(`console.log(JSON.stringify(addon.testTimer(report)));`); + expect(stderr).toBe(""); + const { state, events } = stateAndEvents(stdout); + expect(state).toEqual({ typeIsTimer: 1, isActive: 1, hasRef: 1, dueIn: expect.any(Number), repeat: 0 }); + expect(state.dueIn).toBeGreaterThanOrEqual(0); + expect(state.dueIn).toBeLessThanOrEqual(10); + expect(events).toBe(oneShotTimerEvents); + expect(exitCode).toBe(0); + }); + + test.concurrent("uv_timer_t fires when started from inside a JS timer callback, alongside a JS timer", async () => { + // The addon arms its timers while the timer heap is being drained, like an + // addon constructor called from a setTimeout callback. The order in which + // the three timers fire depends on the scheduler, so compare sorted events. + const { stdout, stderr, exitCode } = await runInChild(` + setTimeout(() => { + console.log(JSON.stringify(addon.testTimer(report))); + addon.testTimerRepeat(report); + setTimeout(() => console.log("js timer"), 10); + }, 1); + `); + expect(stderr).toBe(""); + const { state, events } = stateAndEvents(stdout); + expect(state).toEqual({ typeIsTimer: 1, isActive: 1, hasRef: 1, dueIn: expect.any(Number), repeat: 0 }); + const lines = (s: string) => s.trimEnd().split("\n"); + expect(lines(events).sort()).toEqual( + [...lines(oneShotTimerEvents), ...lines(repeatingTimerEvents), "js timer"].sort(), + ); + expect(exitCode).toBe(0); + }); + + test.concurrent("a uv_timer_t in a Worker fires on the Worker's loop", async () => { + const { stdout, stderr, exitCode } = await runInChild(` + const { Worker } = require("node:worker_threads"); + const worker = new Worker( + \` + const { parentPort } = require("node:worker_threads"); + const state = require(${JSON.stringify(addonPath)}).testTimer((event, a, b) => parentPort.postMessage([event, a, b].join(" "))); + parentPort.postMessage("started " + state.isActive); + \`, + { eval: true }, + ); + worker.on("message", message => console.log(message)); + worker.on("exit", code => console.log("worker exited", code)); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("started 1\n" + oneShotTimerEvents + "worker exited 0\n"); + expect(exitCode).toBe(0); + }); + + test.concurrent("a repeating uv_timer_t fires until it is stopped", async () => { + const { stdout, stderr, exitCode } = await runInChild( + `console.log(JSON.stringify(addon.testTimerRepeat(report)));`, + ); + expect(stderr).toBe(""); + const { state, events } = stateAndEvents(stdout); + expect(state).toEqual({ typeIsTimer: 1, isActive: 1, hasRef: 1, dueIn: expect.any(Number), repeat: 1 }); + expect(state.dueIn).toBeLessThanOrEqual(1); + expect(events).toBe(repeatingTimerEvents); + expect(exitCode).toBe(0); + }); + + test.concurrent("uv_timer_set_repeat and uv_timer_again restart a timer with its repeat", async () => { + const { stdout, stderr, exitCode } = await runInChild(`console.log(JSON.stringify(addon.testTimerAgain(report)));`); + expect(stderr).toBe(""); + const { state, events } = stateAndEvents(stdout); + expect(state).toEqual({ dueInBefore: expect.any(Number), again: 0, dueInAfter: expect.any(Number), repeat: 1 }); + expect(state.dueInBefore).toBeGreaterThan(HOUR / 2); + expect(state.dueInBefore).toBeLessThanOrEqual(HOUR); + expect(state.dueInAfter).toBeLessThanOrEqual(1); + expect(events).toBe(repeatingTimerEvents); + expect(exitCode).toBe(0); + }); + + test.concurrent("uv_timer_start on a started timer reschedules it", async () => { + const { stdout, stderr, exitCode } = await runInChild( + `console.log(JSON.stringify(addon.testTimerRestart(report)));`, + ); + expect(stderr).toBe(""); + const { state, events } = stateAndEvents(stdout); + expect(state).toEqual({ typeIsTimer: 1, isActive: 1, hasRef: 1, dueIn: expect.any(Number), repeat: 0 }); + expect(state.dueIn).toBeLessThanOrEqual(10); + expect(events).toBe(oneShotTimerEvents); + expect(exitCode).toBe(0); + }); + + test.concurrent("uv_close on a started timer only runs close_cb, and the process exits", async () => { + const { stdout, stderr, exitCode } = await runInChild( + `console.log(JSON.stringify(addon.testTimerClosedBeforeItFires(report)));`, + ); + expect(stderr).toBe(""); + expect(stateAndEvents(stdout)).toEqual({ + state: { typeIsTimer: 1, isActive: 0, hasRef: 1, dueIn: 0, repeat: 0 }, + events: "close timer 1 0\n", + }); + expect(exitCode).toBe(0); + }); + + test.concurrent("an unref'd timer lets the process exit", async () => { + // The timer is never stopped. If unref didn't work, the child would hang for an hour. + const { stdout, stderr, exitCode } = await runInChild(`console.log(JSON.stringify(addon.testTimerUnref(report)));`); + expect(stderr).toBe(""); + const { state, events } = stateAndEvents(stdout); + expect(state).toEqual({ typeIsTimer: 1, isActive: 1, hasRef: 0, dueIn: expect.any(Number), repeat: 0 }); + expect(state.dueIn).toBeGreaterThan(HOUR / 2); + expect(events).toBe(""); + expect(exitCode).toBe(0); + }); + + test.concurrent("an exception thrown by JS called from timer_cb is reported as uncaught", async () => { + const { stdout, stderr, exitCode } = await runInChild(` + addon.testTimer(() => { throw new Error("thrown from inside timer_cb"); }); + `); + expect(stdout).toBe(""); + expect(stderr).toContain("thrown from inside timer_cb"); + expect(exitCode).toBe(1); + }); + + test.concurrent("uv_now advances", async () => { + const { stdout, stderr, exitCode } = await runInChild(`console.log(JSON.stringify(addon.testNow()));`); + expect(stderr).toBe(""); + const result = JSON.parse(stdout); + expect(result).toEqual({ nowIsPositive: 1, advancedMs: expect.any(Number) }); + // The addon spins for 5ms between the two readings. + expect(result.advancedMs).toBeGreaterThanOrEqual(4); + expect(exitCode).toBe(0); + }); }); diff --git a/test/regression/issue/30613.test.ts b/test/regression/issue/30613.test.ts new file mode 100644 index 000000000000..9aab1bf80f02 --- /dev/null +++ b/test/regression/issue/30613.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, isArm64 } from "harness"; + +// simdutf treats an unknown implementation name like a CPU that none of its kernels supports. +// arm64 builds have a single kernel and ignore the variable. +test.skipIf(isArm64)("fails fast with a clear error when simdutf has no supported implementation", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", "console.log('unreachable')"], + env: { ...bunEnv, SIMDUTF_FORCE_IMPLEMENTATION: "none" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toContain("which Bun requires"); + expect(stderr).toContain("SIMDUTF_FORCE_IMPLEMENTATION"); + expect(stdout).toBe(""); + expect(exitCode).toBe(134); +}); diff --git a/test/v8/v8-module/main.cpp b/test/v8/v8-module/main.cpp index 76bcb331cec2..6575b69f5156 100644 --- a/test/v8/v8-module/main.cpp +++ b/test/v8/v8-module/main.cpp @@ -890,6 +890,43 @@ void test_uv_os_getppid(const FunctionCallbackInfo<Value> &info) { return ok(info); } +struct QueuedWork { + uv_work_t req; + Isolate *isolate; + Global<Function> callback; + int result = 0; +}; + +// Queues work the way Nan::AsyncQueueWorker does. after_work_cb only opens a +// v8::HandleScope, like Nan::AsyncExecuteComplete. +void queue_work_and_call(const FunctionCallbackInfo<Value> &info) { + Isolate *isolate = info.GetIsolate(); + auto *work = new QueuedWork; + work->req.data = work; + work->isolate = isolate; + work->callback.Reset(isolate, info[0].As<Function>()); + int rc = uv_queue_work( + node::GetCurrentEventLoop(isolate), &work->req, + [](uv_work_t *req) { static_cast<QueuedWork *>(req->data)->result = 42; }, + [](uv_work_t *req, int status) { + auto *work = static_cast<QueuedWork *>(req->data); + Isolate *isolate = work->isolate; + { + HandleScope scope(isolate); + Local<Value> args[] = { + String::NewFromUtf8(isolate, "done").ToLocalChecked(), + Number::New(isolate, status), + Number::New(isolate, work->result), + }; + Local<Function>::New(isolate, work->callback) + ->Call(isolate->GetCurrentContext(), Undefined(isolate), 3, args) + .ToLocalChecked(); + } + delete work; + }); + info.GetReturnValue().Set(Number::New(isolate, rc)); +} + void test_v8_object_get_by_key(const FunctionCallbackInfo<Value> &info) { printf("Testing Object::Get(context, key)...\n"); @@ -1885,6 +1922,21 @@ void test_v8_exception(const FunctionCallbackInfo<Value> &info) { isolate->ThrowException(err); } +void test_v8_exception_empty_message(const FunctionCallbackInfo<Value> &info) { + Isolate *isolate = info.GetIsolate(); + Local<String> empty = String::NewFromUtf8(isolate, "").ToLocalChecked(); + + Local<Value> errors[] = {Exception::Error(empty), + Exception::TypeError(empty)}; + info.GetReturnValue().Set(Array::New(isolate, errors, 2)); +} + +void test_v8_throw_error_empty_message( + const FunctionCallbackInfo<Value> &info) { + Isolate *isolate = info.GetIsolate(); + isolate->ThrowError(String::NewFromUtf8(isolate, "").ToLocalChecked()); +} + void test_v8_aligned_pointer_in_internal_field( const FunctionCallbackInfo<Value> &info) { Isolate *isolate = info.GetIsolate(); @@ -2184,6 +2236,7 @@ void initialize(Local<Object> exports, Local<Value> module, return_accessor_value_from_inner_scope); NODE_SET_METHOD(exports, "test_uv_os_getpid", test_uv_os_getpid); NODE_SET_METHOD(exports, "test_uv_os_getppid", test_uv_os_getppid); + NODE_SET_METHOD(exports, "queue_work_and_call", queue_work_and_call); NODE_SET_METHOD(exports, "test_v8_object_get_by_key", test_v8_object_get_by_key); NODE_SET_METHOD(exports, "test_v8_object_get_by_index", @@ -2226,6 +2279,10 @@ void initialize(Local<Object> exports, Local<Value> module, test_v8_getfunction_memoized); NODE_SET_METHOD(exports, "test_v8_map", test_v8_map); NODE_SET_METHOD(exports, "test_v8_exception", test_v8_exception); + NODE_SET_METHOD(exports, "test_v8_exception_empty_message", + test_v8_exception_empty_message); + NODE_SET_METHOD(exports, "test_v8_throw_error_empty_message", + test_v8_throw_error_empty_message); NODE_SET_METHOD(exports, "test_v8_aligned_pointer_in_internal_field", test_v8_aligned_pointer_in_internal_field); NODE_SET_METHOD(exports, "test_v8_cpu_profiler", test_v8_cpu_profiler); diff --git a/test/v8/v8-module/module.js b/test/v8/v8-module/module.js index 2625a5035dde..d0c9a45246c4 100644 --- a/test/v8/v8-module/module.js +++ b/test/v8/v8-module/module.js @@ -185,6 +185,22 @@ module.exports = debugMode => { } }, + test_v8_exception_empty_message() { + for (const e of nativeModule.test_v8_exception_empty_message()) { + console.log(e.constructor.name, "message =", JSON.stringify(e.message), "own =", Object.hasOwn(e, "message")); + } + }, + + test_v8_throw_error_empty_message() { + try { + nativeModule.test_v8_throw_error_empty_message(); + console.log("did not throw"); + } catch (e) { + const own = Object.hasOwn(e, "message"); + console.log("caught", e.constructor.name, "message =", JSON.stringify(e.message), "own =", own); + } + }, + print_native_function() { nativeModule.print_values_from_js(nativeModule.create_function_with_data()); }, @@ -259,6 +275,11 @@ module.exports = debugMode => { keep(); }, + test_uv_queue_work_on_current_event_loop() { + const rc = nativeModule.queue_work_and_call((...args) => console.log("after_work_cb", ...args)); + console.log("uv_queue_work", rc); + }, + test_v8_object_get_set_exceptions() { for (const key of [0, "key"]) { for (const access of ["get", "set"]) { diff --git a/test/v8/v8.test.ts b/test/v8/v8.test.ts index 90c0b92cfc96..0a8ffdb881cb 100644 --- a/test/v8/v8.test.ts +++ b/test/v8/v8.test.ts @@ -469,6 +469,14 @@ describe.skipIf(!canBuildNodeAddons()).todoIf(isBroken && isMusl)("node:v8", () it("Exception::Error/TypeError create throwable Error objects", async () => { await checkSameOutput("test_v8_exception"); }); + it("Exception::Error/TypeError accept an empty message", async () => { + const output = await checkSameOutput("test_v8_exception_empty_message"); + expect(output).toBe(['Error message = "" own = true', 'TypeError message = "" own = true'].join("\n")); + }); + it("Isolate::ThrowError accepts an empty message", async () => { + const output = await checkSameOutput("test_v8_throw_error_empty_message"); + expect(output).toBe('caught Error message = "" own = true'); + }); }); describe("Aligned internal fields", () => { @@ -503,6 +511,13 @@ describe.skipIf(!canBuildNodeAddons()).todoIf(isBroken && isMusl)("node:v8", () await checkSameOutput("test_uv_os_getppid"); }); }); + + describe("uv_queue_work", () => { + // TODO: on Windows libuv calls after_work_cb with no handle scope open, so creating a handle aborts. + it.skipIf(isWindows)("runs on node::GetCurrentEventLoop and after_work_cb can use a v8::HandleScope", async () => { + await checkSameOutput("test_uv_queue_work_on_current_event_loop"); + }); + }); }); async function checkSameOutput(testName: string, args?: string, thisValue?: any) { @@ -822,6 +837,251 @@ for (const [hi, lo] of [ ); }); +// Node runs every Node-API callback inside a v8::HandleScope, so an addon that calls the V8 API +// from one doesn't need to open its own scope. With the Node 26 headers, an addon's +// v8::HandleScope is inlined and never calls into Bun either. +describe.skipIf(!canBuildNodeAddons()).todoIf(isBroken && isMusl)("V8 API from Node-API callbacks", () => { + it( + "creates handles in the Node-API scope, keeps them alive, and releases them when it closes", + async () => { + using dir = tempDir( + "v8-api-from-napi", + standaloneAddonFiles( + "hybridnapi", + `// A Node-API addon whose callbacks also use the V8 C++ API. Node runs every Node-API callback +// inside a v8::HandleScope, so the callbacks below only open one when that's what they test. +// Each callback returns its result as a string so run.js can print everything in order. +#include <node.h> +#include <node_api.h> +#include <cstdio> +#include <cstring> + +namespace hybrid_test { + +using namespace v8; + +Global<Array> stashed_array; + +napi_value to_napi_string(napi_env env, const char* text) { + napi_value result; + napi_create_string_utf8(env, text, NAPI_AUTO_LENGTH, &result); + return result; +} + +// Bun's callback overload of Array::New opens an EscapableHandleScope around the callbacks. +napi_value describe_new_values(napi_env env, Isolate* isolate) { + Local<Array> array = Array::New(isolate, 3); + Local<String> string = + String::NewFromUtf8(isolate, "string from v8", NewStringType::kNormal).ToLocalChecked(); + int next = 0; + Local<Array> from_callback = + Array::New(isolate->GetCurrentContext(), 4, + [&]() -> MaybeLocal<Value> { return Number::New(isolate, next++); }) + .ToLocalChecked(); + char string_text[64]; + string->WriteUtf8V2(isolate, string_text, sizeof string_text, String::WriteFlags::kNullTerminate); + char result[128]; + snprintf(result, sizeof result, "arrays of length %u and %u, '%s'", array->Length(), + from_callback->Length(), string_text); + return to_napi_string(env, result); +} + +napi_value without_scope(napi_env env, napi_callback_info info) { + return describe_new_values(env, Isolate::GetCurrent()); +} + +napi_value with_scope(napi_env env, napi_callback_info info) { + Isolate* isolate = Isolate::GetCurrent(); + HandleScope scope(isolate); + return describe_new_values(env, isolate); +} + +napi_value with_escapable_scope(napi_env env, napi_callback_info info) { + Isolate* isolate = Isolate::GetCurrent(); + Local<String> escaped; + { + EscapableHandleScope scope(isolate); + escaped = scope.Escape( + String::NewFromUtf8(isolate, "escaped string", NewStringType::kNormal).ToLocalChecked()); + } + char text[64]; + escaped->WriteUtf8V2(isolate, text, sizeof text, String::WriteFlags::kNullTerminate); + return to_napi_string(env, text); +} + +napi_value constructor(napi_env env, napi_callback_info info) { + napi_value description = describe_new_values(env, Isolate::GetCurrent()); + napi_value this_arg; + size_t argc = 0; + napi_get_cb_info(env, info, &argc, nullptr, &this_arg, nullptr); + napi_set_named_property(env, this_arg, "description", description); + return this_arg; +} + +void call_argument(napi_env env, napi_callback_info info, napi_value* result) { + size_t argc = 1; + napi_value callback; + napi_get_cb_info(env, info, &argc, &callback, nullptr, nullptr); + napi_value global; + napi_get_global(env, &global); + napi_call_function(env, global, callback, 0, nullptr, result); +} + +// Handles created in a callback must keep their values alive for the rest of the callback, +// including across a GC that the callback triggers. The argument is a function that runs the GC. +napi_value survives_gc(napi_env env, napi_callback_info info) { + Isolate* isolate = Isolate::GetCurrent(); + constexpr int count = 500; + Local<String> strings[count]; + for (int i = 0; i < count; i++) { + char text[32]; + snprintf(text, sizeof text, "string %d", i); + strings[i] = String::NewFromUtf8(isolate, text, NewStringType::kNormal).ToLocalChecked(); + } + + napi_value ignored; + call_argument(env, info, &ignored); + + int intact = 0; + for (int i = 0; i < count; i++) { + char expected[32], actual[32]; + snprintf(expected, sizeof expected, "string %d", i); + strings[i]->WriteUtf8V2(isolate, actual, sizeof actual, String::WriteFlags::kNullTerminate); + intact += strcmp(expected, actual) == 0; + } + char result[64]; + snprintf(result, sizeof result, "%d of %d strings intact after gc", intact, count); + return to_napi_string(env, result); +} + +constexpr int num_buffers = 1000; + +// The argument is a function that runs the GC and returns the number of live ArrayBuffers. Reports +// whether the buffers the caller created in already-closed scopes were collected. +napi_value describe_released(napi_env env, napi_callback_info info) { + napi_value live_value; + call_argument(env, info, &live_value); + int32_t live = -1; + napi_get_value_int32(env, live_value, &live); + return to_napi_string(env, live < num_buffers / 10 ? "released before the callback returned" + : "still alive when the callback returned"); +} + +napi_value napi_scopes_release(napi_env env, napi_callback_info info) { + Isolate* isolate = Isolate::GetCurrent(); + for (int i = 0; i < num_buffers; i++) { + napi_handle_scope scope; + napi_open_handle_scope(env, &scope); + ArrayBuffer::New(isolate, 8); + napi_close_handle_scope(env, scope); + } + return describe_released(env, info); +} + +// Doesn't open a scope. run.js checks that the buffers are released once this returns. +napi_value create_buffers(napi_env env, napi_callback_info info) { + Isolate* isolate = Isolate::GetCurrent(); + for (int i = 0; i < num_buffers; i++) { + ArrayBuffer::New(isolate, 8); + } + return nullptr; +} + +napi_value stash_array(napi_env env, napi_callback_info info) { + Isolate* isolate = Isolate::GetCurrent(); + stashed_array.Reset(isolate, Array::New(isolate, 5)); + return nullptr; +} + +// Global::Get creates its Local with V8's inline handle creation code, which calls into Bun through +// HandleScope::Extend instead of a value constructor like Array::New. Each inline HandleScope +// releases those handles through HandleScope::DeleteExtensions when it closes. +napi_value read_stashed_array(napi_env env, napi_callback_info info) { + Isolate* isolate = Isolate::GetCurrent(); + unsigned total_length = 0; + for (int i = 0; i < 1000; i++) { + HandleScope scope(isolate); + total_length += stashed_array.Get(isolate)->Length(); + } + Local<Array> array = stashed_array.Get(isolate); + char result[96]; + snprintf(result, sizeof result, "stashed array of length %u, %u in total over 1000 inline scopes", + array->Length(), total_length); + stashed_array.Reset(); + return to_napi_string(env, result); +} + +napi_value init(napi_env env, napi_value exports) { + struct { + const char* name; + napi_callback callback; + } functions[] = { + {"withoutScope", without_scope}, + {"withScope", with_scope}, + {"withEscapableScope", with_escapable_scope}, + {"survivesGc", survives_gc}, + {"napiScopesRelease", napi_scopes_release}, + {"createBuffers", create_buffers}, + {"stashArray", stash_array}, + {"readStashedArray", read_stashed_array}, + }; + for (auto& function : functions) { + napi_value value; + napi_create_function(env, function.name, NAPI_AUTO_LENGTH, function.callback, nullptr, &value); + napi_set_named_property(env, exports, function.name, value); + } + napi_value klass; + napi_define_class(env, "Klass", NAPI_AUTO_LENGTH, constructor, nullptr, 0, nullptr, &klass); + napi_set_named_property(env, exports, "Klass", klass); + return exports; +} + +} // namespace hybrid_test + +NAPI_MODULE(NODE_GYP_MODULE_NAME, hybrid_test::init)`, + `const addon = require("./build/Release/hybridnapi"); +const { heapStats } = require("bun:jsc"); + +function gc() { + Bun.gc(true); +} + +function liveArrayBuffers() { + gc(); + return heapStats().objectTypeCounts.ArrayBuffer ?? 0; +} + +console.log("function without scope:", addon.withoutScope()); +console.log("function with scope:", addon.withScope()); +console.log("function with escapable scope:", addon.withEscapableScope()); +console.log("class constructor:", new addon.Klass().description); +console.log("survives gc:", addon.survivesGc(gc)); +console.log("per-iteration napi_handle_scope:", addon.napiScopesRelease(liveArrayBuffers)); +addon.createBuffers(); +console.log("no scope:", liveArrayBuffers() < 100 ? "released when the callback returned" : "still alive after the callback returned"); +addon.stashArray(); +console.log("global handle:", addon.readStashedArray());`, + ), + ); + const cwd = String(dir); + await buildStandaloneAddon(cwd); + const { lines, err, exitCode } = await runStandaloneAddon(cwd); + expect(lines, `stderr:\n${err}`).toEqual([ + "function without scope: arrays of length 3 and 4, 'string from v8'", + "function with scope: arrays of length 3 and 4, 'string from v8'", + "function with escapable scope: escaped string", + "class constructor: arrays of length 3 and 4, 'string from v8'", + "survives gc: 500 of 500 strings intact after gc", + "per-iteration napi_handle_scope: released before the callback returned", + "no scope: released when the callback returned", + "global handle: stashed array of length 5, 5000 in total over 1000 inline scopes", + ]); + expect(exitCode).toBe(0); + }, + 10 * 60 * 1000, + ); +}); + describe.skipIf(!canBuildNodeAddons()).todoIf(isBroken && isMusl)("String::Utf8Length bounds", () => { it( "reports sizes beyond INT32_MAX without wrapping",