From 77dd17c2c6120ffbf66830afe3c9545152f06543 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 22 Oct 2026 13:47:02 +0000 Subject: [PATCH 001/113] net, tls: consolidate the open node:net, node:tls, Bun.connect, Bun.listen and usockets pull requests --- docs/runtime/http/tls.mdx | 16 + docs/runtime/networking/tcp.mdx | 6 +- docs/runtime/nodejs-compat.mdx | 6 +- packages/bun-types/bun.d.ts | 88 +- packages/bun-usockets/src/bsd.c | 151 +- packages/bun-usockets/src/context.c | 53 +- packages/bun-usockets/src/crypto/openssl.c | 245 +- .../bun-usockets/src/crypto/root_certs.cpp | 33 +- .../bun-usockets/src/eventing/epoll_kqueue.c | 52 +- packages/bun-usockets/src/eventing/libuv.c | 4 + packages/bun-usockets/src/internal/internal.h | 32 +- .../src/internal/networking/bsd.h | 7 +- packages/bun-usockets/src/libusockets.h | 18 +- packages/bun-usockets/src/loop.c | 125 +- packages/bun-usockets/src/socket.c | 22 +- packages/bun-uws/src/App.h | 13 +- patches/boringssl/ip-name-constraints.patch | 46 + scripts/build/deps/boringssl.ts | 2 + src/boringssl_sys/boringssl.rs | 11 +- src/bun_core/Global.rs | 14 +- src/dns/lib.rs | 16 + src/http/ProxyTunnel.rs | 1 + src/http/ssl_config.rs | 8 + .../websocket_client/WebSocketProxyTunnel.rs | 1 + .../WebSocketUpgradeClient.rs | 3 + src/js/internal-for-testing.ts | 7 + src/js/internal/http.ts | 2 +- src/js/internal/http1_server_fallback.ts | 2 + src/js/internal/net/symbols.ts | 10 + src/js/internal/tls.ts | 19 +- src/js/node/_http_outgoing.ts | 21 +- src/js/node/_http_server.ts | 357 +-- src/js/node/child_process.ts | 15 + src/js/node/dns.ts | 3 + src/js/node/https.ts | 29 + src/js/node/net.ts | 599 ++-- src/js/node/tls.ts | 136 +- src/jsc/VirtualMachine.rs | 14 + src/jsc/bindings/NodeTLS.cpp | 6 + src/jsc/bindings/NodeTLS.h | 1 + .../bindings/node/JSNodeHTTPServerSocket.cpp | 36 + .../node/JSNodeHTTPServerSocketPrototype.cpp | 49 + src/jsc/rare_data.rs | 42 + src/libuv_sys/libuv.rs | 6 + src/runtime/api/SecureContext.classes.ts | 8 + src/runtime/api/bun/SecureContext.rs | 77 +- src/runtime/cli/Arguments.rs | 13 +- src/runtime/dns_jsc/dns.rs | 41 +- src/runtime/dns_jsc/dns_internals.bind.ts | 1 + src/runtime/ipc_host.rs | 8 +- src/runtime/jsc_hooks.rs | 2 +- src/runtime/node/node_cluster_binding.rs | 4 + src/runtime/node/node_http_binding.bind.ts | 13 + src/runtime/node/node_http_binding.rs | 37 +- src/runtime/node/node_net_binding.bind.ts | 14 - src/runtime/node/node_net_binding.rs | 37 +- src/runtime/node/node_tls_binding.bind.ts | 1 + src/runtime/node/quic/tls.rs | 15 +- src/runtime/server/ServerConfig.rs | 35 +- src/runtime/server/mod.rs | 9 +- src/runtime/server/server_body.rs | 54 +- src/runtime/socket/Handlers.rs | 19 +- src/runtime/socket/JSSocketHandlers.rs | 12 + src/runtime/socket/Listener.rs | 244 +- src/runtime/socket/SSLConfig.bind.ts | 11 +- src/runtime/socket/SSLConfig.rs | 59 +- src/runtime/socket/UpgradedDuplex.rs | 87 +- src/runtime/socket/WindowsNamedPipe.rs | 61 +- src/runtime/socket/mod.rs | 6 +- src/runtime/socket/pending_writes.rs | 46 + src/runtime/socket/socket_body.bind.ts | 1 + src/runtime/socket/socket_body.rs | 490 +++- src/runtime/socket/sockets.classes.ts | 6 +- src/runtime/socket/tls_socket_functions.rs | 200 +- src/runtime/webcore/fetch.rs | 4 +- src/runtime/webcore/fetch/FetchTasklet.rs | 9 + src/spawn/process.rs | 14 +- src/uws/lib.rs | 132 +- src/uws_sys/App.rs | 18 +- src/uws_sys/ListenSocket.rs | 6 + src/uws_sys/Loop.rs | 5 + src/uws_sys/lib.rs | 11 + src/uws_sys/libuwsockets.cpp | 5 + src/uws_sys/socket.rs | 44 +- src/uws_sys/us_socket_t.rs | 5 + test/cli/hot/hot.test.ts | 100 +- test/cli/run/as-node.test.ts | 25 + test/expectations.txt | 6 - test/integration/bun-types/fixture/tcp.ts | 11 + test/js/bun/dns/dns-interleave.test.ts | 151 +- test/js/bun/dns/dns-prefetch.test.ts | 118 + test/js/bun/http/bun-serve-ssl.test.ts | 53 + test/js/bun/http/serve-listen.test.ts | 39 + .../serve-tls-prehandshake-reap-fixture.ts | 6 +- .../js/bun/net/fin-with-final-data-fixture.ts | 71 + test/js/bun/net/keep-event-loop-alive.js | 18 +- test/js/bun/net/socket-huge-fixture.js | 23 +- test/js/bun/net/socket-syscall-fault.test.ts | 86 +- test/js/bun/net/socket.test.ts | 2556 +++++++++++------ test/js/bun/net/tcp-server.test.ts | 27 + .../js/bun/net/tls-fatal-error-closes.test.ts | 29 + .../tls-parked-handshake-timeout-fixture.ts | 165 ++ .../net/tls-parked-handshake-turns-fixture.ts | 136 + .../net/tls-serve-deferred-accept-fixture.ts | 132 + .../bun/spawn/pidfd-exit-nested-tick.test.ts | 23 +- test/js/bun/spawn/spawn.ipc.test.ts | 129 +- .../node/child_process/child_process.test.ts | 192 ++ .../child_process_ipc_handle.test.ts | 67 + test/js/node/cluster.test.ts | 34 + .../js/node/http/client-timeout-error.test.ts | 12 + test/js/node/http/node-http-connect.test.ts | 42 + test/js/node/http/node-http.test.ts | 330 ++- test/js/node/http/node-https-server.test.ts | 864 +++++- test/js/node/net/handle-leak.test.ts | 100 +- .../net/listener-throw-connect-fixture.js | 37 + test/js/node/net/net-syscall-fault.test.ts | 380 ++- test/js/node/net/node-destroy-fixture.js | 8 + test/js/node/net/node-fin-fixture.js | 7 + .../node/net/node-net-allowHalfOpen.test.js | 73 +- test/js/node/net/node-net-server.test.ts | 285 +- test/js/node/net/node-net.test.ts | 1415 +++++++-- test/js/node/net/node-ref-default-fixture.js | 7 +- test/js/node/net/node-unref-fixture.js | 6 +- test/js/node/net/server.spec.ts | 28 +- test/js/node/net/socketaddress.spec.ts | 38 +- test/js/node/quic/bundled-root-probe-cert.pem | 19 + test/js/node/quic/quic-sni.test.ts | 97 + test/js/node/test/common/index.js | 5 + .../test-child-process-server-close.js | 40 + .../test-cluster-send-handle-twice.js | 59 + ...ttps-session-reuse-different-servername.js | 126 + .../parallel/test-https-timeout-server-2.js | 54 + .../test/parallel/test-net-connect-memleak.js | 58 + .../parallel/test-net-onread-static-buffer.js | 186 ++ .../test-tls-client-default-ciphers.js | 44 + .../parallel/test-tls-client-mindhsize.js | 130 + .../parallel/test-tls-onread-static-buffer.js | 206 ++ ...t-tls-server-parent-constructor-options.js | 68 + .../parallel/test-tls-server-setkeycert.js | 56 + ...t-default-ca-certificates-append-fetch.mjs | 54 + ...et-default-ca-certificates-reset-fetch.mjs | 47 + ...t-tls-socket-snicallback-without-server.js | 23 + .../node/test/parallel/test-tls-ticket-12.js | 12 + .../test/parallel/test-tls-writewrap-leak.js | 24 + .../tls/default-ca-native-clients.test.ts | 171 ++ .../ip-name-constraints/dual-stack-ca.pem | 13 + .../ip-name-constraints/ipv4-any-ca.pem | 12 + .../ip-name-constraints/ipv4-cert.pem | 12 + .../ip-name-constraints/ipv4-excluded-ca.pem | 12 + .../ipv4-excluded-other-ca.pem | 12 + .../ip-name-constraints/ipv4-outside-ca.pem | 12 + .../ip-name-constraints/ipv4-subnet-ca.pem | 12 + .../ip-name-constraints/ipv6-any-ca.pem | 13 + .../ip-name-constraints/ipv6-cert.pem | 12 + .../ip-name-constraints/ipv6-excluded-ca.pem | 12 + .../ipv6-excluded-other-ca.pem | 12 + .../ip-name-constraints/ipv6-outside-ca.pem | 13 + .../ip-name-constraints/ipv6-subnet-ca.pem | 12 + .../fixtures/ip-name-constraints/leaf-key.pem | 5 + test/js/node/tls/node-tls-cert.test.ts | 54 + .../node/tls/node-tls-client-wrap-fixture.mjs | 4 +- ...-tls-connect-hostname-verification.test.ts | 30 + test/js/node/tls/node-tls-connect.test.ts | 93 +- test/js/node/tls/node-tls-context.test.ts | 431 ++- test/js/node/tls/node-tls-server.test.ts | 363 ++- test/js/node/tls/node-tls-upgrade.test.ts | 263 +- test/js/node/tls/renegotiation.test.ts | 39 +- test/js/node/tls/test-use-system-ca.test.ts | 161 +- .../tls-sni-callback-over-duplex-fixture.mjs | 106 + test/js/node/tls/tls-syscall-fault.test.ts | 98 +- .../worker_threads/worker_threads.test.ts | 45 + test/js/sql/sql-default-ca.test.ts | 67 + .../workers/worker-terminate-lifetime.test.ts | 87 +- test/leaksan.supp | 2 + 174 files changed, 13009 insertions(+), 2497 deletions(-) create mode 100644 patches/boringssl/ip-name-constraints.patch create mode 100644 src/runtime/socket/pending_writes.rs create mode 100644 test/js/bun/net/fin-with-final-data-fixture.ts create mode 100644 test/js/bun/net/tls-parked-handshake-timeout-fixture.ts create mode 100644 test/js/bun/net/tls-parked-handshake-turns-fixture.ts create mode 100644 test/js/bun/net/tls-serve-deferred-accept-fixture.ts create mode 100644 test/js/node/net/listener-throw-connect-fixture.js create mode 100644 test/js/node/quic/bundled-root-probe-cert.pem create mode 100644 test/js/node/test/parallel/test-child-process-server-close.js create mode 100644 test/js/node/test/parallel/test-cluster-send-handle-twice.js create mode 100644 test/js/node/test/parallel/test-https-session-reuse-different-servername.js create mode 100644 test/js/node/test/parallel/test-https-timeout-server-2.js create mode 100644 test/js/node/test/parallel/test-net-connect-memleak.js create mode 100644 test/js/node/test/parallel/test-net-onread-static-buffer.js create mode 100644 test/js/node/test/parallel/test-tls-client-default-ciphers.js create mode 100644 test/js/node/test/parallel/test-tls-client-mindhsize.js create mode 100644 test/js/node/test/parallel/test-tls-onread-static-buffer.js create mode 100644 test/js/node/test/parallel/test-tls-server-parent-constructor-options.js create mode 100644 test/js/node/test/parallel/test-tls-server-setkeycert.js create mode 100644 test/js/node/test/parallel/test-tls-set-default-ca-certificates-append-fetch.mjs create mode 100644 test/js/node/test/parallel/test-tls-set-default-ca-certificates-reset-fetch.mjs create mode 100644 test/js/node/test/parallel/test-tls-socket-snicallback-without-server.js create mode 100644 test/js/node/test/parallel/test-tls-ticket-12.js create mode 100644 test/js/node/test/parallel/test-tls-writewrap-leak.js create mode 100644 test/js/node/tls/default-ca-native-clients.test.ts create mode 100644 test/js/node/tls/fixtures/ip-name-constraints/dual-stack-ca.pem create mode 100644 test/js/node/tls/fixtures/ip-name-constraints/ipv4-any-ca.pem create mode 100644 test/js/node/tls/fixtures/ip-name-constraints/ipv4-cert.pem create mode 100644 test/js/node/tls/fixtures/ip-name-constraints/ipv4-excluded-ca.pem create mode 100644 test/js/node/tls/fixtures/ip-name-constraints/ipv4-excluded-other-ca.pem create mode 100644 test/js/node/tls/fixtures/ip-name-constraints/ipv4-outside-ca.pem create mode 100644 test/js/node/tls/fixtures/ip-name-constraints/ipv4-subnet-ca.pem create mode 100644 test/js/node/tls/fixtures/ip-name-constraints/ipv6-any-ca.pem create mode 100644 test/js/node/tls/fixtures/ip-name-constraints/ipv6-cert.pem create mode 100644 test/js/node/tls/fixtures/ip-name-constraints/ipv6-excluded-ca.pem create mode 100644 test/js/node/tls/fixtures/ip-name-constraints/ipv6-excluded-other-ca.pem create mode 100644 test/js/node/tls/fixtures/ip-name-constraints/ipv6-outside-ca.pem create mode 100644 test/js/node/tls/fixtures/ip-name-constraints/ipv6-subnet-ca.pem create mode 100644 test/js/node/tls/fixtures/ip-name-constraints/leaf-key.pem create mode 100644 test/js/node/tls/tls-sni-callback-over-duplex-fixture.mjs create mode 100644 test/js/sql/sql-default-ca.test.ts diff --git a/docs/runtime/http/tls.mdx b/docs/runtime/http/tls.mdx index 9e7c0b425d87..9924c72d28a5 100644 --- a/docs/runtime/http/tls.mdx +++ b/docs/runtime/http/tls.mdx @@ -41,6 +41,22 @@ Bun.serve({ }); ``` +### Protocol versions + +To limit the TLS protocol versions a connection can use, set `minVersion` and `maxVersion`. They take the same names as `node:tls`: `"TLSv1"`, `"TLSv1.1"`, `"TLSv1.2"` and `"TLSv1.3"`. The defaults are `"TLSv1.2"` and `"TLSv1.3"`. + +```ts +Bun.serve({ + tls: { + key: Bun.file("./key.pem"), + cert: Bun.file("./cert.pem"), + minVersion: "TLSv1.3", // [!code ++] + }, +}); +``` + +The same two options work in every API that takes a `tls` object, such as `fetch`, `Bun.connect` and `Bun.listen`. When `tls` is an array (see [SNI](#server-name-indication-sni) below), every entry uses the range of the first one. + ### CA Certificates Pass `ca` to override the trusted CA certificates. By default, the server trusts the list of well-known CAs curated by Mozilla; setting `ca` replaces that list. diff --git a/docs/runtime/networking/tcp.mdx b/docs/runtime/networking/tcp.mdx index b23eef720176..6e1aa990644e 100644 --- a/docs/runtime/networking/tcp.mdx +++ b/docs/runtime/networking/tcp.mdx @@ -135,7 +135,7 @@ const socket = await Bun.connect({ }); ``` -To require TLS, specify `tls: true`. +To require TLS, specify `tls: true` or a `tls` options object. An object that sets no option, such as `{}`, is the same as `tls: true`. ```ts // The client @@ -182,7 +182,9 @@ socket.reload({ ## Buffering -TCP sockets in Bun do not buffer data, so performance-sensitive code should buffer writes itself. For example, this: +`socket.write()` does not buffer data. It returns the number of bytes the socket accepted, and you send the rest from the `drain` handler. `socket.end(data)` accepts the whole chunk: Bun queues what the socket didn't accept, sends it once the socket is writable again, and then closes the socket. + +Performance-sensitive code should buffer writes itself. For example, this: ```ts socket.write("h"); diff --git a/docs/runtime/nodejs-compat.mdx b/docs/runtime/nodejs-compat.mdx index 2331b3a5a030..be0284d018ed 100644 --- a/docs/runtime/nodejs-compat.mdx +++ b/docs/runtime/nodejs-compat.mdx @@ -33,7 +33,7 @@ We update this page regularly. It reflects the latest version of Bun's compatibi ### [`node:dns`](https://nodejs.org/api/dns.html) -🟢 Fully implemented. Missing `resolveTlsa`. Bun ignores the `Resolver` `maxTimeout` option. +🟢 Fully implemented. Missing `resolveTlsa`. Bun ignores the `Resolver` `maxTimeout` option. `setDefaultResultOrder()` also sets which address family `fetch()` and `Bun.connect()` dial first. That part applies to the whole process, including other worker threads, because they share one DNS cache. ### [`node:events`](https://nodejs.org/api/events.html) @@ -49,7 +49,7 @@ We update this page regularly. It reflects the latest version of Bun's compatibi ### [`node:https`](https://nodejs.org/api/https.html) -🟡 `request`, `get`, `Agent` and `globalAgent` are implemented, including connection pooling. Client sockets are `tls.TLSSocket`s. `https.Server` is `http.Server` with TLS options rather than a `tls.Server`. Its request sockets (`req.socket`) are not `tls.TLSSocket`s: `encrypted`, `authorized` and `servername` work, but `getPeerCertificate()` and `getCipher()` are missing. `setSecureContext()`, `addContext()`, `SNICallback` and `handshakeTimeout` are not supported. +🟡 `request`, `get`, `Agent` and `globalAgent` are implemented, including connection pooling. `https.Server` extends `http.Server` rather than `tls.Server`, and has `setSecureContext()`, `addContext()`, `getTicketKeys()`, `setTicketKeys()` and `SNICallback`. Client sockets and request sockets (`req.socket`) are `tls.TLSSocket`s. On a request socket, `getSession()`, `getTLSTicket()` and `getSharedSigalgs()` return nothing. The server does not negotiate ALPN, so `ALPNProtocols` and `ALPNCallback` have no effect and `req.socket.alpnProtocol` is `false`. `handshakeTimeout` and the `'keylog'` event are not supported, and `'tlsClientError'` is only emitted for an `SNICallback` error. ### [`node:os`](https://nodejs.org/api/os.html) @@ -141,7 +141,7 @@ We update this page regularly. It reflects the latest version of Bun's compatibi ### [`node:tls`](https://nodejs.org/api/tls.html) -🟡 Missing `pskCallback`, OCSP stapling (`requestOCSP`), the server `'newSession'`/`'resumeSession'` events and session ticket keys (`ticketKeys` is ignored). As a result, session resumption does not work across processes. Bun uses BoringSSL, so `tlsSocket.renegotiate()` always fails and `getEphemeralKeyInfo()`/`getSharedSigalgs()` return no information. +🟡 Missing `pskCallback`, OCSP stapling (`requestOCSP`) and the server `'newSession'`/`'resumeSession'` events. Bun uses BoringSSL, so `tlsSocket.renegotiate()` always fails and `getSharedSigalgs()` returns no information. ### [`node:util`](https://nodejs.org/api/util.html) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 59aa530c8e58..457de353fdea 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -4837,6 +4837,21 @@ declare module "bun" { */ secureOptions?: number | undefined; // Value is a numeric bitmask of the `SSL_OP_*` options + /** + * The minimum TLS protocol version to allow, by the same names as `node:tls`. + * + * @default "TLSv1.2" + */ + minVersion?: "TLSv1" | "TLSv1.1" | "TLSv1.2" | "TLSv1.3" | undefined; + + /** + * The maximum TLS protocol version to allow. A `maxVersion` below + * `"TLSv1.2"` also needs a `minVersion`, because the default minimum is `"TLSv1.2"`. + * + * @default "TLSv1.3" + */ + maxVersion?: "TLSv1" | "TLSv1.1" | "TLSv1.2" | "TLSv1.3" | undefined; + ALPNProtocols?: string | BufferSource; ciphers?: string; @@ -6830,10 +6845,13 @@ declare module "bun" { * The socket remains readable until the remote end also closes its write side or the connection is terminated. * This sends a TCP FIN packet after writing the data. * + * Unlike `write()`, `end(data)` accepts the whole chunk. Bytes the socket can't take right away are queued and sent + * before the FIN. + * * @param data Optional final data to write before closing. Same types as `write()`. * @param byteOffset Optional offset for buffer data. * @param byteLength Optional length for buffer data. - * @returns The number of bytes written for the final chunk. Returns `-1` if the socket was already closed or shutting down. + * @returns The byte length of the final chunk. Returns `-1` if the socket was already closed or shutting down. * @example * ```ts * // send some data and close the write side @@ -6858,11 +6876,15 @@ declare module "bun" { ref(): void; /** - * Set a timeout until the socket automatically closes. + * Call the {@link SocketHandler.timeout `timeout`} handler once, `seconds` seconds from now. + * + * The socket stays open. Call {@link end `end()`} or {@link terminate `terminate()`} in the handler to close it. + * `end()` first sends the data an earlier `end(data)` queued, so it never closes a socket whose peer stopped + * reading. `terminate()` drops that data and closes right away. * - * To reset the timeout, call this function again. + * Reads and writes don't restart the timer. To reset the timeout, call this function again. `0` cancels it. * - * When a timeout happens, the `timeout` callback is called and the socket is closed. + * Bun checks socket timeouts about every 4 seconds, so the handler can run up to 4 seconds early. */ timeout(seconds: number): void; @@ -7315,9 +7337,12 @@ declare module "bun" { open?(socket: Socket): void | Promise; close?(socket: Socket, error?: Error): void | Promise; /** - * Called when a handler throws, and when an established TLS session fails - * (a record that does not decrypt, a fatal alert from the peer). After a - * TLS failure the socket closes, with or without this handler. + * Called when a handler throws, when an established TLS session fails + * (a record that does not decrypt, a fatal alert from the peer), and when + * sending the data queued by `end(data)` fails because the peer is gone + * (`EPIPE` or `ECONNRESET`). After a TLS failure or a failed send the + * socket closes, with or without this handler. A reset that a read reports + * goes to `close(socket, error)` instead. */ error?(socket: Socket, error: Error): void | Promise; data?(socket: Socket, data: BinaryTypeList[DataBinaryType]): void | Promise; @@ -7352,7 +7377,8 @@ declare module "bun" { connectError?(socket: Socket, error: Error): void | Promise; /** - * Called when a message times out. + * Called when the timeout set by {@link Socket.timeout `socket.timeout()`} expires. A socket accepted by + * `Bun.listen` starts with a 120 second timeout. The socket stays open. */ timeout?(socket: Socket): void | Promise; /** @@ -7419,6 +7445,20 @@ declare module "bun" { * @default false */ exclusive?: boolean; + /** + * Set `SO_REUSEPORT` so multiple processes can bind to the same port, which is useful for load balancing. + * + * Ignored when {@link exclusive} is `true`. + * + * @default false + */ + reusePort?: boolean; + /** + * Set `IPV6_V6ONLY` so a socket bound to an IPv6 address doesn't also accept IPv4 connections. + * + * @default false + */ + ipv6Only?: boolean; /** * Whether to allow half-open connections. * @@ -7447,22 +7487,24 @@ declare module "bun" { */ port: number; /** - * TLS configuration with which to create the socket + * TLS configuration with which to create the socket. + * + * `true` or any options object connects with TLS. An object that sets no + * option, such as `{}`, is the same as `true`. Omit `tls` or pass `false` + * to connect without TLS. */ tls?: TLSOptions | boolean; /** - * Whether to use exclusive mode. - * - * When set to `true`, the socket binds exclusively to the specified address:port - * combination, preventing other processes from binding to the same port. - * - * When `false` (default), other sockets may be able to bind to the same port - * depending on the operating system's socket sharing capabilities and settings. - * - * @default false + * @deprecated Has no effect on a client socket. This is a {@link Bun.listen} option (see {@link TCPSocketListenOptions}). */ exclusive?: boolean; + /** + * @deprecated Has no effect on a client socket. This is a {@link Bun.listen} option (see {@link TCPSocketListenOptions}). + */ reusePort?: boolean; + /** + * @deprecated Has no effect on a client socket. This is a {@link Bun.listen} option (see {@link TCPSocketListenOptions}). + */ ipv6Only?: boolean; } @@ -7473,14 +7515,20 @@ declare module "bun" { unix: string; /** - * TLS configuration with which to create the socket + * TLS configuration with which to create the socket. + * + * For {@link Bun.connect}, `true` or any options object connects with TLS. + * An object that sets no option, such as `{}`, is the same as `true`. */ tls?: TLSOptions | boolean; } interface FdSocketOptions extends SocketOptions { /** - * TLS configuration with which to create the socket + * TLS configuration with which to create the socket. + * + * `true` or any options object connects with TLS. An object that sets no + * option, such as `{}`, is the same as `true`. */ tls?: TLSOptions | boolean; /** diff --git a/packages/bun-usockets/src/bsd.c b/packages/bun-usockets/src/bsd.c index a1190b3a6515..7e366903dd6f 100644 --- a/packages/bun-usockets/src/bsd.c +++ b/packages/bun-usockets/src/bsd.c @@ -59,6 +59,7 @@ static void init_debug_logging() { #include #else /* _WIN32 */ #include +#include #endif #if defined(__APPLE__) @@ -817,13 +818,102 @@ int bsd_addr_get_port(struct bsd_addr_t *addr) { return addr->port; } +#ifdef _WIN32 +/* accept() for a listening socket that another process also holds. Every process that polls + * the socket wakes up for each connection. A non-blocking accept() only returns WSAEWOULDBLOCK + * if no connection is pending when it starts. If another process takes the pending connection + * first, accept() waits for the next one and the event loop stops. AcceptEx can be canceled + * instead. libuv accepts with AcceptEx on every listening socket (win/tcp.c). */ +static SOCKET bsd_accept_shared_socket(SOCKET fd, struct bsd_addr_t *addr) { + /* Don't create a socket when no connection is pending. */ + fd_set readable; + FD_ZERO(&readable); + FD_SET(fd, &readable); + struct timeval no_wait = {0, 0}; + int ready = select(0, &readable, NULL, NULL, &no_wait); + if (ready != 1) { + if (ready == 0) { + WSASetLastError(WSAEWOULDBLOCK); + } + return INVALID_SOCKET; + } + + GUID acceptex_guid = WSAID_ACCEPTEX; + LPFN_ACCEPTEX acceptex = NULL; + DWORD bytes = 0; + if (WSAIoctl(fd, SIO_GET_EXTENSION_FUNCTION_POINTER, &acceptex_guid, sizeof(acceptex_guid), + &acceptex, sizeof(acceptex), &bytes, NULL, NULL) != 0) { + /* A provider without AcceptEx keeps the plain accept(). */ + return accept(fd, (struct sockaddr *) &addr->mem, &addr->len); + } + + struct sockaddr_storage local; + int local_len = sizeof(local); + if (getsockname(fd, (struct sockaddr *) &local, &local_len) != 0) { + return INVALID_SOCKET; + } + SOCKET accepted = WSASocketW(local.ss_family, SOCK_STREAM, 0, NULL, 0, + WSA_FLAG_OVERLAPPED | WSA_FLAG_NO_HANDLE_INHERIT); + if (accepted == INVALID_SOCKET) { + return INVALID_SOCKET; + } + HANDLE event = CreateEventW(NULL, TRUE, FALSE, NULL); + if (event == NULL) { + closesocket(accepted); + return INVALID_SOCKET; + } + + /* AcceptEx writes both addresses here, each with 16 bytes of extra space. */ + char addresses[2 * (sizeof(struct sockaddr_storage) + 16)]; + OVERLAPPED overlapped; + memset(&overlapped, 0, sizeof(overlapped)); + /* The low bit keeps the completion off any I/O completion port. */ + overlapped.hEvent = (HANDLE) ((ULONG_PTR) event | 1); + int err = 0; + if (!acceptex(fd, accepted, addresses, 0, sizeof(struct sockaddr_storage) + 16, + sizeof(struct sockaddr_storage) + 16, &bytes, &overlapped)) { + err = WSAGetLastError(); + if (err == WSA_IO_PENDING) { + /* The request either gets a connection that arrives before the cancel, or is canceled. + * Both set the event. Wait for it, because the request writes to this stack frame. */ + CancelIoEx((HANDLE) fd, &overlapped); + WaitForSingleObject(event, INFINITE); + DWORD flags = 0; + err = WSAGetOverlappedResult(fd, &overlapped, &bytes, FALSE, &flags) ? 0 : WSAGetLastError(); + if (err == WSA_OPERATION_ABORTED) { + err = WSAEWOULDBLOCK; + } + } + } + CloseHandle(event); + + if (err == 0 && setsockopt(accepted, SOL_SOCKET, SO_UPDATE_ACCEPT_CONTEXT, (char *) &fd, sizeof(fd)) != 0) { + err = WSAGetLastError(); + } + if (err != 0) { + closesocket(accepted); + WSASetLastError(err); + return INVALID_SOCKET; + } + /* A connection the peer already reset has no address. The first read reports the reset. */ + if (getpeername(accepted, (struct sockaddr *) &addr->mem, &addr->len) != 0) { + addr->mem.ss_family = AF_UNSPEC; + } + return accepted; +} +#endif + // called by dispatch_ready_poll -LIBUS_SOCKET_DESCRIPTOR bsd_accept_socket(LIBUS_SOCKET_DESCRIPTOR fd, struct bsd_addr_t *addr) { +LIBUS_SOCKET_DESCRIPTOR bsd_accept_socket(LIBUS_SOCKET_DESCRIPTOR fd, struct bsd_addr_t *addr, int shared) { LIBUS_SOCKET_DESCRIPTOR accepted_fd; ssize_t injected = 0; int unused = 0; if (US_FAULT_CHECK(US_FAULT_ACCEPT, fd, injected, unused)) return LIBUS_SOCKET_ERROR; (void)injected; (void)unused; +#ifndef _WIN32 + /* A POSIX accept() honors O_NONBLOCK on a shared socket too. */ + (void)shared; +#endif while (1) { addr->len = sizeof(addr->mem); @@ -831,8 +921,10 @@ LIBUS_SOCKET_DESCRIPTOR bsd_accept_socket(LIBUS_SOCKET_DESCRIPTOR fd, struct bsd #if defined(SOCK_CLOEXEC) && defined(SOCK_NONBLOCK) // Linux, FreeBSD accepted_fd = accept4(fd, (struct sockaddr *) addr, &addr->len, SOCK_CLOEXEC | SOCK_NONBLOCK); +#elif defined(_WIN32) + accepted_fd = shared ? bsd_accept_shared_socket(fd, addr) : accept(fd, (struct sockaddr *) addr, &addr->len); #else - // Windows, OS X + // OS X accepted_fd = accept(fd, (struct sockaddr *) addr, &addr->len); #endif @@ -1173,6 +1265,7 @@ inline __attribute__((always_inline)) LIBUS_SOCKET_DESCRIPTOR bsd_bind_listen_fd ) { if (bsd_set_reuse(listenFd, options) != 0) { + *error = LIBUS_ERR; return LIBUS_SOCKET_ERROR; } @@ -1189,6 +1282,7 @@ inline __attribute__((always_inline)) LIBUS_SOCKET_DESCRIPTOR bsd_bind_listen_fd if (listenAddr->ai_family == AF_INET6) { int enabled = (options & LIBUS_SOCKET_IPV6_ONLY) != 0; if (setsockopt(listenFd, IPPROTO_IPV6, IPV6_V6ONLY, &enabled, sizeof(enabled)) != 0) { + *error = LIBUS_ERR; return LIBUS_SOCKET_ERROR; } } @@ -1373,39 +1467,51 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket(const char *host, int port, int return LIBUS_SOCKET_ERROR; } + /* Fall back to the next address when this host can't bind a family (IPv6 disabled). A named host doesn't fall + * back on EADDRINUSE, or "localhost" would bind 127.0.0.1 while another server owns the port on ::1. */ LIBUS_SOCKET_DESCRIPTOR listenFd = LIBUS_SOCKET_ERROR; struct addrinfo *listenAddr; for (struct addrinfo *a = result; a != NULL; a = a->ai_next) { if (a->ai_family == AF_INET6) { - listenFd = bsd_create_socket(a->ai_family, a->ai_socktype, a->ai_protocol, NULL); + listenFd = bsd_create_socket(a->ai_family, a->ai_socktype, a->ai_protocol, error); if (listenFd == LIBUS_SOCKET_ERROR) { continue; } listenAddr = a; + *error = 0; if (bsd_bind_listen_fd(listenFd, listenAddr, port, options, error) != LIBUS_SOCKET_ERROR) { freeaddrinfo(result); return listenFd; } bsd_close_socket(listenFd); + if (host && *error == LIBUS_EADDRINUSE) { + freeaddrinfo(result); + return LIBUS_SOCKET_ERROR; + } } } for (struct addrinfo *a = result; a != NULL; a = a->ai_next) { if (a->ai_family == AF_INET) { - listenFd = bsd_create_socket(a->ai_family, a->ai_socktype, a->ai_protocol, NULL); + listenFd = bsd_create_socket(a->ai_family, a->ai_socktype, a->ai_protocol, error); if (listenFd == LIBUS_SOCKET_ERROR) { continue; } listenAddr = a; + *error = 0; if (bsd_bind_listen_fd(listenFd, listenAddr, port, options, error) != LIBUS_SOCKET_ERROR) { freeaddrinfo(result); return listenFd; } bsd_close_socket(listenFd); + if (host && *error == LIBUS_EADDRINUSE) { + freeaddrinfo(result); + return LIBUS_SOCKET_ERROR; + } } } @@ -1442,6 +1548,22 @@ static LIBUS_SOCKET_DESCRIPTOR bsd_create_unix_socket_address(const char *path, return LIBUS_SOCKET_ERROR; } + // bind() and connect() stop at an embedded NUL, which is a different path. + // Linux abstract names (leading NUL) may contain them. + #if defined(__linux__) + int has_embedded_nul = path[0] != '\0' && memchr(path, '\0', path_len) != NULL; + #else + int has_embedded_nul = memchr(path, '\0', path_len) != NULL; + #endif + if (has_embedded_nul) { + #if defined(_WIN32) + WSASetLastError(WSAEINVAL); + #else + errno = EINVAL; + #endif + return LIBUS_SOCKET_ERROR; + } + *addrlen = sizeof(struct sockaddr_un); #if defined(__linux__) @@ -1469,7 +1591,7 @@ static LIBUS_SOCKET_DESCRIPTOR bsd_create_unix_socket_address(const char *path, memcpy(dirname_buf, path, dirname_len); dirname_buf[dirname_len] = 0; - int socket_dir_fd = open(dirname_buf, O_CLOEXEC | O_PATH | O_DIRECTORY, 0700); + int socket_dir_fd = open(dirname_buf, O_CLOEXEC | O_PATH | O_DIRECTORY); if (socket_dir_fd == -1) { errno = ENAMETOOLONG; return LIBUS_SOCKET_ERROR; @@ -1554,7 +1676,7 @@ static LIBUS_SOCKET_DESCRIPTOR bsd_create_unix_socket_address(const char *path, static LIBUS_SOCKET_DESCRIPTOR internal_bsd_create_listen_socket_unix(const char* path, int options, struct sockaddr_un* server_address, size_t addrlen, int* error) { LIBUS_SOCKET_DESCRIPTOR listenFd = LIBUS_SOCKET_ERROR; - listenFd = bsd_create_socket(AF_UNIX, SOCK_STREAM, 0, NULL); + listenFd = bsd_create_socket(AF_UNIX, SOCK_STREAM, 0, error); if (listenFd == LIBUS_SOCKET_ERROR) { return LIBUS_SOCKET_ERROR; @@ -1891,14 +2013,17 @@ int bsd_disconnect_udp_socket(LIBUS_SOCKET_DESCRIPTOR fd) { } } -static int bsd_do_connect_raw(LIBUS_SOCKET_DESCRIPTOR fd, struct sockaddr *addr, size_t namelen) +/* Returns 0 if the connect succeeded or is in progress. Sets *connected to 1 only if connect() itself returned 0. */ +static int bsd_do_connect_raw(LIBUS_SOCKET_DESCRIPTOR fd, struct sockaddr *addr, size_t namelen, int *connected) { ssize_t injected = 0; int unused = 0; + *connected = 0; if (US_FAULT_CHECK(US_FAULT_CONNECT, fd, injected, unused)) return errno; (void)injected; (void)unused; #ifdef _WIN32 while (1) { if (connect(fd, (struct sockaddr *)addr, namelen) == 0) { + *connected = 1; return 0; } @@ -1936,6 +2061,7 @@ static int bsd_do_connect_raw(LIBUS_SOCKET_DESCRIPTOR fd, struct sockaddr *addr, return errno; } + *connected = r == 0; return 0; #endif } @@ -2040,7 +2166,8 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_connect_socket(struct sockaddr_storage *addr, } #endif - int rc = bsd_do_connect_raw(fd, (struct sockaddr*) addr, addr->ss_family == AF_INET ? sizeof(struct sockaddr_in) : sizeof(struct sockaddr_in6)); + int connected; + int rc = bsd_do_connect_raw(fd, (struct sockaddr*) addr, addr->ss_family == AF_INET ? sizeof(struct sockaddr_in) : sizeof(struct sockaddr_in6), &connected); if (rc != 0) { bsd_close_socket(fd); @@ -2055,7 +2182,7 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_connect_socket(struct sockaddr_storage *addr, return fd; } -static LIBUS_SOCKET_DESCRIPTOR internal_bsd_create_connect_socket_unix(const char *server_path, size_t len, int options, struct sockaddr_un* server_address, const size_t addrlen) { +static LIBUS_SOCKET_DESCRIPTOR internal_bsd_create_connect_socket_unix(const char *server_path, size_t len, int options, struct sockaddr_un* server_address, const size_t addrlen, int *connected) { LIBUS_SOCKET_DESCRIPTOR fd = bsd_create_socket(AF_UNIX, SOCK_STREAM, 0, NULL); if (fd == LIBUS_SOCKET_ERROR) { @@ -2064,7 +2191,7 @@ static LIBUS_SOCKET_DESCRIPTOR internal_bsd_create_connect_socket_unix(const cha win32_set_nonblocking(fd); - int rc = bsd_do_connect_raw(fd, (struct sockaddr *)server_address, addrlen); + int rc = bsd_do_connect_raw(fd, (struct sockaddr *)server_address, addrlen, connected); if (rc != 0) { bsd_close_socket(fd); #ifdef _WIN32 @@ -2076,7 +2203,7 @@ static LIBUS_SOCKET_DESCRIPTOR internal_bsd_create_connect_socket_unix(const cha return fd; } -LIBUS_SOCKET_DESCRIPTOR bsd_create_connect_socket_unix(const char *server_path, size_t len, int options) { +LIBUS_SOCKET_DESCRIPTOR bsd_create_connect_socket_unix(const char *server_path, size_t len, int options, int *connected) { struct sockaddr_un server_address; size_t addrlen = 0; int dirfd_workaround_for_unix_path_len = -1; @@ -2094,7 +2221,7 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_connect_socket_unix(const char *server_path, } #endif - LIBUS_SOCKET_DESCRIPTOR fd = internal_bsd_create_connect_socket_unix(server_path, len, options, &server_address, addrlen); + LIBUS_SOCKET_DESCRIPTOR fd = internal_bsd_create_connect_socket_unix(server_path, len, options, &server_address, addrlen, connected); #if defined(__APPLE__) if (dirfd_workaround_for_unix_path_len != -1) { diff --git a/packages/bun-usockets/src/context.c b/packages/bun-usockets/src/context.c index 9d087ba990a9..a915a3e530b3 100644 --- a/packages/bun-usockets/src/context.c +++ b/packages/bun-usockets/src/context.c @@ -361,6 +361,7 @@ static void us_internal_init_listen_socket(struct us_listen_socket_t *ls, s->read_eof = 0; s->hangup_closes_unsent = 0; s->send_rejected = 0; + s->connect_returned_zero = 0; s->next = 0; s->prev = 0; s->connect_state = NULL; @@ -374,6 +375,7 @@ static void us_internal_init_listen_socket(struct us_listen_socket_t *ls, ls->socket_ext_size = socket_ext_size; ls->deferred_accept = 0; ls->accept_paused = (options & LIBUS_SOCKET_OPEN_PAUSED) && !ssl_ctx; + ls->shared = 0; /* Link into the group so close_all() / test-isolation can find it. */ ls->next = group->head_listen_sockets; @@ -439,6 +441,8 @@ struct us_listen_socket_t *us_socket_group_listen_fd(struct us_socket_group_t *g struct us_listen_socket_t *ls = (struct us_listen_socket_t *) p; us_internal_init_listen_socket(ls, group, kind, ssl_ctx, options, socket_ext_size); + /* The process that handed over the descriptor can still hold it. */ + ls->shared = 1; if (options & LIBUS_LISTEN_DEFER_ACCEPT) { ls->deferred_accept = bsd_set_defer_accept(fd); @@ -521,6 +525,10 @@ struct us_socket_group_t *us_listen_socket_group(struct us_listen_socket_t *ls) return ls->accept_group; } +void us_listen_socket_set_shared(struct us_listen_socket_t *ls) { + ls->shared = 1; +} + /* ── Connect ────────────────────────────────────────────────────────────── */ static inline void us_internal_init_connect_socket(struct us_socket_t *s, @@ -542,6 +550,7 @@ static inline void us_internal_init_connect_socket(struct us_socket_t *s, s->read_eof = 0; s->hangup_closes_unsent = 0; s->send_rejected = 0; + s->connect_returned_zero = 0; s->connect_state = NULL; s->connect_next = NULL; } @@ -630,7 +639,12 @@ void *us_socket_group_connect(struct us_socket_group_t *group, unsigned char kin init_addr_with_port(&entries->info, port, &a); *has_dns_resolved = 1; struct us_socket_t *s = us_socket_group_connect_resolved_dns(group, kind, ssl_ctx, &a, local_addr, options, socket_ext_size); - Bun__addrinfo_freeRequest(ai_req, s == NULL); + if (s == NULL) { + Bun__addrinfo_freeRequest(ai_req, 1); + return NULL; + } + /* Held until the connect has an outcome, so a failure can evict the entry. */ + s->connect_addrinfo_req = ai_req; return s; } } @@ -668,7 +682,8 @@ void *us_socket_group_connect(struct us_socket_group_t *group, unsigned char kin struct us_socket_t *us_socket_group_connect_unix(struct us_socket_group_t *group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, const char *server_path, size_t pathlen, int options, int socket_ext_size) { - LIBUS_SOCKET_DESCRIPTOR connect_socket_fd = bsd_create_connect_socket_unix(server_path, pathlen, options); + int connected = 0; + LIBUS_SOCKET_DESCRIPTOR connect_socket_fd = bsd_create_connect_socket_unix(server_path, pathlen, options, &connected); if (connect_socket_fd == LIBUS_SOCKET_ERROR) { return 0; } @@ -685,6 +700,7 @@ struct us_socket_t *us_socket_group_connect_unix(struct us_socket_group_t *group struct us_socket_t *connect_socket = (struct us_socket_t *) p; us_internal_init_connect_socket(connect_socket, group, kind, options); + connect_socket->connect_returned_zero = connected; if (ssl_ctx) { us_internal_ssl_attach(connect_socket, ssl_ctx, 1, NULL, NULL); @@ -704,6 +720,7 @@ int start_connections(struct us_connecting_socket_t *c, int count) { /* The deferred-DNS path does not carry a local binding. */ LIBUS_SOCKET_DESCRIPTOR connect_socket_fd = bsd_create_connect_socket(&addr, NULL, c->options); if (connect_socket_fd == LIBUS_SOCKET_ERROR) { + c->last_candidate_error = LIBUS_ERR; continue; } bsd_socket_nodelay(connect_socket_fd, 1); @@ -711,6 +728,7 @@ int start_connections(struct us_connecting_socket_t *c, int count) { struct us_poll_t *poll = &s->p; us_poll_init(poll, connect_socket_fd, POLL_TYPE_SEMI_SOCKET); if (us_poll_start_rc(poll, loop, LIBUS_SOCKET_WRITABLE) != 0) { + c->last_candidate_error = LIBUS_ERR; bsd_close_socket(connect_socket_fd); us_poll_free(poll, loop); continue; @@ -718,6 +736,7 @@ int start_connections(struct us_connecting_socket_t *c, int count) { ++opened; us_internal_init_connect_socket(s, group, c->kind, c->options); s->timeout = c->timeout; + s->timeout_laps = 0; s->long_timeout = c->long_timeout; us_internal_socket_group_link_socket(group, s); @@ -731,6 +750,13 @@ int start_connections(struct us_connecting_socket_t *c, int count) { return opened; } +/* Every address failed. Without an error here, us_connecting_socket_close reports a caller abort + * (ECANCELED) and keeps the DNS cache entry for the dead host. */ +static void us_internal_connecting_socket_exhausted(struct us_connecting_socket_t *c) { + c->error = c->last_candidate_error ? c->last_candidate_error : ECONNREFUSED; + us_connecting_socket_close(c); +} + void us_internal_socket_after_resolve(struct us_connecting_socket_t *c) { /* close_all() may have run between the DNS thread queuing this callback and * us reaching it; c->group is NULL'd at close so it can't be touched. The @@ -769,10 +795,7 @@ void us_internal_socket_after_resolve(struct us_connecting_socket_t *c) { int opened = start_connections(c, CONCURRENT_CONNECTIONS); if (opened == 0) { - /* Same as the exhausted path in us_internal_socket_after_open: a - * real connect failure must not be reported as a caller abort. */ - c->error = ECONNREFUSED; - us_connecting_socket_close(c); + us_internal_connecting_socket_exhausted(c); } } @@ -803,6 +826,7 @@ void us_internal_socket_after_open(struct us_socket_t *s, int error) { #endif if (error) { if (c) { + c->last_candidate_error = error; for (struct us_socket_t **next = &c->connecting_head; *next; next = &(*next)->connect_next) { if (*next == s) { *next = s->connect_next; @@ -811,16 +835,10 @@ void us_internal_socket_after_open(struct us_socket_t *s, int error) { } us_socket_close(s, LIBUS_SOCKET_CLOSE_CODE_CONNECTION_RESET, 0); - if (c->connecting_head == NULL || c->connecting_head->connect_next == NULL) { - int opened = start_connections(c, c->connecting_head == NULL ? CONCURRENT_CONNECTIONS : 1); - if (opened == 0 && c->connecting_head == NULL) { - /* Every resolved address failed to connect. Without this, - * us_connecting_socket_close defaults c->error to - * ECANCELED (caller abort) and never invalidates the - * DNS cache entry for the dead host. */ - c->error = ECONNREFUSED; - us_connecting_socket_close(c); - } + /* Replace the failed attempt right away. The ones still pending may be black holes. */ + int opened = start_connections(c, c->connecting_head == NULL ? CONCURRENT_CONNECTIONS : 1); + if (opened == 0 && c->connecting_head == NULL) { + us_internal_connecting_socket_exhausted(c); } } else { us_internal_socket_connect_failed(s, error); @@ -844,6 +862,9 @@ void us_internal_socket_after_open(struct us_socket_t *s, int error) { Bun__addrinfo_freeRequest(c->addrinfo_req, 0); us_connecting_socket_free(c); s->connect_state = NULL; + s->connect_next = NULL; + } else { + us_internal_socket_release_addrinfo(s, 0); } if (s->ssl) { diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index c19e06354d9c..8da18f7c9bc8 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -27,6 +27,7 @@ #ifdef LIBUS_USE_OPENSSL #include +#include #include #include #include @@ -201,18 +202,34 @@ struct us_ssl_spill_t { char data[]; }; +/* Ciphertext a client sent behind its ClientHello (TLS 1.3 early data) while an asynchronous SNICallback has the + * handshake suspended. The loop's read BIO is a window onto one recv(), so it can't wait there. */ +struct us_ssl_held_t { + unsigned int length; + char data[]; +}; + +/* A ChangeCipherSpec record, then as much rejected early data as BoringSSL skips (kMaxEarlyDataSkipped in tls_record.cc). */ +#define US_SSL_SNI_HELD_MAX (6 + 16384) + /* Allocated for an accepted socket, a node:tls socket's events, a client's first renegotiation, fetch's session sink, and a first spill. */ struct us_ssl_rare_t { /* Non-NULL exactly while us_socket_t::ssl_spilled is set. */ struct us_ssl_spill_t *spill; /* One reference to the accepting listen socket's names. */ struct us_server_names_t *server_names; + /* us_socket_sni_resolve feeds it to BoringSSL after the answer. */ + struct us_ssl_held_t *sni_held; /* Sessions and keylog lines parked inside SSL_read/SSL_do_handshake, in arrival order. */ struct us_ssl_pending_event_t *pending_head, *pending_tail; void *session_sink; void (*session_sink_free)(void *sink); uint64_t reneg_window_start_ms; uint32_t reneg_count; + /* Set by us_ssl_on_alpn_offer, until us_select_cert_cb has dispatched the offer. */ + unsigned char alpn_offer_wanted; + /* us_ssl_set_key_cert changed this SSL, so it no longer matches its SSL_CTX. */ + unsigned char has_key_cert; }; static void us_ctx_ex_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad, @@ -234,6 +251,7 @@ static void us_ssl_rare_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad, } if (rare->session_sink_free) rare->session_sink_free(rare->session_sink); if (rare->server_names) us_server_names_unref(rare->server_names); + us_free(rare->sni_held); us_free(rare); } @@ -244,6 +262,7 @@ static void us_ssl_rare_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad, /* Defined in src/uws/lib.rs. `wrapper` is what us_ssl_set_wrapper stored. */ extern int us_ssl_wrapper_server_identity(void *wrapper, SSL *ssl); +extern int us_ssl_wrapper_select_cert(void *wrapper, SSL *ssl); extern int us_ssl_wrapper_identity_checked(void *wrapper); extern void us_ssl_wrapper_new_session(void *wrapper, SSL_SESSION *session); extern void us_ssl_wrapper_keylog(void *wrapper, const char *line, size_t length); @@ -1380,7 +1399,7 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, ssl_ctx_build_fail(ssl_context); return NULL; } - } else if (options.ca && options.ca_count > 0) { + } else if (options.ca) { /* A count of 0 is a list that trusts nothing. */ us_ex_idx_ensure(); SSL_CTX_set_ex_data(ssl_context, us_ctx_user_ca_ex_idx, (void *)1); /* As above: user CAs only, into the SSL_CTX's own initially-empty store — @@ -2230,7 +2249,9 @@ struct us_socket_t *us_internal_ssl_close(struct us_socket_t *s, int code, void /* `fin_ends_handshake` is 0 only from the writable event, which the read path * re-enters while a handshake is in progress: the socket keeps reading after * our FIN or close_notify, and the peer's next flight can still complete that - * handshake. For every other caller a half-closed socket's handshake is over. */ + * handshake. For every other caller a half-closed socket's handshake is over, + * unless ssl_raw_write sent that FIN after a refused send: then us_internal_ssl_close + * or ssl_close_after_rejected_send reports the reset. */ static void ssl_update_handshake(struct us_socket_t *s, int fin_ends_handshake) { /* The OpenSSL error queue is per-thread and another socket's failure (a * server and a client commonly share this thread) may have left entries on @@ -2252,7 +2273,7 @@ static void ssl_update_handshake(struct us_socket_t *s, int fin_ends_handshake) if (us_socket_is_closed(s) || s->ssl_fatal_error || (SSL_get_shutdown(s_ssl(s)) & SSL_RECEIVED_SHUTDOWN) || - (fin_ends_handshake && us_internal_ssl_is_shut_down(s))) { + (fin_ends_handshake && us_internal_ssl_is_shut_down(s) && !s->ssl_send_rejected)) { ssl_trigger_handshake(s, 0); return; } @@ -2449,7 +2470,11 @@ struct us_socket_t *us_internal_ssl_on_timeout(struct us_socket_t *s) { /* The read side delivers what the peer sent before it closes, unless the owner already let go. */ static struct us_socket_t *ssl_close_after_rejected_send(struct us_socket_t *s, int send_error) { - if (!s->ssl_close_after_spill && us_socket_queued_input(s) == LIBUS_QUEUED_INPUT_DATA) return s; + if (!s->ssl_close_after_spill && us_socket_queued_input(s) == LIBUS_QUEUED_INPUT_DATA) { + /* This handshake can't complete, so the read delivers no data, only the failure. A paused owner would never hear it. */ + if (s->ssl_handshake_state == HANDSHAKE_PENDING) us_socket_resume(s); + return s; + } if (s->ssl_handshake_state == HANDSHAKE_PENDING) { ssl_set_loop_data(s); ssl_trigger_handshake_econnreset(s); @@ -2559,6 +2584,21 @@ static struct us_socket_t *ssl_fail_established_session(struct us_socket_t *s, s return NULL; } +/* Appends the unread rest of the read window to what `s` already holds. Returns 0 if that's more than US_SSL_SNI_HELD_MAX. */ +static int ssl_hold_input(struct us_socket_t *s, struct loop_ssl_data *loop_ssl_data) { + unsigned int length = loop_ssl_data->ssl_read_input_length; + struct us_ssl_rare_t *rare = us_ssl_rare_ensure(s_ssl(s)); + unsigned int held = rare->sni_held ? rare->sni_held->length : 0; + if (length > US_SSL_SNI_HELD_MAX - held) return 0; + struct us_ssl_held_t *grown = us_realloc(rare->sni_held, sizeof(*grown) + held + length); + if (!grown) return 0; + memcpy(grown->data + held, loop_ssl_data->ssl_read_input + loop_ssl_data->ssl_read_input_offset, length); + grown->length = held + length; + rare->sni_held = grown; + loop_ssl_data->ssl_read_input_length = 0; + return 1; +} + struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *socket, char *data, int length) { /* The parameter is _Nonnull in release builds, and the helpers below return NULL for a socket that is gone. */ struct us_socket_t *s = socket; @@ -2724,9 +2764,10 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *socket, char *da loop_ssl_data->ssl_last_fatal_error[0] = 0; return NULL; } else { - /* If the BIO still has unread ciphertext at this point, the TLS - * framing is broken — close. */ - if (loop_ssl_data->ssl_read_input_length) { + /* A suspended handshake reads nothing, so its input waits for the answer. Anywhere else, + * unread ciphertext means the TLS framing is broken — close. */ + if (loop_ssl_data->ssl_read_input_length && + (err != SSL_ERROR_PENDING_CERTIFICATE || !ssl_hold_input(s, loop_ssl_data))) { return ssl_close(s, 0, NULL); } /* SSL_read drove the handshake to completion but returned no app @@ -3062,6 +3103,10 @@ void us_socket_sni_resolve(struct us_socket_t *s, struct ssl_ctx_st *ctx, int er struct loop_ssl_data *loop_ssl_data = ssl_set_loop_data(s); SSL_CTX *outer_ctx = loop_ssl_data->ssl_sni_resolved_ctx; loop_ssl_data->ssl_sni_resolved_ctx = NULL; + /* Taken now: the re-drive can free the SSL, and the rare block with it. */ + struct us_ssl_rare_t *rare = us_ssl_rare(s_ssl(s)); + struct us_ssl_held_t *held = rare ? rare->sni_held : NULL; + if (held) rare->sni_held = NULL; if (error) { s->ssl_sni_pending = US_SNI_ERROR; if (ctx) SSL_CTX_free(ctx); @@ -3079,6 +3124,20 @@ void us_socket_sni_resolve(struct us_socket_t *s, struct ssl_ctx_st *ctx, int er } /* Re-drive the handshake; select_cert_cb re-fires and consumes the state. */ ssl_update_handshake(s, 1); + /* No read or writable event is due that would close a socket whose handshake just failed. */ + ssl_close_if_fatal(s); + if (held) { + if (!ssl_gone(s) && s->ssl_handshake_state == HANDSHAKE_PENDING) { + /* The raw half of an upgradeTLS() pair saw these bytes when they arrived. */ + unsigned char raw_tap = s->ssl_raw_tap; + s->ssl_raw_tap = 0; + struct us_socket_t *fed = us_internal_ssl_on_data(s, held->data, (int)held->length); + if (fed && !ssl_gone(fed)) fed->ssl_raw_tap = raw_tap; + /* The read window must not outlive `held`. */ + loop_ssl_data->ssl_read_input_length = 0; + } + us_free(held); + } if (loop_ssl_data->ssl_sni_resolved_ctx) SSL_CTX_free(loop_ssl_data->ssl_sni_resolved_ctx); loop_ssl_data->ssl_sni_resolved_ctx = outer_ctx; } @@ -3150,7 +3209,7 @@ struct us_server_names_t { /* hostname -> sni_node_t. NULL until the first name is added. */ void *tree; /* Returns the SSL_CTX to serve for `hostname` on the in-flight handshake only - * (the caller does not cache it), or NULL to fall through to the tree. */ + * (the caller does not cache it), or NULL to keep the default one. While this is set nothing reads the tree. */ struct ssl_ctx_st *(*on_server_name)(struct us_listen_socket_t *, const char *hostname, int *abort_handshake, struct us_socket_t *socket); }; @@ -3203,6 +3262,8 @@ void us_ssl_ctx_set_sni_policy(SSL_CTX *ctx, int request_cert, int reject_unauth SSL_CTX_set_session_id_context(ctx, (const uint8_t *)&sid, sizeof(sid)); } +static enum ssl_select_cert_result_t us_select_cert_cb(const SSL_CLIENT_HELLO *hello); + /* Every SNI context switch goes through here: SSL_set_SSL_CTX alone leaves * verify_mode as inherited from the default context at accept, so a * per-serverName entry's requestCert/rejectUnauthorized are added on top of @@ -3217,7 +3278,19 @@ void us_ssl_ctx_set_sni_policy(SSL_CTX *ctx, int request_cert, int reject_unauth * authenticated again, against this context's CA. That refusal is deliberate * (RFC 6066 section 3; openssl/ssl.h: "partition session caches between SNI * hosts") and must not be relaxed for parity with another runtime. */ -static void us_ssl_apply_selected_ctx(SSL *ssl, SSL_CTX *ctx) { +void us_ssl_apply_selected_ctx(SSL *ssl, SSL_CTX *ctx) { + struct us_ssl_rare_t *rare = us_ssl_rare(ssl); + /* An SSLWrapper switches between two runs of us_select_cert_cb, and BoringSSL reads that callback off the current SSL_CTX. */ + if (rare && rare->alpn_offer_wanted) SSL_CTX_set_select_certificate_cb(ctx, us_select_cert_cb); + if (rare && rare->has_key_cert) { + rare->has_key_cert = 0; + SSL_set0_client_CAs(ssl, NULL); + /* SSL_set_SSL_CTX does nothing for the SSL_CTX the SSL is already on, so move it off first. */ + SSL_CTX *blank = SSL_CTX_new(TLS_method()); + if (!blank) Bun__outOfMemory(); + SSL_set_SSL_CTX(ssl, blank); + SSL_CTX_free(blank); + } SSL_set_SSL_CTX(ssl, ctx); if (us_ctx_sni_policy_ex_idx < 0) return; uintptr_t policy = (uintptr_t)SSL_CTX_get_ex_data(ctx, us_ctx_sni_policy_ex_idx); @@ -3276,15 +3349,14 @@ static size_t us_client_hello_servername(const SSL_CLIENT_HELLO *hello, char *ou return 0; } -/* The async-capable certificate selector. Registered (instead of relying on +/* The SNI stage of us_select_cert_cb. + * The async-capable certificate selector. Registered (instead of relying on * sni_cb alone) on listener contexts that have a dynamic JS resolver, so an * SNICallback that cannot answer synchronously suspends the handshake * (ssl_select_cert_retry -> SSL_ERROR_PENDING_CERTIFICATE) instead of falling * through to the default context. us_socket_sni_resolve() resumes it. */ -static enum ssl_select_cert_result_t us_select_cert_cb(const SSL_CLIENT_HELLO *hello) { +static enum ssl_select_cert_result_t us_select_cert_sni(const SSL_CLIENT_HELLO *hello, struct us_socket_t *s) { SSL *ssl = hello->ssl; - struct us_socket_t *s = ssl ? us_ssl_socket(ssl) : NULL; - if (!s) return ssl_select_cert_success; struct us_ssl_rare_t *rare = us_ssl_rare(ssl); struct us_server_names_t *names = rare ? rare->server_names : NULL; @@ -3297,24 +3369,6 @@ static enum ssl_select_cert_result_t us_select_cert_cb(const SSL_CLIENT_HELLO *h if (resolved_ctx) { us_ssl_apply_selected_ctx(ssl, resolved_ctx); SSL_CTX_free(resolved_ctx); - return ssl_select_cert_success; - } - /* The asynchronous resolution selected nothing (cb(null, null)): fall - * through to the static SNI tree below, exactly like a synchronous - * resolver returning null - the resume must not skip the tree fallback - * the sync path gets. */ - if (names) { - /* Read the servername from the raw ClientHello, same as the first-call - * path below: that is the read the early-callback contract guarantees - * (SSL_get_servername happens to be populated by the resume re-drive - * today, but the raw parse does not depend on that). */ - char resumed_host[256]; - if (us_client_hello_servername(hello, resumed_host, sizeof(resumed_host))) { - struct sni_node_t *resumed_node = us_server_names_find(names, resumed_host); - if (resumed_node) { - us_ssl_apply_selected_ctx(ssl, resumed_node->ctx); - } - } } return ssl_select_cert_success; } @@ -3339,14 +3393,8 @@ static enum ssl_select_cert_result_t us_select_cert_cb(const SSL_CLIENT_HELLO *h return ssl_select_cert_success; } - /* The dynamic resolver (the user's SNICallback) runs FIRST, matching Node - * where a user-provided SNICallback replaces the default SNI handling - * entirely (tree-first would shadow the callback for every name that also - * has an addContext entry). The static tree (addContext entries) is the - * fallback when the resolver selects nothing, which is also the - * no-user-callback path: the JS dispatch returns undefined immediately in - * that case. */ - + /* The resolver alone selects. In Node a user SNICallback replaces the default one, the only reader of the addContext() + * entries: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1270 */ void *saved_loop_state[US_SSL_LOOP_STATE_SLOTS]; us_internal_ssl_loop_state_save(ssl, saved_loop_state); int abort_handshake = 0; @@ -3370,16 +3418,78 @@ static enum ssl_select_cert_result_t us_select_cert_cb(const SSL_CLIENT_HELLO *h if (dyn) { us_ssl_apply_selected_ctx(ssl, dyn); SSL_CTX_free(dyn); - return ssl_select_cert_success; } + return ssl_select_cert_success; +} + +static int sni_cb(SSL *ssl, int *al, void *arg); - /* No dynamic selection: fall back to the static SNI tree (the - * addContext() entries). An adopted socket has no tree. */ - struct sni_node_t *node = us_server_names_find(names, hostname); - if (node) { - us_ssl_apply_selected_ctx(ssl, node->ctx); +/* Returns 1 if BoringSSL's negotiate_version, which runs after us_select_cert_cb, will pick TLS 1.3. */ +static int us_client_hello_is_tls13(const SSL_CLIENT_HELLO *hello) { + const uint8_t *extension; + size_t extension_length; + if (SSL_get_max_proto_version(hello->ssl) < TLS1_3_VERSION || (SSL_get_options(hello->ssl) & SSL_OP_NO_TLSv1_3) || + !SSL_early_callback_ctx_extension_get(hello, TLSEXT_TYPE_supported_versions, &extension, &extension_length)) { + return 0; } - return ssl_select_cert_success; + CBS contents, versions; + uint16_t version; + CBS_init(&contents, extension, extension_length); + if (!CBS_get_u8_length_prefixed(&contents, &versions)) return 0; + while (CBS_get_u16(&versions, &version)) { + if (version == TLS1_3_VERSION) return 1; + } + return 0; +} + +/* The ALPN stage of us_select_cert_cb. Returns 0 if the owner refused every protocol the client offers. */ +static int us_select_cert_alpn(const SSL_CLIENT_HELLO *hello, struct us_ssl_rare_t *rare) { + rare->alpn_offer_wanted = 0; + const uint8_t *extension; + size_t extension_length; + if (!SSL_early_callback_ctx_extension_get( + hello, TLSEXT_TYPE_application_layer_protocol_negotiation, &extension, &extension_length)) { + return 1; + } + /* ssl_negotiate_alpn (vendor/boringssl/ssl/extensions.cc) runs these checks later and fails the handshake. */ + CBS contents, list, names, name; + CBS_init(&contents, extension, extension_length); + if (!CBS_get_u16_length_prefixed(&contents, &list) || CBS_len(&contents) != 0 || CBS_len(&list) == 0) return 1; + names = list; + while (CBS_len(&names) > 0) { + if (!CBS_get_u8_length_prefixed(&names, &name) || CBS_len(&name) == 0) return 1; + } + return us_dispatch_alpn_offer(hello->ssl, CBS_data(&list), (unsigned int)CBS_len(&list)); +} + +/* BoringSSL picks the cipher, the certificate and the session after this callback and only then runs its ALPN + * callback, too late for node:tls setKeyCert(). So us_dispatch_alpn_offer runs here, in OpenSSL's order: before + * SNI on TLS 1.3, after it on TLS 1.2. */ +static enum ssl_select_cert_result_t us_select_cert_cb(const SSL_CLIENT_HELLO *hello) { + SSL *ssl = hello->ssl; + if (!ssl) return ssl_select_cert_success; + struct us_ssl_rare_t *rare = us_ssl_rare(ssl); + if (rare && rare->alpn_offer_wanted && us_client_hello_is_tls13(hello) && !us_select_cert_alpn(hello, rare)) { + /* BoringSSL's ALPN selection sends the alert. */ + return ssl_select_cert_success; + } + struct us_socket_t *s = us_ssl_socket(ssl); + enum ssl_select_cert_result_t result = ssl_select_cert_success; + if (s) { + result = us_select_cert_sni(hello, s); + } else { + /* No us_socket_t drives this SSL, so its SSLWrapper decides (src/uws/lib.rs). */ + void *wrapper = us_ssl_wrapper(ssl); + if (wrapper && !us_ssl_wrapper_select_cert(wrapper, ssl)) result = ssl_select_cert_retry; + } + if (result == ssl_select_cert_success && rare && rare->alpn_offer_wanted) { + sni_cb(ssl, NULL, NULL); + /* BoringSSL calls sni_cb again later. Dropping the names keeps that call from undoing a us_ssl_set_key_cert. */ + if (rare->server_names) us_server_names_unref(rare->server_names); + rare->server_names = NULL; + us_select_cert_alpn(hello, rare); + } + return result; } static int sni_cb(SSL *ssl, int *al, void *arg) { @@ -3391,11 +3501,7 @@ static int sni_cb(SSL *ssl, int *al, void *arg) { struct us_server_names_t *names = rare ? rare->server_names : NULL; if (!names) return SSL_TLSEXT_ERR_OK; if (names->on_server_name) { - /* A dynamic resolver (user SNICallback) exists: us_select_cert_cb already - * ran it - and the static-tree fallback - at the earlier - * select-certificate stage. Consulting the tree again here would - * OVERWRITE the resolver's per-connection selection with the tree entry, - * undoing the SNICallback-takes-precedence contract. */ + /* us_select_cert_cb already ran the resolver, which alone selects for this listener. */ return SSL_TLSEXT_ERR_OK; } const char *hostname = SSL_get_servername(ssl, TLSEXT_NAMETYPE_host_name); @@ -3468,9 +3574,7 @@ void us_listen_socket_on_server_name(struct us_listen_socket_t *ls, us_listen_socket_server_names(ls)->on_server_name = cb; /* The dynamic resolver may need to suspend the handshake (async * SNICallback); only the early select-certificate callback supports retry, - * so register it on the listener's default context. The servername-stage - * sni_cb stays registered for the static SNI tree (it is a no-op when the - * early callback already installed a context). */ + * so register it on the listener's default context. */ SSL_CTX_set_select_certificate_cb(ls->ssl_ctx, us_select_cert_cb); } @@ -3488,6 +3592,39 @@ void us_socket_on_server_name(struct us_socket_t *s) { if (ctx) SSL_CTX_set_select_certificate_cb(ctx, us_select_cert_cb); } +/* us_socket_on_server_name for an SSLWrapper's SSL, which has no us_socket_t. */ +void us_ssl_wrapper_on_server_name(SSL *ssl) { + SSL_CTX_set_select_certificate_cb(SSL_get_SSL_CTX(ssl), us_select_cert_cb); +} + +/* Takes the SSL, not the socket: an SSLWrapper's has no us_socket_t. */ +void us_ssl_on_alpn_offer(SSL *ssl) { + us_ssl_rare_ensure(ssl)->alpn_offer_wanted = 1; + /* Permanent on a possibly shared SSL_CTX, like us_socket_on_server_name. */ + SSL_CTX_set_select_certificate_cb(SSL_get_SSL_CTX(ssl), us_select_cert_cb); +} + +/* https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_tls.cc#L1674-L1699. The SSL_CTX stays, and with it the + * callbacks, the ciphers and the signature algorithms. `sid_ctx` keeps sessions apart as in us_ssl_apply_selected_ctx. */ +int us_ssl_set_key_cert(SSL *ssl, SSL_CTX *ctx, const unsigned char *sid_ctx, size_t sid_ctx_length) { + X509 *leaf = SSL_CTX_get0_certificate(ctx); + EVP_PKEY *key = SSL_CTX_get0_privatekey(ctx); + STACK_OF(X509) *chain = NULL; + if (leaf == NULL || key == NULL || !SSL_CTX_get0_chain_certs(ctx, &chain)) return 0; + us_ssl_rare_ensure(ssl)->has_key_cert = 1; + SSL_certs_clear(ssl); + if (!SSL_use_certificate(ssl, leaf) || !SSL_use_PrivateKey(ssl, key) || + (sk_X509_num(chain) > 0 && !SSL_set1_chain(ssl, chain)) || + !SSL_set1_verify_cert_store(ssl, SSL_CTX_get_cert_store(ctx)) || + !SSL_set_session_id_context(ssl, sid_ctx, sid_ctx_length)) { + return 0; + } + SSL_set_client_CA_list(ssl, SSL_dup_CA_list(SSL_CTX_get_client_CA_list(ctx))); + /* Past the ServerHello the new session already carries the old session id context, and `ctx`'s CAs will judge the client. */ + SSL_SESSION *session = SSL_in_init(ssl) && !SSL_session_reused(ssl) ? SSL_get_session(ssl) : NULL; + return session == NULL || SSL_SESSION_set1_id_context(session, sid_ctx, sid_ctx_length); +} + 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/crypto/root_certs.cpp b/packages/bun-usockets/src/crypto/root_certs.cpp index 3a6f010c3bea..17302c373d8d 100644 --- a/packages/bun-usockets/src/crypto/root_certs.cpp +++ b/packages/bun-usockets/src/crypto/root_certs.cpp @@ -48,12 +48,17 @@ static void us_cert_file_into_bio(void *ctx, const uint8_t *data, size_t len) { // Forward declarations for platform-specific functions // (Actual implementations are in platform-specific files) -// External variable from Zig CLI arguments +// CA selection initialized by bun_core::Global::set_ca_store_from_flags. +extern "C" bool Bun__Node__UseOpenSSLCA; extern "C" bool Bun__Node__UseSystemCA; // Helper function to check if system CA should be used // Checks both CLI flag (--use-system-ca) and environment variable (NODE_USE_SYSTEM_CA=1) static bool us_should_use_system_ca() { + if (Bun__Node__UseOpenSSLCA) { + return false; + } + // Check CLI flag first if (Bun__Node__UseSystemCA) { return true; @@ -188,7 +193,7 @@ STACK_OF(X509) *us_get_root_extra_cert_instances() { // The file half of X509_STORE_set_default_paths ($SSL_CERT_FILE, else X509_get_default_cert_file(), usually // /etc/ssl/cert.pem), trusting exactly what X509_load_cert_crl_file would, but read once per process into a lazy set // (parsed per certificate on first use, shared by every store) rather than parsed whole into each store, and without -// the certificates the bundled set already holds byte-for-byte — on most distros that is most of the file. +// the certificates the bundled set already holds byte-for-byte when it is enabled — on most distros that is most of the file. struct us_openssl_default_cert_file { X509_LAZY_CERT_SET *certs = nullptr; STACK_OF(X509) *trusted = nullptr; // TRUSTED CERTIFICATE blocks carry auxiliary data the lazy set does not model @@ -206,13 +211,15 @@ static const us_openssl_default_cert_file &us_get_openssl_default_cert_file() { return; } - const uint8_t *const *bundled_certs; - const size_t *bundled_lens; - size_t bundled_count = us_bundled_root_certs_der(&bundled_certs, &bundled_lens); std::unordered_set bundled; - bundled.reserve(bundled_count); - for (size_t i = 0; i < bundled_count; i++) { - bundled.emplace(reinterpret_cast(bundled_certs[i]), bundled_lens[i]); + if (!Bun__Node__UseOpenSSLCA) { + const uint8_t *const *bundled_certs; + const size_t *bundled_lens; + size_t bundled_count = us_bundled_root_certs_der(&bundled_certs, &bundled_lens); + bundled.reserve(bundled_count); + for (size_t i = 0; i < bundled_count; i++) { + bundled.emplace(reinterpret_cast(bundled_certs[i]), bundled_lens[i]); + } } std::vector certs; @@ -298,10 +305,12 @@ extern "C" X509_STORE *us_get_default_ca_store() { } X509_STORE_set_flags(store, X509_V_FLAG_IGNORE_EXPIRED_TRUST_ANCHORS); - X509_LAZY_CERT_SET *bundled = us_get_bundled_root_cert_set(); - if (bundled == NULL || !X509_STORE_add_lazy_cert_set(store, bundled)) { - X509_STORE_free(store); - return NULL; + if (!Bun__Node__UseOpenSSLCA) { + X509_LAZY_CERT_SET *bundled = us_get_bundled_root_cert_set(); + if (bundled == NULL || !X509_STORE_add_lazy_cert_set(store, bundled)) { + X509_STORE_free(store); + return NULL; + } } // What X509_STORE_set_default_paths(store) trusts: the default certificate file (above) and the default hashed diff --git a/packages/bun-usockets/src/eventing/epoll_kqueue.c b/packages/bun-usockets/src/eventing/epoll_kqueue.c index 6e603ba7bed4..1052dba7f8a1 100644 --- a/packages/bun-usockets/src/eventing/epoll_kqueue.c +++ b/packages/bun-usockets/src/eventing/epoll_kqueue.c @@ -254,10 +254,10 @@ struct us_loop_t *us_create_loop(void *hint, void (*wakeup_cb)(struct us_loop_t return loop; } -/* Shared dispatch loop for both us_loop_run and us_loop_run_bun_tick */ -static void us_internal_dispatch_ready_polls(struct us_loop_t *loop) { +/* Shared dispatch loop for both us_loop_run and us_loop_run_bun_tick. Dispatches ready_polls[first..]. */ +static void us_internal_dispatch_ready_polls(struct us_loop_t *loop, int first) { #ifdef LIBUS_USE_EPOLL - for (loop->current_ready_poll = 0; loop->current_ready_poll < loop->num_ready_polls; loop->current_ready_poll++) { + for (loop->current_ready_poll = first; loop->current_ready_poll < loop->num_ready_polls; loop->current_ready_poll++) { struct us_poll_t *poll = GET_READY_POLL(loop, loop->current_ready_poll); if (LIKELY(poll)) { if (CLEAR_POINTER_TAG(poll) != poll) { @@ -298,7 +298,7 @@ static void us_internal_dispatch_ready_polls(struct us_loop_t *loop) { struct kevent_flags coalesced[LIBUS_MAX_READY_POLLS]; /* no zeroing needed — every index is written in the first pass */ /* First pass: decode kevents and coalesce same-poll entries */ - for (int i = 0; i < loop->num_ready_polls; i++) { + for (int i = first; i < loop->num_ready_polls; i++) { struct us_poll_t *poll = GET_READY_POLL(loop, i); if (!poll || CLEAR_POINTER_TAG(poll) != poll) { coalesced[i] = (struct kevent_flags){ .skip = 1 }; @@ -329,7 +329,7 @@ static void us_internal_dispatch_ready_polls(struct us_loop_t *loop) { /* Look backward for a prior entry with the same poll to coalesce into. * Kqueue returns at most 2 kevents per fd (READ + WRITE). */ int merged = 0; - for (int j = i - 1; j >= 0; j--) { + for (int j = i - 1; j >= first; j--) { if (!coalesced[j].skip && GET_READY_POLL(loop, j) == poll) { coalesced[j].readable |= bits.readable; coalesced[j].writable |= bits.writable; @@ -349,7 +349,7 @@ static void us_internal_dispatch_ready_polls(struct us_loop_t *loop) { } /* Second pass: dispatch everything in order — tagged pointers and coalesced events */ - for (loop->current_ready_poll = 0; loop->current_ready_poll < loop->num_ready_polls; loop->current_ready_poll++) { + for (loop->current_ready_poll = first; loop->current_ready_poll < loop->num_ready_polls; loop->current_ready_poll++) { struct us_poll_t *poll = GET_READY_POLL(loop, loop->current_ready_poll); if (!poll) continue; @@ -424,12 +424,18 @@ static void us_internal_drain_ready_polls(struct us_loop_t *loop) { loop->num_ready_polls = 0; break; } - us_internal_dispatch_ready_polls(loop); + us_internal_dispatch_ready_polls(loop, 0); } } -/* Bound `timeout` by the socket-timeout sweep deadline (NULL == forever). */ -static const struct timespec *us_internal_clamp_to_sweep(struct us_loop_t *loop, const struct timespec *timeout, struct timespec *storage) { +/* Bound `timeout` (NULL == forever) by the loop's own pending work: parked TLS handshakes and the socket-timeout sweep deadline. */ +static const struct timespec *us_internal_clamp_timeout(struct us_loop_t *loop, const struct timespec *timeout, struct timespec *storage) { + /* Only a loop iteration releases a parked socket (us_internal_handle_low_priority_sockets). */ + if (loop->data.low_prio_head) { + storage->tv_sec = 0; + storage->tv_nsec = 0; + return storage; + } long long ns = us_internal_sweep_timeout_ns(loop); if (ns < 0) { return timeout; @@ -453,7 +459,7 @@ void us_loop_run(struct us_loop_t *loop) { us_internal_loop_pre(loop); struct timespec sweep_ts; - const struct timespec *timeout = us_internal_clamp_to_sweep(loop, NULL, &sweep_ts); + const struct timespec *timeout = us_internal_clamp_timeout(loop, NULL, &sweep_ts); /* Fetch ready polls */ #ifdef LIBUS_USE_EPOLL @@ -462,7 +468,7 @@ void us_loop_run(struct us_loop_t *loop) { loop->num_ready_polls = bun_kevent64_wait(loop->fd, loop->ready_polls, LIBUS_MAX_READY_POLLS, 0, timeout); #endif - us_internal_dispatch_ready_polls(loop); + us_internal_dispatch_ready_polls(loop, 0); us_internal_drain_ready_polls(loop); us_internal_sweep_if_due(loop); @@ -484,6 +490,17 @@ void us_loop_run_bun_tick(struct us_loop_t *loop, const struct timespec* timeout /* Emit pre callback */ us_internal_loop_pre(loop); + /* A poll callback re-entered the loop (expect().rejects -> waitForPromise) before the outer tick + * dispatched its whole batch. Polling now would overwrite the rest of that batch, and a one-shot + * poll in it would never fire again, so dispatch it as this tick's batch instead. */ + const int undispatched = loop->current_ready_poll + 1; + if (UNLIKELY(undispatched < loop->num_ready_polls)) { + us_internal_dispatch_ready_polls(loop, undispatched); + us_internal_loop_post(loop); + loop->data.tick_depth--; + return; + } + /* loop_pre runs lsquic_engine_process_conns and stores the soonest * earliest_adv_tick. The JS event loop folds this in via src/runtime/timer/mod.rs; other * callers of us_loop_run_bun_tick (HTTP thread) pass NULL, so fold it @@ -500,7 +517,7 @@ void us_loop_run_bun_tick(struct us_loop_t *loop, const struct timespec* timeout } struct timespec sweep_ts; - timeout = us_internal_clamp_to_sweep(loop, timeout, &sweep_ts); + timeout = us_internal_clamp_timeout(loop, timeout, &sweep_ts); const unsigned int had_wakeups = __atomic_exchange_n(&loop->pending_wakeups, 0, __ATOMIC_ACQUIRE); const int will_idle_inside_event_loop = had_wakeups == 0 && (!timeout || (timeout->tv_nsec != 0 || timeout->tv_sec != 0)); @@ -555,7 +572,7 @@ void us_loop_run_bun_tick(struct us_loop_t *loop, const struct timespec* timeout if (released_heap_access) Bun__JSC_acquireHeapAccessAfterWait(loop->data.jsc_vm); - us_internal_dispatch_ready_polls(loop); + us_internal_dispatch_ready_polls(loop, 0); us_internal_drain_ready_polls(loop); us_internal_sweep_if_due(loop); @@ -924,16 +941,19 @@ struct us_internal_async *us_internal_create_async(struct us_loop_t *loop, int f void us_internal_async_close(struct us_internal_async *a) { struct us_internal_callback_t *internal_cb = (struct us_internal_callback_t *) a; + /* us_internal_async_set registered the knote with the port as its ident. */ struct kevent64_s event; - uint64_t ptr = (uint64_t)(void*)internal_cb; - EV_SET64(&event, ptr, EVFILT_MACHPORT, EV_DELETE, 0, 0, (uint64_t)(void*)internal_cb, 0,0); + EV_SET64(&event, internal_cb->port, EVFILT_MACHPORT, EV_DELETE, 0, 0, (uint64_t)(void*)internal_cb, 0,0); int ret; do { ret = kevent64(internal_cb->loop->fd, &event, 1, &event, 1, KEVENT_FLAG_ERROR_EVENTS, NULL); } while (IS_EINTR(ret)); - mach_port_deallocate(mach_task_self(), internal_cb->port); + /* mach_port_deallocate only drops the send right. The port lives until its receive right is released too. */ + mach_port_t self = mach_task_self(); + mach_port_deallocate(self, internal_cb->port); + mach_port_mod_refs(self, internal_cb->port, MACH_PORT_RIGHT_RECEIVE, -1); us_free(internal_cb->machport_buf); /* (regular) sockets are the only polls which are not freed immediately */ diff --git a/packages/bun-usockets/src/eventing/libuv.c b/packages/bun-usockets/src/eventing/libuv.c index 5b6b9bf10c55..4ae689655825 100644 --- a/packages/bun-usockets/src/eventing/libuv.c +++ b/packages/bun-usockets/src/eventing/libuv.c @@ -147,6 +147,10 @@ static void poll_cb(uv_poll_t *p, int status, int events) { static void prepare_cb(uv_prepare_t *p) { struct us_loop_t *loop = p->data; us_internal_loop_pre(loop); + /* Only a loop iteration releases a parked socket (us_internal_handle_low_priority_sockets). */ + if (loop->data.low_prio_head) { + us_wakeup_loop(loop); + } } /* Note: libuv timers execute AFTER the post callback */ diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index 724717d18cac..d14a4b3aba40 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -84,6 +84,7 @@ extern void __attribute__((__noreturn__)) Bun__outOfMemory(void); #define IS_EINTR(rc) (rc == SOCKET_ERROR && WSAGetLastError() == WSAEINTR) #define LIBUS_ERR WSAGetLastError() #define LIBUS_ECONNRESET WSAECONNRESET +#define LIBUS_EADDRINUSE WSAEADDRINUSE /* What libuv translates to UV_ECANCELED (uv_translate_sys_error). */ #define LIBUS_ECANCELED WSAEINTR #else @@ -91,6 +92,7 @@ extern void __attribute__((__noreturn__)) Bun__outOfMemory(void); #define IS_EINTR(rc) (rc == -1 && errno == EINTR) #define LIBUS_ERR errno #define LIBUS_ECONNRESET ECONNRESET +#define LIBUS_EADDRINUSE EADDRINUSE #define LIBUS_ECANCELED ECANCELED #endif #include @@ -154,6 +156,9 @@ extern int us_dispatch_new_session(us_socket_r s, struct ssl_session_st *session /* Returns a US_IDENTITY_* verdict on the name of the server's certificate. Must not run JS. */ extern int us_dispatch_server_identity(us_socket_r s, struct ssl_st *ssl); extern struct ssl_ctx_st *us_dispatch_socket_server_name(us_socket_r s, const char *hostname, int *abort_handshake); +/* The ALPN protocols a ClientHello offers, as entries of (u8 length, bytes). Only for an SSL that called us_ssl_on_alpn_offer. + * Returns 0 if the owner refuses them all. */ +extern int us_dispatch_alpn_offer(struct ssl_st *ssl, const unsigned char *protocols, unsigned int length); extern struct us_socket_t *us_dispatch_ssl_raw_tap(us_socket_r s, char *data, int length); /* crypto/sni_tree.cpp: the one place server names are compared. */ void *sni_new(); @@ -373,11 +378,13 @@ struct us_socket_t { unsigned char ssl_sni_resolver : 1; /* This socket has entries in the loop's ssl_pending_events. */ unsigned char ssl_has_pending_events : 1; - /* Peer FIN was dispatched as on_end on a half-open socket; readable interest is never re-added and on_end never re-fires. */ + /* Peer FIN was dispatched as on_end on a half-open socket. on_end never re-fires, and only a shutdown re-adds readable interest, to close. */ unsigned char read_eof : 1; /* A hangup that leaves bytes unsent closes the socket even while it is paused (loop.c defers it otherwise). * For an owner whose pause can wait for those bytes to drain: node:http's pipelining. */ unsigned char hangup_closes_unsent : 1; + /* connect() itself returned 0 (AF_UNIX), so the connection was established before its first poll event. */ + unsigned char connect_returned_zero : 1; /* The close code passed to the deferred close (e.g. a reset requested from * inside a handshake callback must still RST, not FIN, when it is finally * performed). */ @@ -395,13 +402,22 @@ struct us_socket_t { * us_socket_write_check_error). Reset by any send that makes progress. * Lives in the pad-to-pointer gap before `group`, so it costs nothing. */ unsigned char unclassified_send_failures; + /* Timer-sweep ticks this socket has spent parked in the low-priority queue. Fits in the same gap. */ + unsigned char low_prio_ticks; + /* Full turns of the 240-tick wheel to skip before a match on `timeout` fires. Fits in the same gap. */ + uint16_t timeout_laps; struct us_socket_group_t *group; /* NULL for plain TCP. Direct BoringSSL `SSL*`; set by us_internal_ssl_attach * in adopt_tls / connect-with-ssl_ctx / accept-with-ssl_ctx. */ struct ssl_st *ssl; struct us_socket_t *prev, *next; - struct us_socket_t *connect_next; + union { + /* connect_state != NULL: the next candidate of that us_connecting_socket_t. */ + struct us_socket_t *connect_next; + /* connect_state == NULL, still connecting: the DNS cache entry this socket's address came from. */ + struct addrinfo_request *connect_addrinfo_req; + }; struct us_connecting_socket_t *connect_state; }; @@ -409,6 +425,14 @@ struct us_socket_t { _Static_assert(sizeof(struct us_socket_flags) == 1, "us_socket_flags grew"); #endif +/* Call once a connect without a connect_state succeeds, fails or is closed. `failed` evicts the cache entry. */ +static inline void us_internal_socket_release_addrinfo(struct us_socket_t *s, int failed) { + if (s->connect_addrinfo_req) { + Bun__addrinfo_freeRequest(s->connect_addrinfo_req, failed); + s->connect_addrinfo_req = NULL; + } +} + /* Whether a raw write can send: the fd is open and no FIN went out. */ static inline int us_internal_socket_can_raw_write(struct us_socket_t *s) { return !s->flags.is_closed && us_internal_poll_type(&s->p) != POLL_TYPE_SOCKET_SHUT_DOWN; @@ -453,6 +477,8 @@ struct us_connecting_socket_t { unsigned char kind; uint16_t port; int error; + /* Why the most recent address failed (LIBUS_ERR numbering). Becomes `error` once every address has. */ + int last_candidate_error; struct addrinfo *addrinfo_head; // this is used to track pending connecting sockets in the context struct us_connecting_socket_t* next_pending; @@ -533,6 +559,8 @@ struct us_listen_socket_t { unsigned char deferred_accept; /* LIBUS_SOCKET_OPEN_PAUSED: accepted sockets start without read interest. */ unsigned char accept_paused; + /* Another process can accept on this socket too: it was sent over IPC or adopted from a descriptor (bsd_accept_socket). */ + unsigned char shared; }; void us_internal_socket_group_link_connecting_socket(us_socket_group_r group, struct us_connecting_socket_t *c); diff --git a/packages/bun-usockets/src/internal/networking/bsd.h b/packages/bun-usockets/src/internal/networking/bsd.h index e269a5ca81c5..ae51641425a3 100644 --- a/packages/bun-usockets/src/internal/networking/bsd.h +++ b/packages/bun-usockets/src/internal/networking/bsd.h @@ -209,8 +209,8 @@ int bsd_addr_get_ip_length(struct bsd_addr_t *addr); int bsd_addr_get_port(struct bsd_addr_t *addr); -// called by dispatch_ready_poll -LIBUS_SOCKET_DESCRIPTOR bsd_accept_socket(LIBUS_SOCKET_DESCRIPTOR fd, struct bsd_addr_t *addr); +// called by dispatch_ready_poll. shared: another process can accept on fd too. +LIBUS_SOCKET_DESCRIPTOR bsd_accept_socket(LIBUS_SOCKET_DESCRIPTOR fd, struct bsd_addr_t *addr, int shared); ssize_t bsd_recv(LIBUS_SOCKET_DESCRIPTOR fd, void *buf, int length, int flags); /* One of the LIBUS_QUEUED_INPUT_* codes. Peeks, so it consumes nothing and the @@ -242,7 +242,8 @@ int bsd_disconnect_udp_socket(LIBUS_SOCKET_DESCRIPTOR fd); LIBUS_SOCKET_DESCRIPTOR bsd_create_connect_socket(struct sockaddr_storage *addr, struct sockaddr_storage *local_addr, int options); -LIBUS_SOCKET_DESCRIPTOR bsd_create_connect_socket_unix(const char *server_path, size_t pathlen, int options); +/* Sets *connected to 1 if connect() itself returned 0, so the connection is already established. */ +LIBUS_SOCKET_DESCRIPTOR bsd_create_connect_socket_unix(const char *server_path, size_t pathlen, int options, int *connected); int bsd_socket_export_size(void); int bsd_socket_export(LIBUS_SOCKET_DESCRIPTOR fd, unsigned int target_pid, void *info_out); diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index 02f3a0523f41..8e6839d525f4 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -151,7 +151,8 @@ enum { LIBUS_LISTEN_DISALLOW_REUSE_PORT_FAILURE = 32, /* Ask the kernel to defer accept() until the client has sent data (TCP_DEFER_ACCEPT on * Linux, SO_ACCEPTFILTER "dataready" on FreeBSD). When set, accepted sockets are - * dispatched as readable immediately, skipping a round-trip through the event loop. + * dispatched as readable immediately, skipping a round-trip through the event loop + * (once the TLS handshake budget is spent, only if the socket has input). * Safe for HTTP/TLS where the client always sends first; do not use for protocols where * the server sends the first bytes. */ LIBUS_LISTEN_DEFER_ACCEPT = 64, @@ -432,7 +433,8 @@ void us_listen_socket_set_default_ssl_ctx(struct us_listen_socket_t *ls, int us_ssl_parse_pkcs12(const char *data, size_t len, const char *pass, char **out_key, size_t *out_key_len, char **out_cert, size_t *out_cert_len, char **out_ca, size_t *out_ca_len, const char **err_reason); -/* After us_listen_socket_close(ls) the resolver still runs for accepted connections, with NULL in place of `ls`. */ +/* The resolver alone selects: the names of us_listen_socket_add_server_name aren't looked up, and NULL keeps the default context. + * After us_listen_socket_close(ls) the resolver still runs for accepted connections, with NULL in place of `ls`. */ void us_listen_socket_on_server_name(struct us_listen_socket_t *ls, struct ssl_ctx_st *(*cb)(struct us_listen_socket_t *, const char *hostname, int *abort_handshake, struct us_socket_t *socket)) nonnull_fn_decl; /* Resume a handshake suspended by an async SNICallback (the dynamic resolver @@ -453,6 +455,12 @@ int us_ssl_ctx_reject_unauthorized(struct ssl_ctx_st *ctx); /* Socket-level SNI resolver, for a server-side socket adopted into TLS with no * listen socket behind it: us_dispatch_socket_server_name then resolves. */ void us_socket_on_server_name(us_socket_r s); +/* For a server-side SSL, before its handshake starts. us_dispatch_alpn_offer then runs before BoringSSL picks the + * cipher, the certificate and the session: ahead of the server name resolvers on TLS 1.3, after them on TLS 1.2. */ +void us_ssl_on_alpn_offer(struct ssl_st *ssl); +/* node:tls setKeyCert() on a server: gives this connection `ctx`'s certificate, key and CAs, with `sid_ctx` as its session + * id context. A context that SNI selects later replaces them. Returns 0 if `ctx` has no certificate. */ +int us_ssl_set_key_cert(struct ssl_st *ssl, struct ssl_ctx_st *ctx, const unsigned char *sid_ctx, size_t sid_ctx_length); /* ── Connect ────────────────────────────────────────────────────────────── * Returns either us_socket_t* (fast path, *is_connecting=1) or @@ -582,6 +590,10 @@ int us_ssl_take_error_reason(char *reason, size_t length); void us_ssl_set_wrapper(struct ssl_st *ssl, void *wrapper); /* `ctx` is the X509_STORE_CTX of a verify callback. */ void *us_ssl_wrapper_from_verify(void *ctx); +/* Switches `ssl` to the context that its server name selected. */ +void us_ssl_apply_selected_ctx(struct ssl_st *ssl, struct ssl_ctx_st *ctx); +/* Makes the select-certificate step of `ssl` ask its wrapper (us_ssl_wrapper_select_cert). */ +void us_ssl_wrapper_on_server_name(struct ssl_st *ssl); /* Owner data for us_dispatch_new_session. `on_free(sink)` runs once, on SSL_free. */ void us_socket_set_session_sink(us_socket_r s, void *sink, void (*on_free)(void *)); void *us_socket_session_sink(us_socket_r s); @@ -729,6 +741,8 @@ void *us_listen_socket_ext(struct us_listen_socket_t *ls) nonnull_fn_decl; LIBUS_SOCKET_DESCRIPTOR us_listen_socket_get_fd(struct us_listen_socket_t *ls) nonnull_fn_decl; int us_listen_socket_port(struct us_listen_socket_t *ls) nonnull_fn_decl; struct us_socket_group_t *us_listen_socket_group(struct us_listen_socket_t *ls) nonnull_fn_decl; +/* Call before another process gets a duplicate of the listening socket. */ +void us_listen_socket_set_shared(struct us_listen_socket_t *ls) nonnull_fn_decl; /* Walk a group's live listeners. The list is the source of truth — anything * that caches us_listen_socket_t* across event-loop ticks (e.g. a std::vector * in C++) is a UAF once a listener is closed and freed in loop_post. */ diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index abfdd6af2a55..040f86810fbb 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -238,6 +238,14 @@ int us_loop_close_all_groups(struct us_loop_t *loop) { return any; } +/* A timeout stamp is a slot on the 240-tick wheel, or 255 when no timeout is armed. */ +static unsigned char us_internal_next_timeout_stamp(unsigned char stamp) { + return stamp == 255 ? 255 : (stamp + 1) % 240; +} + +/* Hold a parked socket's timeouts back for at most 120 s, Node's default TLS handshakeTimeout. */ +static const int MAX_LOW_PRIO_TICKS = 120 / LIBUS_TIMEOUT_GRANULARITY; + /* This functions should never run recursively */ void us_internal_timer_sweep(struct us_loop_t *loop) { struct us_internal_loop_data_t *loop_data = &loop->data; @@ -272,8 +280,12 @@ void us_internal_timer_sweep(struct us_loop_t *loop) { group->iterator = s; if (short_ticks == s->timeout) { - s->timeout = 255; - s->ssl ? us_internal_ssl_on_timeout(s) : us_dispatch_timeout(s); + if (s->timeout_laps) { + s->timeout_laps--; + } else { + s->timeout = 255; + s->ssl ? us_internal_ssl_on_timeout(s) : us_dispatch_timeout(s); + } } /* An owner must not deinit the embedding group from a timeout handler * (see us_socket_group_deinit). Survive one that closed every socket @@ -304,27 +316,33 @@ void us_internal_timer_sweep(struct us_loop_t *loop) { } /* Sockets parked in the low-priority queue are unlinked from head_sockets - * (the queue reuses prev/next), so the walk above never visits them. On an - * idle loop the queue drains only as fast as loop iterations occur, so a - * burst of pre-handshake TLS accepts can still be parked when the single - * tick their s->timeout stamp matches passes, after which the exact-match - * test above can never fire again. Sweep the parked set here against each - * socket's own group's freshly-advanced timestamps. low_prio_iterator lets - * close_raw/detach advance iteration past a socket they unlink, same as - * group->iterator does for head_sockets. */ + * (the queue reuses prev/next), so the walk above never visits them. The + * loop turned their reads off, so time in the queue doesn't count against + * the owner's timeouts: advance each stamp with the tick, for at most + * MAX_LOW_PRIO_TICKS. The library's own wait for a spilled close still + * fires. low_prio_iterator lets close_raw advance the walk past a socket + * it unlinks, like group->iterator does for head_sockets. */ for (loop_data->low_prio_iterator = loop_data->low_prio_head; loop_data->low_prio_iterator; ) { struct us_socket_t *s = loop_data->low_prio_iterator; - unsigned char stamp = s->group->timestamp; - unsigned char long_stamp = s->group->long_timestamp; - if (stamp == s->timeout) { - s->timeout = 255; - s->ssl ? us_internal_ssl_on_timeout(s) : us_dispatch_timeout(s); - if (loop_data->low_prio_iterator != s) continue; - } - if (long_stamp == s->long_timeout) { - s->long_timeout = 255; - us_dispatch_long_timeout(s); - if (loop_data->low_prio_iterator != s) continue; + if (!s->ssl_close_after_spill && s->low_prio_ticks < MAX_LOW_PRIO_TICKS) { + s->low_prio_ticks++; + s->timeout = us_internal_next_timeout_stamp(s->timeout); + if (s->group->global_tick % 15 == 0) { + s->long_timeout = us_internal_next_timeout_stamp(s->long_timeout); + } + } else { + if (s->group->timestamp == s->timeout && s->timeout_laps) { + s->timeout_laps--; + } else if (s->group->timestamp == s->timeout) { + s->timeout = 255; + us_internal_ssl_on_timeout(s); + if (loop_data->low_prio_iterator != s) continue; + } + if (s->group->long_timestamp == s->long_timeout) { + s->long_timeout = 255; + us_dispatch_long_timeout(s); + if (loop_data->low_prio_iterator != s) continue; + } } loop_data->low_prio_iterator = s->next; } @@ -332,9 +350,19 @@ void us_internal_timer_sweep(struct us_loop_t *loop) { /* We do not want to block the loop with tons and tons of CPU-intensive work for SSL handshakes. * Spread it out during many loop iterations, prioritizing already open connections, they are far - * easier on CPU */ + * easier on CPU. + * + * A socket over the budget is parked in the low-priority queue: unlinked from its group, reads + * off. Only a socket with unread input is parked, so a FIN doesn't close it (the EOF arm of + * us_internal_dispatch_ready_poll) and its owner's timeouts wait for up to MAX_LOW_PRIO_TICKS + * (us_internal_timer_sweep). */ static const int MAX_LOW_PRIO_SOCKETS_PER_LOOP_ITERATION = 5; +/* An accepted socket's handlers run inside the accept loop, so a client they answer can reconnect + * before the next accept(). Unbounded, the loop never ends under that load and starves timers and + * every other socket. The listener's poll is level-triggered and reports the rest on the next tick. */ +static const int MAX_ACCEPTS_PER_READINESS_EVENT = 32; + void us_internal_handle_low_priority_sockets(struct us_loop_t *loop) { struct us_internal_loop_data_t *loop_data = &loop->data; struct us_socket_t *s; @@ -537,16 +565,15 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in * rather than exact equality so the connect-complete is still * recognized; listen sockets only ever poll READABLE. */ if (us_poll_events(p) & LIBUS_SOCKET_WRITABLE) { - /* The connecting fd became writable with an error/HUP flag also - * set: the handshake may have completed and then been reset - * before we collected the event. Report the kernel's actual - * SO_ERROR (ECONNRESET for that race) instead of the literal - * boolean, which downstream would misreport as ECONNREFUSED. - * libuv does the same getsockopt in uv__stream_connect. */ + /* The first event carries an error or hangup flag, so SO_ERROR decides, + * like libuv's uv__stream_connect. If SO_ERROR is 0 and connect() had + * returned 0, the peer accepted and already closed: open the socket, and + * the next event reads what the peer sent. If connect() was in progress, + * a send() or another SO_ERROR read consumed the error: report ECONNRESET. */ int connect_error = 0; if (error || eof) { connect_error = us_socket_get_error((struct us_socket_t *) p); - if (connect_error == 0) { + if (connect_error == 0 && !((struct us_socket_t *) p)->connect_returned_zero) { connect_error = LIBUS_ECONNRESET; } } @@ -557,7 +584,7 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in struct us_loop_t *loop = accept_group->loop; struct bsd_addr_t addr; - LIBUS_SOCKET_DESCRIPTOR client_fd = bsd_accept_socket(us_poll_fd(p), &addr); + LIBUS_SOCKET_DESCRIPTOR client_fd = bsd_accept_socket(us_poll_fd(p), &addr, listen_socket->shared); if (client_fd == LIBUS_SOCKET_ERROR) { /* Todo: start timer here */ @@ -565,6 +592,7 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in /* Todo: stop timer if any */ + int accepted = 0; do { struct us_poll_t *accepted_p = us_create_poll(loop, 0, sizeof(struct us_socket_t) - sizeof(struct us_poll_t) + listen_socket->socket_ext_size); us_poll_init(accepted_p, client_fd, POLL_TYPE_SOCKET); @@ -596,15 +624,18 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in s->read_eof = 0; s->hangup_closes_unsent = 0; s->send_rejected = 0; + s->connect_returned_zero = 0; /* We always use nodelay */ bsd_socket_nodelay(client_fd, 1); us_internal_socket_group_link_socket(accept_group, s); + int handshake_budget_spent = 0; if (listen_socket->ssl_ctx) { us_internal_ssl_attach(s, listen_socket->ssl_ctx, /*is_client*/ 0, NULL, listen_socket); us_internal_ssl_on_open(s, 0, bsd_addr_get_ip(&addr), bsd_addr_get_ip_length(&addr)); + handshake_budget_spent = loop->data.low_prio_budget <= 0; } else { us_dispatch_open(s, 0, bsd_addr_get_ip(&addr), bsd_addr_get_ip_length(&addr)); } @@ -615,8 +646,11 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in * on Linux, SO_ACCEPTFILTER on FreeBSD), the request/ClientHello is already * in the buffer. Dispatch readable now instead of returning to epoll just to * learn what we already know. The POLL_TYPE_SOCKET handler tolerates - * EWOULDBLOCK for the rare case where the defer timed out with no data. */ - if (listen_socket->deferred_accept && s && !us_socket_is_closed(s)) { + * EWOULDBLOCK for the rare case where the defer timed out with no data. + * Once the handshake budget is spent that dispatch parks the socket, and only + * a socket with input may be parked, so check for input first. */ + if (listen_socket->deferred_accept && s && !us_socket_is_closed(s) && + (!handshake_budget_spent || us_socket_queued_input(s) == LIBUS_QUEUED_INPUT_DATA)) { us_internal_dispatch_ready_poll((struct us_poll_t *) s, 0, 0, LIBUS_SOCKET_READABLE); } @@ -625,7 +659,7 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in break; } - } while ((client_fd = bsd_accept_socket(us_poll_fd(p), &addr)) != LIBUS_SOCKET_ERROR); + } while (++accepted < MAX_ACCEPTS_PER_READINESS_EVENT && (client_fd = bsd_accept_socket(us_poll_fd(p), &addr, listen_socket->shared)) != LIBUS_SOCKET_ERROR); } } break; @@ -671,6 +705,11 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in us_poll_change(&s->p, loop, us_poll_events(&s->p) | LIBUS_SOCKET_WRITABLE); #endif } + + /* `events` was masked before the handler ran, and the handler may have paused the socket. */ + if (!(us_poll_events(&s->p) & LIBUS_SOCKET_READABLE)) { + events &= ~LIBUS_SOCKET_READABLE; + } } /* An error event (EPOLLERR, EV_EOF with the socket error in fflags, an AFD @@ -730,6 +769,7 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in loop->data.low_prio_head = s; flags->low_prio_state = 1; + s->low_prio_ticks = 0; break; } @@ -749,7 +789,9 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in if(s->flags.is_ipc) { struct msghdr msg = {0}; struct iovec iov = {0}; - char cmsg_buf[CMSG_SPACE(sizeof(int))]; + /* The protocol sends one descriptor per message. macOS installs every descriptor a peer + * sends before it truncates the control message, so the ones that don't fit here leak. */ + char cmsg_buf[CMSG_SPACE(64 * sizeof(int))]; iov.iov_base = loop->data.recv_buf + LIBUS_RECV_BUFFER_PADDING; iov.iov_len = LIBUS_RECV_BUFFER_LENGTH; @@ -774,12 +816,18 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in // protocol; close anything else a peer packed into the buffer. if (length > 0 && msg.msg_controllen > 0) { int fd = -1; + unsigned char *control_end = (unsigned char *) msg.msg_control + msg.msg_controllen; for (struct cmsghdr *cmsg_ptr = CMSG_FIRSTHDR(&msg); cmsg_ptr; cmsg_ptr = CMSG_NXTHDR(&msg, cmsg_ptr)) { if (cmsg_ptr->cmsg_level != SOL_SOCKET || cmsg_ptr->cmsg_type != SCM_RIGHTS || cmsg_ptr->cmsg_len < CMSG_LEN(0)) { continue; } unsigned char *fds = CMSG_DATA(cmsg_ptr); size_t nfds = (cmsg_ptr->cmsg_len - CMSG_LEN(0)) / sizeof(int); + /* macOS keeps the untruncated cmsg_len in a truncated control message. */ + size_t received_fds = fds < control_end ? (size_t) (control_end - fds) / sizeof(int) : 0; + if (nfds > received_fds) { + nfds = received_fds; + } for (size_t i = 0; i < nfds; i++) { int received; memcpy(&received, fds + i * sizeof(int), sizeof(int)); @@ -998,9 +1046,14 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in * stream's 'finish' never happened - the FIN-terminated * http response tests hung on every Linux target. The * writable dispatch disables writable polling again once - * the buffer is drained, so this does not busy-poll. */ - us_poll_change(&s->p, loop, LIBUS_SOCKET_WRITABLE); + * the buffer is drained, so this does not busy-poll. + * on_end runs first: an owner that closes there needs no poll + * change, and a shutdown there already armed what it needs. */ s = s->ssl ? us_internal_ssl_on_end(s) : us_dispatch_end(s); + s = us_internal_socket_follow_adopted(s); + if (s && !error && us_internal_socket_can_raw_write(s)) { + us_poll_change(&s->p, loop, LIBUS_SOCKET_WRITABLE); + } } else { /* Half-open not allowed, or a hangup (both directions down, level-triggered): * emit end unless a FIN already did, then close so EPOLLHUP stops re-firing. */ diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index 81e5489c8d11..5be73e1cac86 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -99,7 +99,10 @@ unsigned char us_connecting_socket_kind(struct us_connecting_socket_t *c) { __attribute__((always_inline)) void us_socket_timeout(struct us_socket_t *s, unsigned int seconds) { if (seconds) { - s->timeout = ((unsigned int)s->group->timestamp + ((seconds + 3) >> 2)) % 240; + unsigned int ticks = (unsigned int) (((uint64_t) seconds + 3) >> 2); + unsigned int laps = (ticks - 1) / 240; + s->timeout = ((unsigned int)s->group->timestamp + ticks) % 240; + s->timeout_laps = laps > UINT16_MAX ? UINT16_MAX : laps; } else { s->timeout = 255; } @@ -267,9 +270,9 @@ void us_connecting_socket_close(struct us_connecting_socket_t *c) { } if (c->addrinfo_req) { - /* Invalidate the cache entry for a refused connect (addresses may be + /* Invalidate the cache entry for a failed connect (addresses may be * stale) and for a resolver failure (never cache a negative result). */ - Bun__addrinfo_freeRequest(c->addrinfo_req, c->error == ECONNREFUSED || c->error_is_dns); + Bun__addrinfo_freeRequest(c->addrinfo_req, c->error != LIBUS_ECANCELED || c->error_is_dns); c->addrinfo_req = 0; } us_dispatch_connecting_error(c, c->error); @@ -338,6 +341,8 @@ static struct us_socket_t *us_internal_socket_close_and_notify(struct us_socket_ res = s->ssl ? us_internal_ssl_on_close(s, code, reason) : us_dispatch_close(s, code, reason); } else if (!s->connect_state) { + /* Before the handler, so a retry from inside it resolves the name again. */ + us_internal_socket_release_addrinfo(s, connect_error != LIBUS_ECANCELED); res = us_dispatch_connect_error(s, connect_error); } /* A candidate of a us_connecting_socket_t is held through that, which hears @@ -480,6 +485,7 @@ struct us_socket_t *us_socket_from_fd(struct us_socket_group_t *group, unsigned s->read_eof = 0; s->hangup_closes_unsent = 0; s->send_rejected = 0; + s->connect_returned_zero = 0; s->connect_state = NULL; /* We always use nodelay */ @@ -761,7 +767,15 @@ void us_internal_socket_raw_shutdown(struct us_socket_t *s) { * so far, the app has to track this and call close as needed */ if (us_internal_socket_can_raw_write(s)) { us_internal_poll_set_type(&s->p, POLL_TYPE_SOCKET_SHUT_DOWN); - us_poll_change(&s->p, s->group->loop, us_poll_events(&s->p) & LIBUS_SOCKET_READABLE); + int events = us_poll_events(&s->p) & LIBUS_SOCKET_READABLE; +#ifndef LIBUS_USE_EPOLL + /* After read_eof the poll may have no events armed. Arm READABLE so the loop sees the EOF again and closes the socket. + * epoll reports the hangup with nothing armed. */ + if (s->read_eof) { + events = LIBUS_SOCKET_READABLE; + } +#endif + us_poll_change(&s->p, s->group->loop, events); bsd_shutdown_socket(us_poll_fd((struct us_poll_t *) s)); } } diff --git a/packages/bun-uws/src/App.h b/packages/bun-uws/src/App.h index 545b762e4397..a3f97d326ea8 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -104,7 +104,7 @@ struct TemplatedApp { /* The app always owns at least one http context, but creates websocket contexts on demand */ HttpContext *httpContext; /* Shared SSL_CTX for every accepted socket. nullptr for plain HTTP. Built - * once in the constructor; the listener up_ref's it; SSL_CTX_free in dtor. */ + * or up_ref'd in the constructor; the listener up_ref's it; SSL_CTX_free in dtor. */ struct ssl_ctx_st *sslCtx = nullptr; /* SNI: the tree hangs off the listen socket, but addServerName() is allowed * before listen(). Queue them and replay onto each us_listen_socket_t. */ @@ -311,6 +311,13 @@ struct TemplatedApp { } httpContext = HttpContext::create(Loop::get(), options.request_cert, options.reject_unauthorized); } + + /* Serves an SSL_CTX the caller built, which already carries its verify mode. */ + TemplatedApp(struct ssl_ctx_st *ctx, bool rejectUnauthorized) { + us_internal_ssl_ctx_up_ref(ctx); + sslCtx = ctx; + httpContext = HttpContext::create(Loop::get(), rejectUnauthorized, rejectUnauthorized); + } public: static TemplatedApp* create(SocketContextOptions options = {}) { @@ -322,6 +329,10 @@ struct TemplatedApp { return app; } + static TemplatedApp* create(struct ssl_ctx_st *ctx, bool rejectUnauthorized) { + return new TemplatedApp(ctx, rejectUnauthorized); + } + bool constructorFailed() { return !httpContext; } diff --git a/patches/boringssl/ip-name-constraints.patch b/patches/boringssl/ip-name-constraints.patch new file mode 100644 index 000000000000..8890db96ec84 --- /dev/null +++ b/patches/boringssl/ip-name-constraints.patch @@ -0,0 +1,46 @@ +Match iPAddress name constraints (RFC 5280, section 4.2.1.10). + +nc_match_single has no GEN_IPADD case, so a leaf with an IP subjectAltName +under a CA that carries an iPAddress constraint fails with +X509_V_ERR_UNSUPPORTED_CONSTRAINT_TYPE even when the address is inside the +permitted subtree. OpenSSL's nc_ip matches the same way. + +--- a/crypto/x509/v3_ncons.cc ++++ b/crypto/x509/v3_ncons.cc +@@ -507,6 +507,26 @@ + return X509_V_OK; + } + ++int nc_ip(const ASN1_OCTET_STRING *ip, const ASN1_OCTET_STRING *base) { ++ // RFC 5280, section 4.2.1.10 encodes an IP constraint as address || mask. ++ if (ip->length != 4 && ip->length != 16) { ++ return X509_V_ERR_UNSUPPORTED_NAME_SYNTAX; ++ } ++ if (base->length != 8 && base->length != 32) { ++ return X509_V_ERR_UNSUPPORTED_CONSTRAINT_SYNTAX; ++ } ++ if (ip->length * 2 != base->length) { ++ return X509_V_ERR_PERMITTED_VIOLATION; ++ } ++ const uint8_t *mask = base->data + ip->length; ++ for (int i = 0; i < ip->length; i++) { ++ if (((ip->data[i] ^ base->data[i]) & mask[i]) != 0) { ++ return X509_V_ERR_PERMITTED_VIOLATION; ++ } ++ } ++ return X509_V_OK; ++} ++ + int nc_match_single(const GENERAL_NAME *gen, const GENERAL_NAME *base, + bool excluding, bool case_insensitive_local_part) { + switch (base->type) { +@@ -524,6 +544,9 @@ + return nc_uri(gen->d.uniformResourceIdentifier, + base->d.uniformResourceIdentifier); + ++ case GEN_IPADD: ++ return nc_ip(gen->d.iPAddress, base->d.iPAddress); ++ + default: + return X509_V_ERR_UNSUPPORTED_CONSTRAINT_TYPE; + } diff --git a/scripts/build/deps/boringssl.ts b/scripts/build/deps/boringssl.ts index 9db3b212578d..63b127e3aa6e 100644 --- a/scripts/build/deps/boringssl.ts +++ b/scripts/build/deps/boringssl.ts @@ -36,6 +36,8 @@ export const boringssl: Dependency = { commit: BORINGSSL_COMMIT, }), + patches: ["patches/boringssl/ip-name-constraints.patch"], + build: cfg => { // win-x64 uses NASM-syntax .asm; everything else (including win-aarch64) // uses gas .S that clang assembles. diff --git a/src/boringssl_sys/boringssl.rs b/src/boringssl_sys/boringssl.rs index 6b3b1a4dac25..dab0ce01e021 100644 --- a/src/boringssl_sys/boringssl.rs +++ b/src/boringssl_sys/boringssl.rs @@ -864,6 +864,7 @@ pub const SSL_ERROR_WANT_READ: c_int = 2; pub const SSL_ERROR_WANT_WRITE: c_int = 3; pub const SSL_ERROR_SYSCALL: c_int = 5; pub const SSL_ERROR_ZERO_RETURN: c_int = 6; +pub const SSL_ERROR_PENDING_CERTIFICATE: c_int = 12; pub const SSL_ERROR_WANT_RENEGOTIATE: c_int = 19; pub const SSL_VERIFY_NONE: c_int = 0x00; @@ -963,6 +964,13 @@ unsafe extern "C" { sid_ctx: *const u8, sid_ctx_len: usize, ) -> c_int; + pub fn SSL_CTX_get_tlsext_ticket_keys(ctx: *mut SSL_CTX, out: *mut c_void, len: usize) + -> c_int; + pub fn SSL_CTX_set_tlsext_ticket_keys( + ctx: *mut SSL_CTX, + r#in: *const c_void, + len: usize, + ) -> c_int; pub fn SSL_CTX_set_ex_data(ctx: *mut SSL_CTX, idx: c_int, data: *mut c_void) -> c_int; pub fn SSL_CTX_set0_buffer_pool(ctx: *mut SSL_CTX, pool: *mut CRYPTO_BUFFER_POOL); pub fn SSL_CTX_set1_groups_list(ctx: *mut SSL_CTX, groups: *const c_char) -> c_int; @@ -996,13 +1004,14 @@ unsafe extern "C" { pub fn SSL_set_renegotiate_mode(ssl: *mut SSL, mode: ssl_renegotiate_mode_t); pub fn SSL_renegotiate(ssl: *mut SSL) -> c_int; pub fn SSL_get_servername(ssl: *const SSL, ty: c_int) -> *const c_char; - pub fn SSL_CTX_set_default_verify_paths(ctx: *mut SSL_CTX) -> c_int; pub fn SSL_CTX_set_alpn_protos( ctx: *mut SSL_CTX, protos: *const u8, protos_len: usize, ) -> c_int; pub fn SSL_CTX_get_cert_store(ctx: *const SSL_CTX) -> *mut X509_STORE; + /// Frees the context's current store and takes ownership of `store`. + pub fn SSL_CTX_set_cert_store(ctx: *mut SSL_CTX, store: *mut X509_STORE); pub fn SSL_CTX_add0_chain_cert(ctx: *mut SSL_CTX, x509: *mut X509) -> c_int; pub fn SSL_CTX_clear_chain_certs(ctx: *mut SSL_CTX) -> c_int; pub fn PEM_read_bio_X509_AUX( diff --git a/src/bun_core/Global.rs b/src/bun_core/Global.rs index 7954b6b8da95..1762032dca26 100644 --- a/src/bun_core/Global.rs +++ b/src/bun_core/Global.rs @@ -862,6 +862,10 @@ unsafe impl Sync for SyncCStr {} #[unsafe(no_mangle)] static Bun__userAgent: SyncCStr = SyncCStr(concatcp!(user_agent, "\0").as_ptr().cast::()); +/// True if the default certificate store leaves out the bundled and the system's roots (`--use-openssl-ca`). +#[unsafe(no_mangle)] +pub static Bun__Node__UseOpenSSLCA: AtomicBool = AtomicBool::new(false); + /// True if TLS should also trust the system's certificate authorities. `root_certs.cpp` reads this /// when it builds the default certificate store. #[unsafe(no_mangle)] @@ -879,7 +883,15 @@ pub fn set_ca_store_from_flags(has_flag: impl Fn(&'static [u8]) -> bool) { ); exit(1); } - let from_env = count == 0 && crate::env_var::NODE_USE_SYSTEM_CA.get().unwrap_or(false); + let system_ca_from_env = crate::env_var::NODE_USE_SYSTEM_CA.get().unwrap_or(false); + if passed[1] && system_ca_from_env { + crate::pretty_errorln!( + "error: either --use-openssl-ca or --use-system-ca can be used, not both" + ); + exit(1); + } + let from_env = count == 0 && system_ca_from_env; + Bun__Node__UseOpenSSLCA.store(passed[1], Ordering::Relaxed); Bun__Node__UseSystemCA.store(passed[0] || from_env, Ordering::Relaxed); } diff --git a/src/dns/lib.rs b/src/dns/lib.rs index 6e6e87fd99e9..a7753bee154d 100644 --- a/src/dns/lib.rs +++ b/src/dns/lib.rs @@ -1,6 +1,7 @@ #![warn(unused_must_use)] use core::ffi::c_int; +use core::sync::atomic::{AtomicU8, Ordering}; use std::io::Write as _; use bun_core::String as BunString; @@ -460,8 +461,23 @@ impl Order { bun_core::Global::exit(1) }) } + + /// The address family `fetch()` and `Bun.connect()` dial first. Process-wide, like the DNS cache they share. + pub fn for_connect() -> Order { + match CONNECT_ORDER.load(Ordering::SeqCst) { + 4 => Order::Ipv4first, + 6 => Order::Ipv6first, + _ => Order::Verbatim, + } + } + + pub fn set_for_connect(order: Order) { + CONNECT_ORDER.store(order as u8, Ordering::SeqCst); + } } +static CONNECT_ORDER: AtomicU8 = AtomicU8::new(Order::Verbatim as u8); + /// A numeric address, or a host name within the RFC 1035 limits made of the bytes /// c-ares allows in one (`ares_is_hostnamech`) or of non-ASCII bytes (UTF-8 mDNS names). pub fn is_valid_hostname(name: &[u8]) -> bool { diff --git a/src/http/ProxyTunnel.rs b/src/http/ProxyTunnel.rs index b6ea15bf7be0..b80d6c7cb490 100644 --- a/src/http/ProxyTunnel.rs +++ b/src/http/ProxyTunnel.rs @@ -619,6 +619,7 @@ impl ProxyTunnel { on_session: None, on_keylog: None, server_identity: Some(server_identity), + on_server_name: None, ctx: this.as_erased_ptr().as_ptr(), }, ) { diff --git a/src/http/ssl_config.rs b/src/http/ssl_config.rs index 0a85d66ecddc..c92a73840bee 100644 --- a/src/http/ssl_config.rs +++ b/src/http/ssl_config.rs @@ -128,6 +128,12 @@ impl SSLConfig { Self::default() } + /// What `tls.setDefaultCACertificates` was given, for a client config without `ca`. It then needs a context of its own. + pub fn set_default_ca(&mut self, certs: &[Box<[u8]>]) { + self.ca = Some(certs.iter().map(|cert| bun_core::dupe_z(cert)).collect()); + self.requires_custom_request_ctx = true; + } + /// What was assigned to `tls.DEFAULT_CIPHERS`, for a config without `ciphers`. It then needs a context of its own, like one with `ciphers`. pub fn set_default_ciphers(&mut self, ciphers: &[u8]) { self.ssl_ciphers = bun_core::dupe_z(ciphers); @@ -361,6 +367,8 @@ impl SSLConfig { } macro_rules! hash_slice { ($f:ident) => { + // An empty list trusts nothing; no list means the default store. + hasher.update(&[u8::from(self.$f.is_some())]); if let Some(slice) = &self.$f { for s in slice.iter() { hasher.update(cstr_bytes(*s)); diff --git a/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs b/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs index 165be2bf17e8..bde4b7496715 100644 --- a/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs +++ b/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs @@ -233,6 +233,7 @@ impl WebSocketProxyTunnel { on_session: None, on_keylog: None, server_identity: Some(Self::server_identity), + on_server_name: None, }, ) .map_err(|_| crate::Error::InvalidOptions)?; diff --git a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs index 3d0321bc9481..1b5d94f3d872 100644 --- a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs +++ b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs @@ -1180,6 +1180,9 @@ where if let Some(ciphers) = vm.tls_default_ciphers() { c.set_default_ciphers(ciphers); } + if let Some(certs) = vm.tls_default_ca() { + c.set_default_ca(certs); + } c } }; diff --git a/src/js/internal-for-testing.ts b/src/js/internal-for-testing.ts index e3410d551e61..817cc414e866 100644 --- a/src/js/internal-for-testing.ts +++ b/src/js/internal-for-testing.ts @@ -764,6 +764,13 @@ export const socketFaultInjection = { clear: $newRustFunction("runtime/socket/socket.rs", "TestingAPIs.jsClearSocketFaults", 0) as () => void, }; +/** Runs the uSockets timeout sweep of this thread's loop `count` times. The loop runs one every 4 seconds. */ +export const sweepSocketTimers: (count: number) => void = $newRustFunction( + "runtime/socket/socket.rs", + "TestingAPIs.jsSweepSocketTimers", + 1, +); + export const namedPipeInternals = { /** * Live native contexts behind sockets over Windows named pipes: one per diff --git a/src/js/internal/http.ts b/src/js/internal/http.ts index 678010214feb..1ab050a6cc2a 100644 --- a/src/js/internal/http.ts +++ b/src/js/internal/http.ts @@ -11,7 +11,7 @@ const { setServerCustomOptions, setServerAppFlags, setServerMaxHeadersCount, dra lenientHttpFlags: number, maxHeaderSize: number, onClientError: (ssl: boolean, socket: any, errorCode: number, rawPacket: ArrayBuffer) => undefined, - onConnection?: (socketHandle: any) => undefined, + onConnection?: (socketHandle: any, secured: boolean) => undefined, ) => void; setServerAppFlags: ( server: any, diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index 476bb70ce9d2..61777d80baab 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -705,6 +705,8 @@ function connectionListenerHTTP1(server, socket, options) { parser.close(); } catch {} }); + // Node's parser reads from the handle, so a pause() by the socket's previous owner doesn't stop it. + socket.resume(); } function closeIdleHttp1Connections(server) { diff --git a/src/js/internal/net/symbols.ts b/src/js/internal/net/symbols.ts index 0f05738b1e81..0e9472c69e55 100644 --- a/src/js/internal/net/symbols.ts +++ b/src/js/internal/net/symbols.ts @@ -1,12 +1,22 @@ export default { + // The tls.Server that accepted this TLSSocket. Cleared by that server's 'connection' listener. + kAcceptedTLS: Symbol("kAcceptedTLS"), kArmHandshakeTimeout: Symbol("kArmHandshakeTimeout"), // Set while a socket must receive nothing (an idle socket in an http.Agent // pool): the read path destroys the socket instead of delivering the bytes. kDestroyOnRead: Symbol("kDestroyOnRead"), kPreHandshakeWrite: Symbol("kPreHandshakeWrite"), + // net.Socket.prototype method for child_process: Node's handle.readStop(). + kReadStop: Symbol("kReadStop"), // Internal handshake-settled signal: server-side sockets emit no user // 'secureConnect' (node parity), so internal deferrals park on this instead. kSecureConnectDone: Symbol("kSecureConnectDone"), + // net.Server.prototype method: runs the server's SNICallback for one handshake. https.Server calls it for its own connections. + kSelectSNIContext: Symbol("kSelectSNIContext"), + // The error of an SNICallback, on the socket whose handshake it aborted, for 'tlsClientError'. + kSNIError: Symbol("kSNIError"), + // tls.Server's addContext() map of hostname to SecureContext. https.Server adds the entries to its own listener. + kSNIContexts: Symbol("kSNIContexts"), kVerifyError: Symbol("kVerifyError"), // net.Socket.prototype method behind the client-side `new tls.TLSSocket(socket)`. kUpgradeClientTLS: Symbol("kUpgradeClientTLS"), diff --git a/src/js/internal/tls.ts b/src/js/internal/tls.ts index a16c1b31c495..c42d09717dca 100644 --- a/src/js/internal/tls.ts +++ b/src/js/internal/tls.ts @@ -291,8 +291,6 @@ function processPfxOptions(options) { const certs = out.cert == null ? [] : Array.isArray(out.cert) ? [...out.cert] : [out.cert]; const pfxCAs: string[] = []; const entries = Array.isArray(out.pfx) ? out.pfx : [out.pfx]; - // CAs added after the context is built complete the chain of a lone identity only; several carry theirs, as in Node. - const several = entries.length + Math.max(keys.length, certs.length) > 1; for (const entry of entries) { let buf = entry; let passphrase = out.passphrase; @@ -306,7 +304,8 @@ function processPfxOptions(options) { const parsed = NativeSecureContext.parsePkcs12(buf, passphrase); const parsedCA = parsed.ca; keys.push(parsed.key); - certs.push(several && parsedCA ? parsed.cert + parsedCA : parsed.cert); + // The bundle's other certificates are the chain: https://github.com/nodejs/node/blob/v26.5.0/src/crypto/crypto_context.cc#L2324 + certs.push(parsedCA ? parsed.cert + parsedCA : parsed.cert); // A CA bundled inside the PKCS#12 EXTENDS the trust set (Node loads it // via addCACert on top of the default roots); folding it into the `ca` // option would instead REPLACE the trust store and break verification @@ -470,8 +469,22 @@ function tlsHandshakeError(verifyError) { return new (require("internal/shared").ConnResetException)("socket hang up"); } +// Returns true if `stream` has every method the TLS engine calls on a wrapped stream. +function hasStreamMethods(stream) { + return ( + typeof stream.pause === "function" && + typeof stream.resume === "function" && + typeof stream.on === "function" && + typeof stream.once === "function" && + typeof stream.write === "function" && + typeof stream.end === "function" && + typeof stream.destroy === "function" + ); +} + export { SSL_OP_CIPHER_SERVER_PREFERENCE, + hasStreamMethods, nodeClientTlsKeys, nodeClientTlsToNative, normalizePemKeyOption, diff --git a/src/js/node/_http_outgoing.ts b/src/js/node/_http_outgoing.ts index 6e5276f4afd1..712ba22c854e 100644 --- a/src/js/node/_http_outgoing.ts +++ b/src/js/node/_http_outgoing.ts @@ -116,6 +116,7 @@ const kChunkedLength = Symbol("kChunkedLength"); const kUniqueHeaders = Symbol("kUniqueHeaders"); const kBytesWritten = Symbol("kBytesWritten"); const kErrored = Symbol("errored"); +const kEndCallbacks = Symbol("kEndCallbacks"); const kHighWaterMark = Symbol("kHighWaterMark"); const kRejectNonStandardBodyWrites = Symbol("kRejectNonStandardBodyWrites"); const kLenientValidation = Symbol("kLenientValidation"); @@ -1058,9 +1059,19 @@ OutgoingMessage.prototype.addTrailers = function addTrailers(headers) { } }; -function onFinish(outmsg) { - if (outmsg?.socket?._hadError) return; - outmsg.emit("finish"); +function queueEndCallback(msg, callback) { + msg.once("finish", callback); + (msg[kEndCallbacks] ??= []).push(callback); +} + +// When the last write fails there's no 'finish', and end() callbacks get the error like a Writable's. +function onFinish(outmsg, err?) { + if (!err && !outmsg?.socket?._hadError) return void outmsg.emit("finish"); + const callbacks = outmsg[kEndCallbacks]; + if (!callbacks) return; + outmsg[kEndCallbacks] = undefined; + err ??= outmsg[kErrored] ?? outmsg[kSocket]?.errored ?? $ERR_STREAM_DESTROYED("end"); + for (let i = 0; i < callbacks.length; i++) callbacks[i](err); } OutgoingMessage.prototype.end = function end(chunk, encoding, callback) { @@ -1087,7 +1098,7 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) { } else if (this.finished) { if (typeof callback === "function") { if (!this.writableFinished) { - this.on("finish", callback); + queueEndCallback(this, callback); } else { callback($ERR_STREAM_ALREADY_FINISHED("end")); } @@ -1102,7 +1113,7 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) { this._implicitHeader(); } - if (typeof callback === "function") this.once("finish", callback); + if (typeof callback === "function") queueEndCallback(this, callback); let contentLength; if (strictContentLength(this) && this[kBytesWritten] !== (contentLength = this._contentLength)) { diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 958ba989a17d..a727e143bc0e 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -17,6 +17,7 @@ const { validateInteger, validateFunction, validateOneOf, + validatePort, } = require("internal/validators"); const { ConnResetException, @@ -31,6 +32,7 @@ const kServerResponseStatistics = Symbol("ServerResponseStatistics"); const { isPrimary } = require("internal/cluster/isPrimary"); const { addServerAbortSignalOption } = require("internal/net/server_abort_signal"); +const { kSelectSNIContext, kSNIContexts, kSNIError } = require("internal/net/symbols"); const { kInternalSocketData, serverSymbol, @@ -89,6 +91,8 @@ const kPendingCloseGenerations = Symbol("http.server.pendingCloseGenerations"); const kListenerGeneration = Symbol("http.server.listenerGeneration"); // Set on the 'connect'/'upgrade' handoff; closeAll/closeIdleConnections() skip these, as in Node. const kHandedOff = Symbol("http.server.socketHandedOff"); +// True on a TLS connection from accept until 'secureConnection', when Node's connectionListener takes it. +const kHandshaking = Symbol("http.server.socketHandshaking"); function closeHandle(handle) { if (!handle.closed) handle.close(); } @@ -111,6 +115,8 @@ function traceServerRequestEnd() { const getBunServerAllClosedPromise = $newRustFunction("node_http_binding.rs", "getBunServerAllClosedPromise", 1); const getBunServerOpenCount = $bindgen("node_http_binding.bind.ts", "getBunServerOpenCount"); +const addServerName = $bindgen("node_http_binding.bind.ts", "addServerName"); +const dispatchServerNames = $bindgen("node_http_binding.bind.ts", "dispatchServerNames"); const kServerResponse = Symbol("ServerResponse"); const kChunkedEncoding = Symbol("kChunkedEncoding"); @@ -285,14 +291,31 @@ function normalizeServerTls(tls) { function connectionListener(this: Server, socket) { // A tunnel socket that was handed to 'connect'/'upgrade' has left the native listener: re-emitted, it is a connection like any other. if (NodeHTTPServerSocket && socket instanceof NodeHTTPServerSocket && !socket[kHandedOff]) return; - (http1Fallback ??= require("internal/http1_server_fallback")).connectionListenerHTTP1(this, socket, { + const tlsServer = this[tlsSymbol]; + // Its 'connection' listener wraps the socket in a TLSSocket, which comes back as 'secureConnection'. + if (tlsServer) tlsServer.emit("connection", socket); + else connectionListenerHTTP1(this, socket); +} + +function secureConnectionListener(this: Server, socket) { + if (NodeHTTPServerSocket && socket instanceof NodeHTTPServerSocket) return; + connectionListenerHTTP1(this, socket); +} + +function connectionListenerHTTP1(server: Server, socket) { + (http1Fallback ??= require("internal/http1_server_fallback")).connectionListenerHTTP1(server, socket, { http1Options: { - IncomingMessage: this[kIncomingMessage], - ServerResponse: this[kServerResponse], + IncomingMessage: server[kIncomingMessage], + ServerResponse: server[kServerResponse], }, }); } +// https://github.com/nodejs/node/blob/v26.3.0/lib/https.js#L103-L106 +function onTLSClientError(this: Server, err, socket) { + if (!this.emit("clientError", err, socket)) socket.destroy(err); +} + type NodeHTTPServer = import("node:http").Server; interface Server extends NodeHTTPServer { maxHeaderSize: number | undefined; @@ -300,6 +323,7 @@ interface Server extends NodeHTTPServer { httpValidation?: "strict" | "relaxed" | "insecure"; requireHostHeader: boolean; httpAllowHalfOpen: boolean; + allowHalfOpen: boolean; blockList?: import("node:net").BlockList; } function Server(options, callback): void { @@ -324,6 +348,7 @@ function Server(options, callback): void { this[kListenerGeneration] = undefined; this[tlsSymbol] = null; this.noDelay = true; + let isTLS = false; if (typeof options === "function") { callback = options; options = {}; @@ -332,113 +357,22 @@ function Server(options, callback): void { } else { validateObject(options, "options"); options = { ...options }; - const tlsHelpers = - this[isTlsSymbol] || options.pfx || options.cert || options.key || options.ca - ? require("internal/tls") - : undefined; - - // Node's https.Server accepts PKCS#12 bundles (pfx [+ passphrase]); fold - // them into plain key/cert/ca so the native TLS config sees PEM material. - let tlsOptions = options; - if (options.pfx) { - tlsOptions = tlsHelpers.processPfxOptions(options); - } - - let cert = tlsOptions.cert; - if (cert) { - tlsHelpers.throwOnInvalidTLSArray("options.cert", cert); - } - - let key = tlsOptions.key; - if (key) { - tlsHelpers.throwOnInvalidTLSArray("options.key", key); - } - - let ca = tlsOptions.ca; - // PKCS#12-embedded CAs extend the trust set; the server path hands raw - // {key, cert, ca} to the native config and has no addCACert hook, so fold - // them into `ca` (mirrors tls.Server.setSecureContext). - const pfxExtraCAs = tlsOptions._pfxExtraCACerts; - if (pfxExtraCAs?.length) { - ca = ca == null ? pfxExtraCAs : $isArray(ca) ? [...ca, ...pfxExtraCAs] : [ca, ...pfxExtraCAs]; - } - if (ca) { - tlsHelpers.throwOnInvalidTLSArray("options.ca", ca); - } - - let passphrase = options.passphrase || undefined; - if (passphrase && typeof passphrase !== "string") { - throw $ERR_INVALID_ARG_TYPE("options.passphrase", "string", passphrase); - } - - let serverName = options.servername; - if (serverName && typeof serverName !== "string") { - throw $ERR_INVALID_ARG_TYPE("options.servername", "string", serverName); - } - - let secureOptions = options.secureOptions || 0; - if (secureOptions && typeof secureOptions !== "number") { - throw $ERR_INVALID_ARG_TYPE("options.secureOptions", "number", secureOptions); - } - - if (tlsHelpers) { - const { secureProtocolToVersionRange, tlsStringToProtocolVersion } = tlsHelpers; - // A falsy value counts as absent: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1423-L1518 - const secureProtocol = options.secureProtocol || undefined; - const minVersionOption = options.minVersion || undefined; - const maxVersionOption = options.maxVersion || undefined; - const sessionTimeout = options.sessionTimeout || undefined; - tlsHelpers.validateSecureContextOptions({ - ...options, - secureProtocol, - minVersion: minVersionOption, - maxVersion: maxVersionOption, - sessionTimeout, - passphrase, - ciphers: options.ciphers || undefined, - clientCertEngine: options.clientCertEngine || undefined, - ticketKeys: options.ticketKeys || undefined, - // BoringSSL has no DHE suites to apply it to. - dhparam: undefined, - }); - const crl = options.crl || undefined; - if (crl) { - tlsHelpers.throwOnInvalidTLSArray("options.crl", crl); - } - if (options.honorCipherOrder !== false) secureOptions |= tlsHelpers.SSL_OP_CIPHER_SERVER_PREFERENCE; - // secureProtocol, or else minVersion/maxVersion, as the integer range the native layer applies; 0 keeps its defaults. - let minVersion, maxVersion; - const ciphers = options.ciphers ? tlsHelpers.stripTls13CipherNames(options.ciphers) : undefined; - const range = secureProtocolToVersionRange(secureProtocol); - if (range) { - minVersion = range[0]; - maxVersion = range[1]; - } else { - minVersion = tlsStringToProtocolVersion(ciphers?.tls13Only ? "TLSv1.3" : minVersionOption); - maxVersion = tlsStringToProtocolVersion(maxVersionOption); - } - this[tlsSymbol] = normalizeServerTls({ - serverName, - key: tlsHelpers.normalizePemKeyOption(key, passphrase), - cert, - ca, - crl, - sessionTimeout: sessionTimeout ?? 0, - sigalgs: options.sigalgs, - ecdhCurve: options.ecdhCurve ?? require("node:tls").DEFAULT_ECDH_CURVE, - passphrase, - secureOptions, - minVersion, - maxVersion, - ciphers: ciphers?.cipherList || undefined, - requestCert: options.requestCert, - rejectUnauthorized: options.rejectUnauthorized, - }); - } + isTLS = !!(this[isTlsSymbol] || options.pfx || options.cert || options.key || options.ca); } - this[optionsSymbol] = options; storeHTTPOptions.$call(this, options); + // Node's http.Server always allows half-open sockets. https.Server takes the option, like tls.Server. + this.allowHalfOpen = this[isTlsSymbol] ? !!options.allowHalfOpen : true; + // This tls.Server never listens. It validates the TLS options and owns the SecureContext, like the one Node's https.Server extends. BoringSSL has no DHE suites, so dhparam is dropped. + if (isTLS) { + const tlsServer = (this[tlsSymbol] = new (require("node:tls").Server)({ ...options, dhparam: undefined })); + tlsServer.on("secureConnection", socket => this.emit("secureConnection", socket)); + tlsServer.on("tlsClientError", (err, socket) => this.emit("tlsClientError", err, socket)); + this.on("secureConnection", secureConnectionListener); + this.on("tlsClientError", onTLSClientError); + } + // listen() needs these, so a server whose TLS options threw can't listen in cleartext. + this[optionsSymbol] = options; if (callback) this.on("request", callback); return this; @@ -493,6 +427,7 @@ function checkConnections(this: Server) { } for (const socket of connections) { + if (socket[kHandshaking]) continue; const handle = socket[kHandle]; if (handle?.isRequestTimedOut?.(headersTimeout, requestTimeout)) { onRequestTimeout(socket); @@ -519,11 +454,11 @@ Server.prototype.unref = function () { Server.prototype.closeAllConnections = function () { http1Fallback?.closeAllHttp1Connections(this); - // Like Node, the listener and the sockets handed to 'connect' and 'upgrade' listeners stay. + // Like Node, the listener, the sockets handed to 'connect' and 'upgrade' listeners and the TLS connections still in their handshake stay. const tracked = this[kTrackedConnections]; if (tracked && tracked.size > 0) { for (const socket of $Array.from(tracked) as NodeHTTPServerSocket[]) { - if (!socket[kHandedOff]) socket.destroyNow(); + if (!socket[kHandedOff] && !socket[kHandshaking]) socket.destroyNow(); } } }; @@ -622,7 +557,14 @@ Server.prototype.listen = function () { const server = this; let port, host; let socketPath; - let tls = this[tlsSymbol]; + const tlsServer = this[tlsSymbol]; + // The context already has the verify mode. socket.authorized reads requestCert. + let tls = tlsServer && { + __proto__: null, + secureContext: tlsServer._sharedCreds.context, + requestCert: tlsServer._requestCert === true, + serverName: tlsServer.servername, + }; // This logic must align with: // - https://github.com/nodejs/node/blob/2eff28fb7a93d3f672f80b582f664a7c701569fb/lib/net.js#L274-L307 @@ -638,7 +580,7 @@ Server.prototype.listen = function () { const otherTLS = arg0.tls; if (otherTLS && $isObject(otherTLS)) { - tls = normalizeServerTls({ __proto__: null, ...otherTLS }); + tls = normalizeServerTls({ __proto__: null, ...otherTLS, secureContext: undefined }); } } else if (typeof arg0 === "string" && !(Number(arg0) >= 0)) { // (path[...][, cb]) @@ -658,11 +600,11 @@ Server.prototype.listen = function () { port = 0; } - if (typeof port === "string") { - const portNumber = parseInt(port); - if (!Number.isNaN(portNumber)) { - port = portNumber; - } + // A bad port throws and a valid one overrides `path`: https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L2332-L2334 + if (typeof port === "number" || typeof port === "string") { + validatePort(port, "options.port"); + port = +port | 0; + socketPath = undefined; } const lastArg = arguments[argc - 1]; @@ -711,6 +653,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort { // What the first connection and the first request need, so that neither waits for it. getNodeHTTPServerSocket(); + if (tls) inheritFromTLSSocket(); traceEvents ??= require("internal/trace_events"); const ResponseClass = this[optionsSymbol].ServerResponse || ServerResponse; @@ -749,7 +692,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort isPipelinedDispatch?: boolean, ) { if (!socket) { - socket = new (getNodeHTTPServerSocket())(server, socketHandle, !!tls, listenerGeneration); + socket = createNodeHTTPServerSocket(server, socketHandle, listenerGeneration); } if (isPipelinedDispatch) { @@ -1142,13 +1085,26 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort const handle = this[serverSymbol]; listenerGeneration = { + server: this, isUnix: !!socketPath, + tls, + onServerName: undefined, nativeClosed: false, drainedAtClose: false, }; this[kListenerGeneration] = listenerGeneration; getBunServerAllClosedPromise(handle).$then(emitCloseNTServer.bind(this, listenerGeneration)); applyServerCustomOptions(this); + const tlsServer = this[tlsSymbol]; + if (tlsServer) { + for (const { 0: hostname, 1: context } of tlsServer[kSNIContexts]) { + addServerName(handle, hostname, context.context); + } + if (tlsServer._SNICallback) { + listenerGeneration.onServerName = tlsServer[kSelectSNIContext].bind(tlsServer); + dispatchServerNames(handle); + } + } if (this?._unref) { this[serverSymbol]?.unref?.(); @@ -1240,16 +1196,25 @@ function defineMaxHeadersCount(server: Server) { }); } -// Native callback fired when the server accepts a connection (for TLS, when -// its handshake completes), before any request bytes - like Node.js's -// net.Server 'connection' / tls.Server 'secureConnection' events. -function onServerConnection(this: Server, listenerGeneration, socketHandle) { - if (socketHandle.duplex) { - // Already wrapped (shouldn't happen for a brand-new connection). +// Native callback fired at TCP accept, and for TLS once more, with `secured`, when the handshake succeeds. +function onServerConnection(this: Server, listenerGeneration, socketHandle, secured: boolean) { + if (secured) { + const socket = socketHandle.duplex; + socket[kHandshaking] = false; + socket._secureEstablished = true; + socket.on("timeout", onNodeHTTPServerSocketTimeout); + // Keep a shorter timeout that a 'connection' listener armed. It may have fired during the handshake, so restart it. + const serverTimeout = this.timeout; + if (serverTimeout && !(socket.timeout > 0 && socket.timeout < serverTimeout)) { + socket.setTimeout(serverTimeout); + } else { + socket._unrefTimer(); + } + socket.parser = createServerParserShim(socket); + this.emit("secureConnection", socket); return; } - const isTLS = !!this[tlsSymbol]; - const socket = new (getNodeHTTPServerSocket())(this, socketHandle, isTLS, listenerGeneration); + const socket = createNodeHTTPServerSocket(this, socketHandle, listenerGeneration); // Node's net.Server accept path refuses at maxConnections and emits 'drop'; the native // listener bypasses that, so gate it here. `>` (not Node's `>=`) because the constructor @@ -1290,13 +1255,16 @@ function onServerConnection(this: Server, listenerGeneration, socketHandle) { } } - // Node's connectionListener attaches the HTTPParser (socket.parser) before - // emitting 'connection'; expose the shim here so listeners see it populated. - socket.parser = createServerParserShim(socket); - this.emit("connection", socket); - if (isTLS && socketHandle.secureEstablished) { - this.emit("secureConnection", socket); + const onServerName = listenerGeneration.onServerName; + if (onServerName) { + socketHandle.onservername = onServerName; + // `this` of the SNICallback. + socketHandle.data = socket; } + + // Like Node, socket.parser is set from 'connection' on for plain TCP and from 'secureConnection' on for TLS. + if (!listenerGeneration.tls) socket.parser = createServerParserShim(socket); + this.emit("connection", socket); } // Like Node.js: server.setTimeout only records the per-socket inactivity @@ -1351,13 +1319,7 @@ enum HttpParserError { // socketOnError, exactly like Node's onParserExecuteCommon: the server's // 'clientError' listener (or the default handler) decides what to write back // and when to destroy the connection. -function onServerClientError( - listenerGeneration, - ssl: boolean, - socket: unknown, - errorCode: number, - rawPacket: ArrayBuffer, -) { +function onServerClientError(listenerGeneration, socket: unknown, errorCode: number, rawPacket: ArrayBuffer) { const self = this as Server; // A prior request on this keep-alive connection may already have wrapped // the native handle (the native side returns the existing handle); a second @@ -1365,7 +1327,7 @@ function onServerClientError( // kTrackedConnections. Reuse it, and only announce genuinely new // connections - the existing duplex already had its 'connection' event. const existingDuplex = (socket as any).duplex; - const nodeSocket = existingDuplex ?? new (getNodeHTTPServerSocket())(self, socket, ssl, listenerGeneration); + const nodeSocket = existingDuplex ?? createNodeHTTPServerSocket(self, socket, listenerGeneration); if (!existingDuplex) { nodeSocket.parser = createServerParserShim(nodeSocket); self.emit("connection", nodeSocket); @@ -1638,6 +1600,7 @@ function onSocketTimeoutTimerExpired(socket) { // contract (`req.socket instanceof net.Socket`); all I/O still goes through // the native NodeHTTP handle, not a net handle. let NodeHTTPServerSocket; +let NodeHTTPServerTLSSocket; type NodeHTTPServerSocket = InstanceType>; type NetSocketPrototypeAccessors = | "bufferSize" @@ -1675,9 +1638,9 @@ function getNodeHTTPServerSocket() { [kHandoffResponse] = undefined; [kDestroySoon] = false; [kHandedOff] = false; + [kHandshaking] = false; server: Server; _httpMessage; - _secureEstablished = false; // Node's connectionListener sets socket._paused when it stops reading a // connection whose pipelined responses have buffered past the high water mark. _paused = false; @@ -1686,21 +1649,21 @@ function getNodeHTTPServerSocket() { #closeHandled = false; #peer; #closingHandle; - #resetSupported; + #listenerGeneration; #closeError: Error | undefined = undefined; - declare encrypted: boolean; declare resetAndClosing: boolean; declare _writableState: { emitClose: boolean; decodeStrings: boolean }; declare _readableState: { emitClose: boolean }; declare writableBuffer: { chunk: Buffer }[]; - constructor(server: Server, handle, encrypted, listenerGeneration) { + constructor(server: Server, handle, listenerGeneration) { // allowHalfOpen: node's connectionListener sockets never auto-end the // writable side on the peer's FIN (CONNECT/Upgrade tunnels stay writable); // net.Socket would otherwise default it to false. + const allowHalfOpen = listenerGeneration.tls ? server.allowHalfOpen : true; super( server[kHighWaterMark] !== undefined - ? { highWaterMark: server[kHighWaterMark], allowHalfOpen: true } - : { allowHalfOpen: true }, + ? { highWaterMark: server[kHighWaterMark], allowHalfOpen } + : { allowHalfOpen }, ); // net.Socket's constructor wires net-handle machinery this class replaces: // its 'end' listener installs writeAfterFIN (breaks half-open tunnels), and @@ -1711,14 +1674,15 @@ function getNodeHTTPServerSocket() { this._readableState.emitClose = true; this._writableState.decodeStrings = true; this.server = server; - this.#resetSupported = !encrypted && !listenerGeneration?.isUnix; + this.#listenerGeneration = listenerGeneration; this[kHandle] = handle; - this._secureEstablished = !!handle?.secureEstablished; handle.onclose = this.#onClose.bind(this); handle.duplex = this; - this.encrypted = encrypted; - this.on("timeout", onNodeHTTPServerSocketTimeout); + // onServerConnection() arms the http timeouts of a TLS connection at 'secureConnection'. + const handshaking = !!listenerGeneration.tls && !handle.secureEstablished; + if (handshaking) this[kHandshaking] = true; + else this.on("timeout", onNodeHTTPServerSocketTimeout); // Like Node.js's socketOnError: connection errors are routed to the // server's 'clientError' event instead of crashing as unhandled 'error' // events on the socket. @@ -1727,7 +1691,7 @@ function getNodeHTTPServerSocket() { // Like Node.js's connectionListener: server.setTimeout's per-socket // inactivity timeout is armed when the connection is established. const serverTimeout = server.timeout; - if (serverTimeout) { + if (serverTimeout && !handshaking) { this.setTimeout(serverTimeout); } } @@ -1860,8 +1824,11 @@ function getNodeHTTPServerSocket() { this.#closeError = er.code === closeError.code ? er : closeError; } } - const server = this.server; - const tracked = server?.[kTrackedConnections]; + // this.server changes when the socket is passed to another server's emit('connection'). + const server = this.#listenerGeneration.server; + const sniError = this[kSNIError]; + if (sniError) server.emit("tlsClientError", sniError, this); + const tracked = server[kTrackedConnections]; if (tracked) { tracked.delete(this); if (tracked.size === 0 && server[kPendingDrainClose]) { @@ -2095,27 +2062,37 @@ function getNodeHTTPServerSocket() { } } - // SNI hostname the client sent in its ClientHello, or false when the TLS - // client sent none (matches Node's server-side TLSSocket.servername). - get servername() { - if (!this.encrypted) return undefined; - const name = (this[kHandle] ?? this.#peer)?.servername; - return typeof name === "string" && name.length > 0 ? name : false; + // inheritFromTLSSocket() removes this class's prototype from a TLS socket's chain. + static [Symbol.hasInstance](value) { + return $isObject(value) && #closeHandled in value; } - // Like Node's server-side TLSSocket: `authorized` is only ever true when the - // server requested a client certificate and its verification succeeded; - // `authorizationError` carries the X.509 verification error code otherwise. - get authorized() { - if (!this.encrypted) return undefined; - if (!this.server?.[tlsSymbol]?.requestCert) return false; - return (this[kHandle] ?? this.#peer)?.peerCertVerified === true; - } + // Node's plain net.Socket has none of these names, and callers tell the two apart with `"encrypted" in socket`. + static { + NodeHTTPServerTLSSocket = class TLSSocket extends this { + encrypted = true; + isServer = true; + alpnProtocol = false; + _secureEstablished = !!this[kHandle].secureEstablished; + + // The SNI hostname from the ClientHello, or false when the client sent none, like Node's server-side TLSSocket. + get servername() { + const name = (this[kHandle] ?? this.#peer)?.servername; + return typeof name === "string" && name.length > 0 ? name : false; + } + + // Like Node's server-side TLSSocket: true only when the server requested a client certificate and it verified. + get authorized() { + if (!this.#listenerGeneration.tls.requestCert) return false; + return (this[kHandle] ?? this.#peer)?.peerCertVerified === true; + } - get authorizationError() { - if (!this.encrypted) return undefined; - if (!this.server?.[tlsSymbol]?.requestCert) return null; - return (this[kHandle] ?? this.#peer)?.authorizationError ?? null; + // The X.509 verification error code of a requested client certificate that did not verify. + get authorizationError() { + if (!this.#listenerGeneration.tls.requestCert) return null; + return (this[kHandle] ?? this.#peer)?.authorizationError ?? null; + } + }; } ref() { @@ -2151,7 +2128,7 @@ function getNodeHTTPServerSocket() { resetAndDestroy() { if (this[kHandle]) { - if (!this.#resetSupported) { + if (this.#listenerGeneration.tls || this.#listenerGeneration.isUnix) { throw $ERR_INVALID_HANDLE_TYPE(); } this.resetAndClosing = true; @@ -2272,6 +2249,44 @@ function getNodeHTTPServerSocket() { return NodeHTTPServerSocket; } +let inheritsFromTLSSocket = false; +// Makes `req.socket instanceof tls.TLSSocket` true: NodeHTTPServerSocket's members, then TLSSocket's, then net.Socket's. +function inheritFromTLSSocket() { + if (inheritsFromTLSSocket) return; + inheritsFromTLSSocket = true; + const TLSSocketPrototype = require("node:tls").TLSSocket.prototype; + const prototype = NodeHTTPServerTLSSocket.prototype; + Object.setPrototypeOf( + prototype, + Object.create(TLSSocketPrototype, Object.getOwnPropertyDescriptors(NodeHTTPServerSocket.prototype)), + ); + // TLSSocket's methods read this._handle, which stays null here because net.Socket would drive the connection through it. + for (const name of [ + "getPeerCertificate", + "getPeerX509Certificate", + "getCertificate", + "getX509Certificate", + "getCipher", + "getProtocol", + "getEphemeralKeyInfo", + "getFinished", + "getPeerFinished", + "isSessionReused", + "exportKeyingMaterial", + ]) { + const method = TLSSocketPrototype[name]; + prototype[name] = function () { + const { [kHandle]: _handle = null, _secureEstablished } = this; + return method.$apply({ __proto__: TLSSocketPrototype, _handle, _secureEstablished }, arguments); + }; + } +} + +function createNodeHTTPServerSocket(server: Server, handle, listenerGeneration) { + const PlainSocket = getNodeHTTPServerSocket(); + return new (listenerGeneration.tls ? NodeHTTPServerTLSSocket : PlainSocket)(server, handle, listenerGeneration); +} + // Node validates the `Trailer` header inside _storeHeader, after the body framing has // been decided, so `this.chunkedEncoding` is already set. Bun frames the body in uWS // and never sets `response.chunkedEncoding`, so reproduce Node's decision here. diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index 23ef6489fb5e..f39362264871 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -1502,6 +1502,8 @@ class ChildProcess extends EventEmitter { }); this.pid = this.#handle.pid; + if ($isJSArray(stdio)) stopReadingSharedStdio(stdio); + $debug("ChildProcess: spawn", this.pid, spawnargs); process.nextTick(() => { @@ -1784,6 +1786,19 @@ function streamFdOf(item): number | undefined { return undefined; } +// The child reads these sockets now, so the parent stops: https://github.com/nodejs/node/blob/v26.5.0/lib/internal/child_process.js#L477-L488 +function stopReadingSharedStdio(stdio) { + for (let i = 0; i < stdio.length; i++) { + const stream = stdio[i]; + if (typeof stream !== "object" || stream === null) continue; + const readStop = stream[require("internal/net/symbols").kReadStop]; + if (typeof readStop !== "function") continue; + readStop.$call(stream); + stream.pause(); + stream._readableState.reading = false; + } +} + function nodeToBun(item: string, index: number): Bun.Spawn.NodeStdio[number] { // If not defined, use the default. // For stdin/stdout/stderr, it's pipe. For others, it's ignore. diff --git a/src/js/node/dns.ts b/src/js/node/dns.ts index 66d6dd84aae9..25b88afc6ced 100644 --- a/src/js/node/dns.ts +++ b/src/js/node/dns.ts @@ -78,6 +78,7 @@ function setServers(servers) { } const getRuntimeDefaultResultOrderOption = $bindgen("dns_internals.bind.ts", "getRuntimeDefaultResultOrderOption"); +const setConnectResultOrder = $bindgen("dns_internals.bind.ts", "setConnectResultOrder"); function newResolver(options) { if (!newResolver.native) { @@ -103,6 +104,8 @@ declare namespace defaultResultOrder { function setDefaultResultOrder(order) { validateOrder(order); defaultResultOrder.value = order; + // fetch() and Bun.connect() resolve through the native DNS cache, not dns.lookup(). + setConnectResultOrder(order); } function getDefaultResultOrder() { diff --git a/src/js/node/https.ts b/src/js/node/https.ts index 91df9effe069..6da29ac85565 100644 --- a/src/js/node/https.ts +++ b/src/js/node/https.ts @@ -12,7 +12,12 @@ const { kWaitForProxyTunnel, kPerRequestCheckServerIdentity, isTlsSymbol, + serverSymbol, + tlsSymbol, } = require("internal/http"); +const { kSNIContexts } = require("internal/net/symbols"); +const setListenerSecureContext = $bindgen("node_http_binding.bind.ts", "setSecureContext"); +const addServerName = $bindgen("node_http_binding.bind.ts", "addServerName"); const { validateHeaderValue } = require("node:_http_common"); const { isAnyArrayBuffer, isDataView } = require("node:util/types"); @@ -616,6 +621,30 @@ $toClass(Server, "Server", http.Server); // A listener of this class speaks TLS whatever its options hold. Server.prototype[isTlsSymbol] = true; +Server.prototype.addContext = function addContext(hostname, context) { + const tlsServer = this[tlsSymbol]; + tlsServer.addContext(hostname, context); + const handle = this[serverSymbol]; + if (handle) addServerName(handle, hostname, tlsServer[kSNIContexts].$get(hostname).context); +}; + +Server.prototype.setSecureContext = function setSecureContext(options) { + validateObject(options, "options"); + const tlsServer = this[tlsSymbol]; + // BoringSSL has no DHE suites to apply dhparam to. + tlsServer.setSecureContext({ ...options, dhparam: undefined }); + const handle = this[serverSymbol]; + if (handle) setListenerSecureContext(handle, tlsServer._sharedCreds.context); +}; + +Server.prototype.getTicketKeys = function getTicketKeys() { + return this[tlsSymbol].getTicketKeys(); +}; + +Server.prototype.setTicketKeys = function setTicketKeys(keys) { + this[tlsSymbol].setTicketKeys(keys); +}; + function createServer(options, requestListener) { return new Server(options, requestListener); } diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 16d66ba9f229..d07c17b97146 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -24,6 +24,8 @@ const Duplex = require("internal/streams/duplex"); const { isUint8Array } = require("node:util/types"); const { getDefaultHighWaterMark } = require("internal/streams/state"); const EventEmitter = require("node:events"); +// Fake timers replace the global, and 'close' must still fire. +const setImmediate = $newCppFunction("node/NodeTimers.cpp", "functionSetImmediate", 1); let dns: typeof import("./dns").default; const normalizedArgsSymbol = Symbol("normalizedArgs"); @@ -32,6 +34,7 @@ const { ConnResetException, NodeAggregateError, ErrnoException, + guardCallback, hasObserver, startPerf, stopPerf, @@ -43,10 +46,14 @@ const { kTimeout, getTimerDuration } = require("internal/timers"); const { validateFunction, validateNumber, validatePort, validateBoolean, validateInt32, validateString } = require("internal/validators"); // prettier-ignore const { isIPv4, isIPv6, isIP } = require("internal/net/isIP"); const { + kAcceptedTLS, kArmHandshakeTimeout, kDestroyOnRead, kPreHandshakeWrite, + kReadStop, kSecureConnectDone, + kSelectSNIContext, + kSNIError, kStandaloneWrap, kUpgradeClientTLS, kVerifyError, @@ -67,6 +74,7 @@ interface ListenerHandle extends SocketListener { interface SocketInternals { bytesRead: number; + _bytesDispatched: number; connecting: boolean; isServer: boolean; data?: null; @@ -205,16 +213,31 @@ function uv() { } const isWindows = process.platform === "win32"; -const getDefaultAutoSelectFamily = $bindgen("node_net_binding.bind.ts", "getDefaultAutoSelectFamily"); -const setDefaultAutoSelectFamily = $bindgen("node_net_binding.bind.ts", "setDefaultAutoSelectFamily"); -const getDefaultAutoSelectFamilyAttemptTimeout = $bindgen( - "node_net_binding.bind.ts", - "getDefaultAutoSelectFamilyAttemptTimeout", -); -const setDefaultAutoSelectFamilyAttemptTimeout = $bindgen( - "node_net_binding.bind.ts", - "setDefaultAutoSelectFamilyAttemptTimeout", -); +// Module state, like Node's lib/net.js, so each Worker has its own copy. +let autoSelectFamilyDefault = true; +// Node's docs say 250, but its CLI default in node_options.h is 500. +let autoSelectFamilyAttemptTimeoutDefault = 500; + +function getDefaultAutoSelectFamily() { + return autoSelectFamilyDefault; +} + +function setDefaultAutoSelectFamily(value) { + validateBoolean(value, "value"); + autoSelectFamilyDefault = value; +} + +function getDefaultAutoSelectFamilyAttemptTimeout() { + return autoSelectFamilyAttemptTimeoutDefault; +} + +function setDefaultAutoSelectFamilyAttemptTimeout(value) { + validateInt32(value, "value", 1); + if (value < 10) { + value = 10; + } + autoSelectFamilyAttemptTimeoutDefault = value; +} /** * `--tls-keylog=`: every TLS socket appends its NSS key-log lines here, @@ -280,6 +303,7 @@ const checkServerIdentityInHandshake = $bindgen("socket_body.bind.ts", "checkSer const releaseHeldFlight = $bindgen("socket_body.bind.ts", "releaseHeldFlight"); const isNamedPipeSocket = $bindgen("socket_body.bind.ts", "isNamedPipeSocket"); const getBufferedAmount = $bindgen("socket_body.bind.ts", "getBufferedAmount"); +const getBytesRead = $bindgen("socket_body.bind.ts", "getBytesRead"); const bunTlsSymbol = Symbol.for("::buntls::"); const bunSocketServerOptions = Symbol.for("::bunnetserveroptions::"); @@ -288,7 +312,9 @@ const { owner_symbol } = require("internal/async_hooks").symbols; // Write-only by design: the onconnection write is a GC edge keeping the // native Listener reachable via accepted socket handles (see a93d2fa48e). const kServerSocket = Symbol("kServerSocket"); +const kBytesRead = Symbol("kBytesRead"); const kBytesWritten = Symbol("kBytesWritten"); +const kLastWriteQueueSize = Symbol("kLastWriteQueueSize"); const bunTLSConnectOptions = Symbol.for("::buntlsconnectoptions::"); // tls.Server exposes its native SecureContext constructor through this key so // the SNI dispatch (below) can recognize a raw native context the way Node's @@ -302,6 +328,7 @@ const kSetNoDelay = Symbol("kSetNoDelay"); const kSetTOS = Symbol("kSetTOS"); const kSetKeepAlive = Symbol("kSetKeepAlive"); const kSyncWriteFd = Symbol("kSyncWriteFd"); +const kSyncWriteSink = Symbol("kSyncWriteSink"); const kSetKeepAliveInitialDelay = Symbol("kSetKeepAliveInitialDelay"); const kConnectOptions = Symbol("connect-options"); const kAttach = Symbol("kAttach"); @@ -321,7 +348,6 @@ const khandlers = Symbol("khandlers"); const kclosed = Symbol("closed"); const kended = Symbol("ended"); const kpendingSession = Symbol("pendingSession"); -const kSNIError = Symbol("kSNIError"); const kALPNError = Symbol("kALPNError"); const kPerfHooksNetConnectContext = Symbol("kPerfHooksNetConnectContext"); const khandshakeTimer = Symbol("khandshakeTimer"); @@ -353,6 +379,7 @@ const kOnreadReadRequested = Symbol("kOnreadReadRequested"); const kOnreadEmptyTail = Buffer.alloc(0); const kwriteCallback = Symbol("writeCallback"); const kRejectedWrite = Symbol("kRejectedWrite"); +const kCanceledWrite = Symbol("kCanceledWrite"); const kSocketClass = Symbol("kSocketClass"); // A completed write whose status is a negative errno: Node hands it to the write @@ -419,13 +446,28 @@ function failWriteWith(self, er, callback) { } } } -// No drain comes from a handle that has closed. Node cancels the write that waits for one after 'error' and before 'close'. -function cancelPendingWriteNT(self) { +// A write waiting for 'connect' or for its TLS handle fails from its 'close' listener. A rejected TLS write fails with its own error. +function takeWriteToCancel(self) { const callback = self[kwriteCallback]; - if (!callback || !self[kclosed]) return; - self._pendingData = self[kwriteCallback] = null; + if (!callback || self._pendingData != null || self[kRejectedWrite] !== undefined) return false; + self[kwriteCallback] = null; + self[kCanceledWrite] = callback; + return true; +} +// Node cancels the write the handle held after 'error' and before 'close'. +function cancelWriteNT(self, err?) { + const callback = self[kCanceledWrite]; + if (!callback) return; + self[kCanceledWrite] = undefined; const reason = self.encrypted ? "Canceled because of SSL destruction" : undefined; - callback(new ErrnoException(uv().UV_ECANCELED, "write", reason)); + callback(err ?? new ErrnoException(uv().UV_ECANCELED, "write", reason)); +} +// Runs a tick after the close, behind 'end'. A TLS handshake cut short destroys the socket there, which cancels this write. +function failStrandedWriteNT(self, err?) { + const callback = self[kwriteCallback]; + if (!callback) return; + self[kwriteCallback] = null; + callback(err ?? $ERR_SOCKET_CLOSED()); } // Node's errnoException(status, 'write') for the negative errno $write returns. function writeErrnoException(negErrno) { @@ -458,8 +500,17 @@ function emitCloseNT(self, hasError) { function closeAdoptedTLSRawNT(self, handle, isException) { setImmediate(closeSocketHandle, self, handle, isException); } +// Bytes an onread callback declined don't count until it gets them. +function handleBytesRead(self, handle) { + return getBytesRead(handle) - (self[kOnreadTail]?.length ?? 0); +} function detachSocket(self) { if (!self) self = this; + const handle = self._handle; + if (handle) { + self[kBytesRead] = handleBytesRead(self, handle); + self[kBytesWritten] = handle.bytesWritten; + } self._handle = null; } function destroyNT(self, err) { @@ -493,6 +544,14 @@ function onUpgradedReadError(self, err) { SocketEmitEndNT(self, err); self.destroy(); } +// Node wraps any stream in a JSStreamSocket, so a Duplex from another implementation (readable-stream) works too. +function isWrappableStream(stream) { + return stream instanceof Duplex || require("internal/tls").hasStreamMethods(stream); +} +// Like Node's `connecting || !_handle`, except that an http 'connect' or 'upgrade' socket is connected without a handle. +function isNotConnected(socket) { + return socket.pending || socket.destroyed; +} // Whatever state the wrapped stream is in: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L739-L741 function linkUpgraded(self, connection: SocketInstance) { const isSocket = connection instanceof Socket; @@ -500,11 +559,11 @@ function linkUpgraded(self, connection: SocketInstance) { self._parent = isSocket ? connection : null; destroyWhenUpgradedCloses(self, connection); connection.on("error", onUpgradedError.bind(null, self)); - self.connecting = isSocket && (connection.connecting || !connection._handle); + self.connecting = isSocket && isNotConnected(connection); } // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L964-L973 function onUpgradedConnect(self, connection) { - if (self.destroyed || self[kupgraded] !== connection) return; + if (self.destroyed || !self.connecting || self[kupgraded] !== connection) return; self.connecting = false; self.emit("connect"); } @@ -512,15 +571,19 @@ function onUpgradedConnect(self, connection) { function onUpgradedEnd(connection, eof) { if (!connection.destroyed) eof(); } +// Marks the handle of a TLS engine that runs over a stream, where one write is one SSL_write() of at most 2^31 - 1 bytes. +const kTLSOverStream = Symbol("kTLSOverStream"); +const kMaxTLSWrite = 0x7fffffff; +const kMaxTLSWriteUnits = (kMaxTLSWrite / 3) | 0; // A UTF-16 unit encodes to at most 3 bytes. function attachTLSEngine(self, connection, options) { const [handle, events] = upgradeDuplexToTLS(connection, options); connection.on("data", events[0]); connection.on("end", onUpgradedEnd.bind(null, connection, events[1])); - connection.on("drain", events[2]); - connection.on("close", events[3]); + connection.on("close", events[2]); connection.once(kReadError, onUpgradedReadError.bind(null, self)); // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/js_stream_socket.js#L117-L120 connection.resume(); + handle[kTLSOverStream] = true; self._handle = handle; } function adoptTLSPair(self, connection, pair) { @@ -686,6 +749,8 @@ function onClientHandshake(self, socket, success, verifyError) { self._securePending = false; self._secureEstablished = true; self[kVerifyError] = verifyError ?? null; + // https://github.com/nodejs/node/blob/v26.5.0/lib/internal/tls/wrap.js#L1190 + self.servername ||= false; self.alpnProtocol = socket.alpnProtocol; // Node has no try/catch around these emits; a listener throw reaches // InternalCallbackScope as uncaughtException. reportError mirrors that @@ -829,7 +894,6 @@ const SocketHandlers = { if (!self) return; self._unrefTimer(); - self.bytesRead += buffer.length; pushDataToSocket(self, socket, buffer); }, // `rejected`: the -errno of the send that failed for what a TLS handle still held of the pending write. @@ -847,12 +911,11 @@ const SocketHandlers = { } else if (res) { self._pendingData = self[kwriteCallback] = null; unrefAfterDrain(self, socket); + afterQueuedWrite(self); callback(null); } else { self._pendingData = null; } - - self[kBytesWritten] = socket.bytesWritten; } }, end(socket) { @@ -892,8 +955,7 @@ const SocketHandlers = { self[kwriteCallback] = null; callback(error); } - - self.emit("error", error); + if (!self.destroyed) self.destroy(error); }, open(socket) { const self = socket.data; @@ -932,11 +994,14 @@ const SocketHandlers = { } if (!self[kupgraded]) { - self[kBytesWritten] = socket.bytesWritten; // this is not actually emitted on nodejs when socket used on the connection // this is already emmited on non-TLS socket and on TLS socket is emmited secureConnect after handshake - self.emit("connect", self); - self.emit("ready"); + try { + self.emit("connect", self); + self.emit("ready"); + } catch (e) { + reportError(e); + } } SocketHandlers.drain(socket); @@ -985,8 +1050,15 @@ function unrefAfterDrain(self, handle) { if ((self[kended] || self[kPausedUnref]) && !self[kUserUnrefed] && handle === self._handle) handle.unref?.(); } +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/stream_base_commons.js#L101-L102 +function afterQueuedWrite(self) { + self[kLastWriteQueueSize] = 0; + self._unrefTimer(); +} + function finishSocketEnd(self) { - if (self[kended]) return; + // destroy() isn't an EOF from the peer, so it emits 'close' without 'end'. + if (self[kended] || self.destroyed) return; self[kended] = true; if (!self.allowHalfOpen) self.write = writeAfterFIN; self.push(null); @@ -1034,10 +1106,11 @@ function SocketEmitEndNT(self, _err?) { // merely called): a peer reset while queued data is still unflushed is the // peer aborting mid-transfer and must surface (test-net-error-twice). const teardownNoise = self[kended] && self.writableFinished; - // _hadError: the failure already reached JS through the error dispatch - // (native on_error / a fatal write); node emits a socket error exactly - // once, so the close that follows it is delivered plain. - if (_err && !self.destroyed && !self._hadError && !teardownNoise && self.listenerCount("error") > 0) { + const hasErrorListener = self.listenerCount("error") > 0; + // A write in flight fails the socket anyway, so report the read error like Node does. + const reportsReadError = hasErrorListener || (self[kwriteCallback] != null && !self[kended]); + // _hadError: the error dispatch (native on_error, a fatal write) already reported this failure, and Node emits 'error' once. + if (_err && !self.destroyed && !self._hadError && !teardownNoise && reportsReadError) { // Its TLS socket reports the error. The handle is gone, so this socket closes with nothing to report. if (self.emit(kReadError, _err)) return void self.destroy(); // The consumer can detach its 'error' listener between this close @@ -1045,7 +1118,7 @@ function SocketEmitEndNT(self, _err?) { // finished just as the reset arrived); a last-resort no-op listener keeps // that race from surfacing as an uncaught exception - the no-listener // case is already a documented silent close. - self.once("error", () => {}); + if (hasErrorListener) self.once("error", () => {}); let errErrno; if (_err.code === undefined && typeof (errErrno = _err.errno) === "number" && errErrno !== 0) { // A codeless close error that still carries the errno (Windows IOCP @@ -1085,16 +1158,13 @@ function SocketEmitEndNT(self, _err?) { // no further events. What a finished exchange left unread is still the // reader's: its 'end' closes the socket. self.destroy(); + cancelWriteNT(self, _err); } // A write that was waiting on the native drain can never complete once the // socket is gone - fail it so 'finish'/destroy are not stuck behind it. // kclosed, not _err: a native close can carry no error. A half-open 'end' // also lands here, and its write stays parked because it can still drain. - const pendingWrite = self[kwriteCallback]; - if (pendingWrite && (self[kclosed] || self.destroyed)) { - self[kwriteCallback] = null; - pendingWrite(_err ?? $ERR_SOCKET_CLOSED()); - } + if (self[kwriteCallback] && (self[kclosed] || self.destroyed)) process.nextTick(failStrandedWriteNT, self, _err); } // --- SNICallback dispatch helpers (hoisted: no per-handshake closures) --- @@ -1108,7 +1178,7 @@ function toSNIError(err) { // Applies one SNICallback resolution to the dispatch state. Node assigns // `sni_context = context.context || context`: both the SecureContext wrapper -// and a raw native context are accepted, null/undefined falls through to the +// and a raw native context are accepted, a falsy or primitive value keeps the // default context, and anything else is an invalid SNI context that drops the // connection before the handshake completes. function consumeSNIResult(state, err, context) { @@ -1116,14 +1186,13 @@ function consumeSNIResult(state, err, context) { state.failed = toSNIError(err); return; } - if (context == null) return; - const innerContext = typeof context === "object" ? context.context : undefined; - if (innerContext) { - state.selected = innerContext; - } else if (state.server?.[kNativeSecureContextCtor] && context instanceof state.server[kNativeSecureContextCtor]) { - state.selected = context; - } else { - state.failed = new Error("Invalid SNI context"); + if (!context) return; + const selected = context.context || context; + const NativeSecureContext = state.server?.[kNativeSecureContextCtor]; + if (NativeSecureContext && selected instanceof NativeSecureContext) { + state.selected = selected; + } else if (typeof selected === "object" || typeof selected === "function") { + state.failed = new TypeError("Invalid SNI context"); } } @@ -1158,7 +1227,6 @@ const ServerHandlers = { if (!self) return; self._unrefTimer(); - self.bytesRead += buffer.length; pushDataToSocket(self, socket, buffer); }, keylog(socket, line) { @@ -1215,7 +1283,7 @@ const ServerHandlers = { // (BoringSSL select-certificate retry) until then. // Nothing is cached - the callback runs per-connection the way Node's // does. The native dispatch passes the listener's `data` (the owning - // tls.Server) and the accepted connection's handle. + // tls.Server) and the accepted connection's handle, whose `data` is its TLSSocket. const cb = server?._SNICallback; if (typeof cb !== "function" || !servername) return undefined; const state: SNIState = { @@ -1227,7 +1295,8 @@ const ServerHandlers = { suspended: false, }; try { - cb.$call(server, servername, onSNIResolution.bind(null, state)); + // https://github.com/nodejs/node/blob/v26.5.0/lib/internal/tls/wrap.js#L298 + cb.$call(socketHandle?.data, servername, onSNIResolution.bind(null, state)); } catch (err) { state.settled = true; state.failed = toSNIError(err); @@ -1384,6 +1453,14 @@ const ServerHandlers = { } } self[kSessionAccepted] = true; + // A net.Socket with its own transport never emits 'connect', but a finished handshake proves it's connected. + if (self.connecting) { + try { + onUpgradedConnect(self, self[kupgraded]); + } catch (err) { + reportError(err); + } + } const pauseOnConnect = server?.pauseOnConnect; if (pauseOnConnect) { pauseOnCreate(self, socket); @@ -1587,8 +1664,10 @@ function onconnection(err, clientHandle) { // Use the server's normalized fields so the per-socket flag agrees with the // native listener: node's tlsConnectionListener passes `this.requestCert` // (`=== true`-normalized), not the raw option. - _socket._requestCert = self._requestCert; - _socket._rejectUnauthorized = self._rejectUnauthorized; + if (typeof _socket[bunTlsSymbol] === "function") { + _socket._requestCert = self._requestCert; + _socket._rejectUnauthorized = self._rejectUnauthorized; + } _socket[kAttach](clientHandle.localPort, clientHandle); @@ -1641,11 +1720,12 @@ function onconnection(err, clientHandle) { _socket.server = self; _socket._server = self; - if (pauseOnConnect && !isTLS) { - pauseOnCreate(_socket, clientHandle); - } - - if (isTLS) initAcceptedTLSSocket(self, _socket); + // Before 'connection', so it can't undo a listener's readStop: https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L623-L632 + if (isTLS) { + initAcceptedTLSSocket(self, _socket); + _socket[kAcceptedTLS] = self; + } else if (pauseOnConnect) pauseOnCreate(_socket, clientHandle); + else _socket.read(0); // Node reports a throwing 'connection' listener as uncaughtException and keeps the socket. try { @@ -1653,9 +1733,6 @@ function onconnection(err, clientHandle) { } catch (e) { reportError(e); } - if (!pauseOnConnect && !isTLS) { - _socket.read(0); - } } // TODO: SocketHandlers2 is a bad name but its temporary. reworking the Server in a followup PR @@ -1687,7 +1764,6 @@ const SocketHandlers2 = { $debug("Bun.Socket data"); const { self } = socket.data; self._unrefTimer(); - self.bytesRead += buffer.length; pushDataToSocket(self, socket, buffer); }, // See SocketHandlers.drain. @@ -1701,15 +1777,13 @@ const SocketHandlers2 = { const res = rejected ?? socket.$write(writeChunk || "", self._pendingEncoding || "utf8"); if (typeof res === "number" && res < 0) { // The retried send failed for good (peer gone): $write returned -errno. - self[kBytesWritten] = socket.bytesWritten; if (!holdRejectedWrite(self, socket, res, callback)) failWrite(self, res, callback); } else if (res) { - self[kBytesWritten] = socket.bytesWritten; self._pendingData = self[kwriteCallback] = null; unrefAfterDrain(self, socket); + afterQueuedWrite(self); callback(null); } else { - self[kBytesWritten] = socket.bytesWritten; self._pendingData = null; } } @@ -1751,12 +1825,14 @@ const SocketHandlers2 = { // family-autoselection race and raw sockets handed off during a TLS // upgrade also report errors on close, and those must keep ending // cleanly. - if (!leftToTLSSocket && err && !self.destroyed && socket === self._handle && self.listenerCount("error") > 0) { + const hasErrorListener = self.listenerCount("error") > 0; + const reportsReadError = hasErrorListener || self[kwriteCallback] != null; + if (!leftToTLSSocket && err && !self.destroyed && socket === self._handle && reportsReadError) { // See SocketEmitEndNT. if (self.emit(kReadError, err)) return void self.destroy(); // Same late-detach guard as SocketEmitEndNT: the listener seen at // close-time can be gone by the deferred 'error' emission. - self.once("error", () => {}); + if (hasErrorListener) self.once("error", () => {}); if (err.code === undefined || err.code === "ECONNRESET") { // Shape it like Node's errnoException(UV_ECONNRESET, 'read'). const er = new ConnResetException("read ECONNRESET") as Error & { errno?: number; syscall?: string }; @@ -1778,11 +1854,7 @@ const SocketHandlers2 = { // A write that was waiting on the native drain can never complete once the // socket is gone - fail it so 'finish'/destroy are not stuck behind it // (mirrors SocketEmitEndNT). - const pendingWrite = self[kwriteCallback]; - if (pendingWrite) { - self[kwriteCallback] = null; - pendingWrite($ERR_SOCKET_CLOSED()); - } + if (self[kwriteCallback]) process.nextTick(failStrandedWriteNT, self); }, handshake(socket, success, verifyError) { $debug("Bun.Socket handshake"); @@ -1895,6 +1967,11 @@ function traceConnectEnd(req) { function kConnectTcp(self, addressType, req, address, port) { $debug("SocketHandle.kConnectTcp", addressType, address, port); + // Node dials through the handle, and @mswjs/interceptors replaces these methods to take over the connection. + const handle = self._handle; + const replaced = addressType === 6 ? handle.connect6 : handle.connect; + if (typeof replaced === "function") return replaced.$call(handle, req, address, port); + traceConnectStart(req); return kConnectDispatch(self, req, { hostname: address, port, @@ -1916,16 +1993,30 @@ function kConnectTcp(self, addressType, req, address, port) { function kConnectPipe(self, req, address) { $debug("SocketHandle.kConnectPipe"); - return kConnectDispatch(self, req, { + const handle = self._handle; + const replaced = handle.connect; + if (typeof replaced === "function") return replaced.$call(handle, req, address); + traceConnectStart(req, address); + const data = { self, req }; + const errno = kConnectDispatch(self, req, { hostname: address, unix: address, // Always half-open natively; see kConnect. allowHalfOpen: true, tls: req.tls, pauseOnConnect: req.pauseOnConnect, - data: { self, req }, + data, socket: self[khandlers], }); + // libuv returns EINVAL for a name it can't use and reports a failed connect(2) from the loop. + if (errno === 0 || errno === uv().UV_EINVAL) return errno; + setImmediate(afterPipeConnectFailed, self, handle, data, req, errno); + return 0; +} + +function afterPipeConnectFailed(self, handle, data, req, errno) { + // destroy() or another connect() already ended this request. + if (self._handle === handle && handle.data === data) req.oncomplete(errno, handle, req, true, true); } function kConnectDispatch(self, req, opts) { @@ -2004,8 +2095,9 @@ function Socket(options?): void { this[khandlers] = SocketHandlers2; this[kDestroyOnRead] = false; - this.bytesRead = 0; + this[kBytesRead] = 0; this[kBytesWritten] = undefined; + this[kLastWriteQueueSize] = 0; this[kclosed] = false; this[kended] = false; this.connecting = false; @@ -2017,6 +2109,7 @@ function Socket(options?): void { // https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L401 this[kTimeout] = null; this[kwriteCallback] = undefined; + this[kCanceledWrite] = undefined; this._pendingData = undefined; this._pendingEncoding = undefined; // for compatibility this._hadError = false; @@ -2032,19 +2125,14 @@ function Socket(options?): void { // Shut down the socket when we're finished with it. this.on("end", onSocketEnd); + let stats; if (fd !== undefined) { // Adopt pipe/character-device fds with synchronous writes. Matches // node's effective semantics for stdio-style sockets: writes to a pipe // complete inline, so data survives an immediate process.exit(). // Gated on an explicit `writable: true` (how node's own stdio wraps fds, - // e.g. new Socket({ fd: 2, readable: false, writable: true })): a bare - // { fd } is the connect({ fd }) path (child_process extra stdio), which - // attaches a native duplex handle in Socket.prototype.connect - adopting - // here would end its readable side and stomp its write path. - // Network-socket fds are not supported (handle adoption needs native - // support); those keep the previous validated-but-inert behavior. + // e.g. new Socket({ fd: 2, readable: false, writable: true })). if (opts.writable === true) { - let stats; try { stats = require("node:fs").fstatSync(fd); } catch { @@ -2056,19 +2144,27 @@ function Socket(options?): void { if (stats.isFile()) { throw $ERR_INVALID_FD_TYPE("FILE"); } - // isSocket() covers stdio handed to a child as a socketpair (how spawn - // implements pipes on unix); writable-only adoption with sync write(2) - // is correct there too. - const optionsReadable = opts.readable; - if (stats.isFIFO() || stats.isCharacterDevice() || (stats.isSocket() && optionsReadable !== true)) { - this[kSyncWriteFd] = fd; - this._write = fdSyncWrite; - this._writev = fdSyncWritev; - // No native reads on this path: an unspecified `readable` gets EOF here (`false` already ended it in the Duplex). - if (optionsReadable !== true && optionsReadable !== false) { - this.push(null); - this.read(0); - } + } + } + + // After the fd type check like Node, but before adopting the fd: a socket discarded by the throw would close it. + const typeOfService = opts.typeOfService; + if (typeOfService !== undefined) { + validateInt32(typeOfService, "options.typeOfService", 0, 255); + this[kSetTOS] = typeOfService; + } + + if (stats !== undefined) { + // isSocket() covers stdio handed to a child as a socketpair, which is how spawn implements pipes on unix. + const optionsReadable = opts.readable; + if (stats.isFIFO() || stats.isCharacterDevice() || (stats.isSocket() && optionsReadable !== true)) { + this[kSyncWriteFd] = fd; + this._write = fdSyncWrite; + this._writev = fdSyncWritev; + // No native reads on this path: an unspecified `readable` gets EOF here (`false` already ended it in the Duplex). + if (optionsReadable !== true && optionsReadable !== false) { + this.push(null); + this.read(0); } } } @@ -2116,7 +2212,7 @@ function Socket(options?): void { if (dest === true) { let ret; try { - ret = onreadCallback(total - offset, true); + ret = onreadCallback.$call(self, total - offset, true); if (onreadBufferIsFn) { const next = onreadBuffer(); if (isUint8Array(next)) self[kOnreadBuffer] = next; @@ -2147,7 +2243,7 @@ function Socket(options?): void { offset += n; let ret; try { - ret = onreadCallback(n, dest); + ret = onreadCallback.$call(self, n, dest); if (onreadBufferIsFn) { const next = onreadBuffer(); if (isUint8Array(next)) self[kOnreadBuffer] = next; @@ -2201,6 +2297,20 @@ function Socket(options?): void { } this.blockList = optsBlockList; } + + // usockets can't read a FIFO (recv() fails with ENOTSOCK), and it would close fds 0-2 on destroy, which Node never does. + if ( + process.platform !== "win32" && + fd > 2 && + this[kSyncWriteFd] === undefined && + opts.readable !== false && + !this[bunTlsSymbol] + ) { + try { + stats ??= require("node:fs").fstatSync(fd); + } catch {} + if (stats?.isSocket()) Socket.prototype.connect.$call(this, { fd }); + } } $toClass(Socket, "Socket", Duplex); @@ -2215,15 +2325,20 @@ Socket.prototype.address = function address() { Socket.prototype._onTimeout = function () { // if there is pending data, write is in progress // so we suppress the timeout - if (this._pendingData) { + // A write queued before 'connect' isn't in progress yet. + if (this._pendingData && !this.connecting) { return; } const handle = this._handle; - // if there is a handle, and it has pending data, - // we suppress the timeout because a write is in progress - if (handle && getBufferedAmount(handle) > 0) { - return; + if (handle) { + // A write queue that moved since the last tick is still draining, so wait another period. A stalled one times out. + const writeQueueSize = getBufferedAmount(handle); + if (writeQueueSize !== this[kLastWriteQueueSize]) { + this[kLastWriteQueueSize] = writeQueueSize; + this._unrefTimer(); + return; + } } this.emit("timeout"); }; @@ -2237,15 +2352,27 @@ Object.defineProperty(Socket.prototype, "bufferSize", { }, }); +// https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L939-L950 +Object.defineProperty(Socket.prototype, "bytesRead", { + configurable: false, + enumerable: true, + get: function () { + const handle = this._handle; + return handle ? handleBytesRead(this, handle) : this[kBytesRead]; + }, +}); + +// https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L1043-L1045 Object.defineProperty(Socket.prototype, "_bytesDispatched", { get: function () { - return this[kBytesWritten] || 0; + const handle = this._handle; + return (handle ? handle.bytesWritten : this[kBytesWritten]) || 0; }, }); Object.defineProperty(Socket.prototype, "bytesWritten", { get: function (this: SocketInstance) { - let bytes = this[kBytesWritten] || 0; + let bytes = this._bytesDispatched; const data = this._pendingData; const writableBuffer = this.writableBuffer; if (!writableBuffer) return undefined; @@ -2282,7 +2409,6 @@ Socket.prototype[kAttach] = function (port, socket) { } if (!this[kupgraded]) { - this[kBytesWritten] = socket.bytesWritten; // this is not actually emitted on nodejs when socket used on the connection // this is already emmited on non-TLS socket and on TLS socket is emmited secureConnect after handshake this.emit("connect", this); @@ -2296,13 +2422,15 @@ Socket.prototype[kCloseRawConnection] = function () { // Only a destroy the connection's owner started counts as it closing under this socket. if (!connection.destroyed) connection.removeListener("close", this[kOnUpgradedClose]); connection.connecting = false; - connection._handle = null; + detachSocket(connection); connection.unref(); connection.destroy(); }; Socket.prototype.connect = function connect(...args) { $debug("Socket.prototype.connect"); + // A write that destroy() canceled belongs to the old connection. Settled later, it would fail this one. + cancelWriteNT(this); { const [options, connectListener] = $isArray(args[0]) && args[0][normalizedArgsSymbol] ? args[0] : normalizeArgs(args); @@ -2315,12 +2443,14 @@ Socket.prototype.connect = function connect(...args) { process.nextTick(destroyNT, this, ex); return this; } - this.servername = options.servername; + if (typeof this[bunTlsSymbol] === "function") this.servername = options.servername; if (socket) { connection = socket; } - if (fd != null) { - doConnect(this._handle, { + // createConnection({ fd }) gets here after the constructor already adopted the fd. + const { _handle } = this; + if (fd != null && _handle?.fd !== fd) { + doConnect(_handle, { data: this, fd: fd, ...(options.fdIsRawSocket === true ? { fdIsRawSocket: true } : {}), @@ -2353,16 +2483,20 @@ Socket.prototype.connect = function connect(...args) { if (fd != null) { return this; } + const bunTLS = this[bunTlsSymbol]; if ( // TLSSocket already created a socket and is forwarding it here. This is a private API. - !(socket && $isObject(socket) && socket instanceof Duplex) && + !( + socket && + $isObject(socket) && + (typeof bunTLS === "function" ? isWrappableStream(socket) : socket instanceof Duplex) + ) && // public api for net.Socket.connect port === undefined && path == null ) { throw $ERR_MISSING_ARGS(["options", "port", "path"]); } - const bunTLS = this[bunTlsSymbol]; var tls: TLSConnectOptions | undefined = undefined; if (typeof bunTLS === "function") { tls = bunTLS.$call(this, port, host, true); @@ -2380,12 +2514,15 @@ Socket.prototype.connect = function connect(...args) { this[bunTLSConnectOptions] = tls; } if (connection) { + // An http 'connect' or 'upgrade' socket is connected but has no fd to adopt. + const connectedWithoutHandle = !connection._handle && !isNotConnected(connection); if ( typeof connection !== "object" || !(connection instanceof Socket) || - typeof connection[bunTlsSymbol] === "function" + typeof connection[bunTlsSymbol] === "function" || + connectedWithoutHandle ) { - if (connection instanceof Duplex) { + if (isWrappableStream(connection)) { upgradeDuplex = true; } else { throw new TypeError("socket must be an instance of net.Socket or Duplex"); @@ -2478,6 +2615,8 @@ Socket.prototype._destroy = function _destroy(err, callback) { $debug("Socket.prototype._destroy"); this.connecting = false; + // Closing the handle first would fail this write from the close handler, ahead of 'error'. + const canceled = takeWriteToCancel(this); const upgraded = this[kupgraded]; // Close an fd adopted for synchronous writes (node closes the wrapping @@ -2490,6 +2629,18 @@ Socket.prototype._destroy = function _destroy(err, callback) { // socket goes through the fresh handle's normal write path. delete this._write; delete this._writev; + const sink = this[kSyncWriteSink]; + if (sink !== undefined) { + this[kSyncWriteSink] = undefined; + // The sink has its own dup of the fd. Unref it, or bytes the reader never reads would block process exit. + sink.unref(); + try { + const ended = sink.end(); + if ($isPromise(ended)) ended.$then(noop, noop); + } catch (e) { + err ||= e; + } + } if (syncFd > 2) { try { require("node:fs").closeSync(syncFd); @@ -2509,7 +2660,7 @@ Socket.prototype._destroy = function _destroy(err, callback) { $debug("close handle"); const isException = err ? true : false; // `bytesRead` and `kBytesWritten` should be accessible after `.destroy()` - // this[kBytesRead] = this._handle.bytesRead; + this[kBytesRead] = handleBytesRead(this, this._handle); this[kBytesWritten] = this._handle.bytesWritten; const currentHandle = this._handle; @@ -2553,11 +2704,11 @@ Socket.prototype._destroy = function _destroy(err, callback) { } callback(err); closeOwedRaw(this, upgraded); - if (this[kwriteCallback]) process.nextTick(cancelPendingWriteNT, this); + if (canceled) process.nextTick(cancelWriteNT, this); } else { callback(err); closeOwedRaw(this, upgraded); - if (this[kwriteCallback]) process.nextTick(cancelPendingWriteNT, this); + if (canceled) process.nextTick(cancelWriteNT, this); const transport = transportToDestroy(this); if (transport !== undefined) abortTransport(transport); if (transport instanceof Socket) transport.once("close", emitCloseNT.bind(null, this, err ? true : false)); @@ -2679,17 +2830,31 @@ Socket.prototype.pause = function pause() { return Duplex.prototype.pause.$call(this); }; +Socket.prototype[kReadStop] = function () { + const handle = this._handle; + if (handle) readStop(this, handle); +}; + // Server-side TLS upgrade over an accepted socket, for // `new tls.TLSSocket(socket, { isServer: true })`. Adopts the connection's fd // into an accept-state TLS socket (us_socket_adopt_tls with is_client=0) so the // native read path drives the handshake. Lives here, not tls.ts, to reach // ServerHandlers — the shared accepted-socket handler table, with per-socket // state carried via `data` (mirrors tls.createServer's one-handler-for-all model). -Socket.prototype[Symbol.for("::bunUpgradeServerTLS::")] = function (connection, tls) { +Socket.prototype[Symbol.for("::bunUpgradeServerTLS::")] = function ( + connection: SocketInstance & { encrypted?: boolean }, + tls, +) { linkUpgraded(this, connection); const options = { data: this, tls, socket: serverHandlersFor(this), isServer: true }; const socket = connection._handle; - if (!socket || connection.encrypted || isNamedPipeSocket(socket) || hasUnflushedWrites(connection)) { + if ( + !socket || + !(connection instanceof Socket) || + connection.encrypted || + isNamedPipeSocket(socket) || + hasUnflushedWrites(connection) + ) { // No adoptable fd (generic Duplex, named pipe, never dialed), TLS over TLS (the fd belongs to the // outer SSL layer), or pending plain writes that must flush first. attachTLSEngine(this, connection, options); @@ -2813,41 +2978,84 @@ function fdSyncWrite(chunk, encoding, callback) { const fs = require("node:fs"); // node's _writeGeneric restarts the idle timer on every write. this._unrefTimer(); + const buf = typeof chunk === "string" ? Buffer.from(chunk, encoding) : chunk; + let offset = 0; try { - const buf = typeof chunk === "string" ? Buffer.from(chunk, encoding) : chunk; - let offset = 0; while (offset < buf.length) { offset += fs.writeSync(this[kSyncWriteFd], buf, offset); } - // No native handle on this path, so feed bytesWritten/_bytesDispatched - // directly (node accounts these via the libuv handle). - this[kBytesWritten] = (this[kBytesWritten] || 0) + offset; - callback(); } catch (err) { - callback(err); + if (!isFullPipe(this, err)) return callback(err); + return queueFdWrite(this, offset, buf.subarray(offset), callback); } + // No native handle counts these bytes for bytesWritten and _bytesDispatched. + this[kBytesWritten] = (this[kBytesWritten] || 0) + offset; + callback(); +} + +function toBuffer({ chunk, encoding }) { + return typeof chunk === "string" ? Buffer.from(chunk, encoding) : chunk; } function fdSyncWritev(data, callback) { const fs = require("node:fs"); this._unrefTimer(); - try { - let total = 0; - for (let i = 0; i < data.length; i++) { - const { chunk, encoding } = data[i]; - const buf = typeof chunk === "string" ? Buffer.from(chunk, encoding) : chunk; - let offset = 0; + let total = 0; + for (let i = 0; i < data.length; i++) { + const buf = toBuffer(data[i]); + let offset = 0; + try { while (offset < buf.length) { offset += fs.writeSync(this[kSyncWriteFd], buf, offset); } - total += offset; + } catch (err) { + if (!isFullPipe(this, err)) return callback(err); + const rest = [buf.subarray(offset)]; + while (++i < data.length) ArrayPrototypePush.$call(rest, toBuffer(data[i])); + return queueFdWrite(this, total + offset, Buffer.concat(rest), callback); } - // See fdSyncWrite: no native handle to account these on. - this[kBytesWritten] = (this[kBytesWritten] || 0) + total; - callback(); - } catch (err) { - callback(err); + total += offset; + } + // See fdSyncWrite: no native handle to account these on. + this[kBytesWritten] = (this[kBytesWritten] || 0) + total; + callback(); +} + +// Returns true if write(2) failed because the fd is non-blocking, its buffer is full, and a FileSink can poll it. +function isFullPipe(self, err) { + if (process.platform === "win32" || err?.code !== "EAGAIN") return false; + if (self[kSyncWriteSink] !== undefined) return true; + const stats = require("node:fs").fstatSync(self[kSyncWriteFd]); + return stats.isFIFO() || stats.isSocket(); +} + +// Writes `rest` once the fd is writable again, like Node's pipe handle. `written` bytes of this write are already out. +function queueFdWrite(self, written, rest, callback) { + // Node counts a write when the handle gets it. + self[kBytesWritten] = (self[kBytesWritten] || 0) + written + rest.length; + let result; + try { + const sink = (self[kSyncWriteSink] ??= Bun.file(self[kSyncWriteFd]).writer()); + result = sink.write(rest); + // The sink buffers a small chunk instead of writing it. + if (!$isPromise(result)) result = sink.flush(); + } catch (e) { + return callback(e); } + if (!$isPromise(result)) return callback(); + self[kwriteCallback] = callback; + result.$then( + afterQueuedFdWrite.bind(undefined, self, callback, null), + afterQueuedFdWrite.bind(undefined, self, callback), + ); +} + +function afterQueuedFdWrite(self, callback, err) { + // destroy() canceled it. + if (self[kwriteCallback] !== callback) return; + self[kwriteCallback] = null; + // A throw from a promise reaction would be an unhandled rejection. Node reports it as an uncaught exception. + guardCallback(callback)(err); } Socket.prototype.resetAndDestroy = function resetAndDestroy() { @@ -2901,6 +3109,11 @@ Socket.prototype.setNoDelay = function setNoDelay(enable = true) { return this; }; +// EINVAL is a unix socket, EBADF is TLS over a stream. Node's Pipe and TLSWrap lack these methods, so it only caches the value. +function handleHasNoTypeOfService(socket, err) { + return err === uv().UV_EINVAL || (err === uv().UV_EBADF && !socket.connecting); +} + // Matches Node's setTypeOfService/getTypeOfService (lib/net.js + TCPWrap). // The native handle does the setsockopt (IP_TOS / IPV6_TCLASS); a socket // without a handle yet caches the value and applies it on connect. @@ -2921,7 +3134,7 @@ Socket.prototype.setTypeOfService = function setTypeOfService(tos) { const err = this._handle.setTypeOfService(tos); // Windows often restricts TOS or reports errors even when partially // applied - best-effort there, the way Node treats it. - if (err && process.platform !== "win32") { + if (err && process.platform !== "win32" && !handleHasNoTypeOfService(this, err)) { throw new ErrnoException(err, "setTypeOfService"); } } @@ -2936,7 +3149,7 @@ Socket.prototype.getTypeOfService = function getTypeOfService() { if (typeof res === "number" && res < 0) { // getsockopt(IP_TOS) commonly fails on Windows: fall back to the cached // value the way Node does. - if (process.platform === "win32") { + if (process.platform === "win32" || handleHasNoTypeOfService(this, res)) { return this[kSetTOS] !== undefined ? this[kSetTOS] : 0; } throw new ErrnoException(res, "getTypeOfService"); @@ -3068,6 +3281,15 @@ Socket.prototype._write = function _write(chunk, encoding, callback) { } this._unrefTimer(); if (this.secureConnecting) this[kPreHandshakeWrite] = true; + if ( + chunk.length > kMaxTLSWriteUnits && + socket[kTLSOverStream] && + (typeof chunk === "string" ? Buffer.byteLength(chunk, encoding) : chunk.length) > kMaxTLSWrite + ) { + // https://github.com/nodejs/node/blob/v26.5.0/src/crypto/crypto_tls.cc#L1124-L1126 + callback(new ErrnoException(uv().UV_EPROTO, "write")); + return false; + } if (socket.readyState < 0) { // The handle's native socket was already closed (e.g. handle.close() was // called directly): fail the write the way a write(2) on a closed fd does @@ -3083,7 +3305,6 @@ Socket.prototype._write = function _write(chunk, encoding, callback) { return false; } const res = socket.$write(chunk, encoding); - this[kBytesWritten] = socket.bytesWritten; if (res < 0) { // The kernel rejected the send outright (peer reset): $write returned the // negative errno; deliver it like the EBADF/EPIPE branch above. @@ -3383,6 +3604,8 @@ function internalConnect(self, options, address, port?, addressType?, localAddre $debug("connect: attempting to connect to %s:%d (addressType: %d)", address, port, addressType); self.emit("connectionAttempt", address, port, addressType); + // A listener may destroy() the socket. Connecting now would open a handle with no owner. + if (!self.connecting) return; if (addressType === 6 || addressType === 4) { if (self.blockList?.check(address, `ipv${addressType}`)) { @@ -3399,7 +3622,6 @@ function internalConnect(self, options, address, port?, addressType?, localAddre req.tls = tls; req.pauseOnConnect = options.pauseOnConnect; - traceConnectStart(req); err = kConnectTcp(self, addressType, req, address, port); // kConnectTcp returns 0 (not undefined) on the async-connect path, so the // perf context must be established whenever the attempt was dispatched @@ -3420,7 +3642,6 @@ function internalConnect(self, options, address, port?, addressType?, localAddre req.tls = tls; req.pauseOnConnect = options.pauseOnConnect; - traceConnectStart(req, address); err = kConnectPipe(self, req, address); } @@ -3502,6 +3723,7 @@ function internalConnectMultiple(context, canceled?) { $debug("connect/multiple: attempting to connect to %s:%d (addressType: %d)", address, port, addressType); self.emit("connectionAttempt", address, port, addressType); + if (!self.connecting) return; // const req = new TCPConnectWrap(); const req: ConnectReq = {}; @@ -3516,7 +3738,6 @@ function internalConnectMultiple(context, canceled?) { ArrayPrototypePush.$call(self.autoSelectFamilyAttemptedAddresses, `${address}:${port}`); - traceConnectStart(req); err = kConnectTcp(self, addressType, req, address, port); if (err) { @@ -3529,9 +3750,7 @@ function internalConnectMultiple(context, canceled?) { return; } - // The if(err) above covers sync failure; this catches a sync open or a - // destroy() from a 'connectionAttempt' listener. Arming the timer now - // would capture a stale handle and overwrite the next attempt's kTimeout. + // After a sync open, arming the timer would capture a stale handle and overwrite the next attempt's kTimeout. if (!self.connecting || context.current !== current + 1) { return; } @@ -3623,8 +3842,13 @@ function afterConnect(status, handle, req, readable, writable) { const pausedBeforeConnect = self.isPaused() || (self.readableEnded && self[kOnreadBuffer] === undefined); if (pausedBeforeConnect && !self.encrypted) readStop(self, self._handle); - self.emit("connect"); - self.emit("ready"); + // Node reports a throwing 'connect' or 'ready' listener as uncaughtException and keeps the socket. + try { + self.emit("connect"); + self.emit("ready"); + } catch (e) { + reportError(e); + } if (self[kPerfHooksNetConnectContext] && hasObserver("net")) { stopPerf(self, kPerfHooksNetConnectContext); @@ -3881,21 +4105,18 @@ Server.prototype.address = function address() { Server.prototype.getConnections = function getConnections(callback) { if (typeof callback === "function") { - //in Bun case we will never error on getConnections - //node only errors if in the middle of the couting the server got disconnected, what never happens in Bun - //if disconnected will only pass null as well and 0 connected - callback(null, this._handle ? this._connections : 0); + process.nextTick(callback, null, this._connections); } return this; }; -Server.prototype.listen = function listen(port, hostname, onListen) { +Server.prototype.listen = function listen(port, hostname, backlogArg) { const argsLength = arguments.length; - if (typeof port === "string") { - const numPort = Number(port); - if (!Number.isNaN(numPort)) port = numPort; - } - let backlog; + // The callback is always the last argument: https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L315-L319 + const lastArg = argsLength > 0 ? arguments[argsLength - 1] : undefined; + let onListen = typeof lastArg === "function" ? lastArg : undefined; + // (path, backlog), (port, backlog) or (port, host, backlog) + let backlog = toNumber(argsLength > 1 && hostname) || toNumber(argsLength > 2 && backlogArg); let path; let exclusive = false; let reusePort = false; @@ -3903,35 +4124,14 @@ Server.prototype.listen = function listen(port, hostname, onListen) { let readableAll = false; let writableAll = false; let fd; - //port is actually path - if (typeof port === "string") { - if (Number.isSafeInteger(hostname)) { - if (hostname > 0) { - //hostname is backlog - backlog = hostname; - } - } else if (typeof hostname === "function") { - //hostname is callback - onListen = hostname; - } - + if (isPipeName(port)) { path = port; hostname = undefined; port = undefined; } else { - if (typeof hostname === "number") { - backlog = hostname; - hostname = undefined; - } else if (typeof hostname === "function") { - onListen = hostname; - hostname = undefined; - } else if (typeof hostname === "string" && typeof onListen === "number") { - backlog = onListen; - onListen = argsLength > 3 ? arguments[3] : undefined; - } + if (typeof hostname !== "string") hostname = undefined; if (typeof port === "function") { - onListen = port; port = 0; } else if (port !== null && typeof port === "object") { const options = port; @@ -3946,7 +4146,7 @@ Server.prototype.listen = function listen(port, hostname, onListen) { // constructor (it shapes accepted sockets' Duplex behavior); the native // listen always uses allowHalfOpen: true. reusePort = options.reusePort; - backlog = options.backlog; + backlog = options.backlog || backlog; // For a unix-socket listen, readableAll/writableAll chmod the socket file // in kRealListen; threaded through as locals (not stashed on the instance). readableAll = options.readableAll; @@ -4102,13 +4302,11 @@ Server.prototype.listen = function listen(port, hostname, onListen) { contexts, onListen, ); - } catch (err) { + } catch (err: any) { const isUnix = path != null; - process.nextTick( - emitErrorNextTick, - this, - formatListenError(err, isUnix ? path : hostname, isUnix ? undefined : port), - ); + // After the IPv4 fallback, the address that failed isn't `hostname`. + const address = isUnix ? path : err?.address || hostname; + process.nextTick(emitErrorNextTick, this, formatListenError(err, address, isUnix ? undefined : port)); } return this; }; @@ -4126,6 +4324,7 @@ Server.prototype[kRealListen] = function ( contexts, _onListen, fd, + hostDefaulted, ) { // NOTE: accepted sockets are always allowHalfOpen:true at the native layer // (hardcoded below); the stream layer implements allowHalfOpen=false @@ -4181,7 +4380,7 @@ Server.prototype[kRealListen] = function ( pauseOnConnect: this.pauseOnConnect, }); } else { - this._handle = Bun.listen({ + const listenOptions = { port, hostname, tls, @@ -4192,7 +4391,15 @@ Server.prototype[kRealListen] = function ( socket: serverHandlersFor(this), data: this, pauseOnConnect: this.pauseOnConnect, - }); + }; + try { + this._handle = Bun.listen(listenOptions); + } catch (err: any) { + // No IPv6 in the kernel: https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L2127-L2139 + if (!hostDefaulted || err?.code !== "EAFNOSUPPORT") throw err; + listenOptions.hostname = "0.0.0.0"; + this._handle = Bun.listen(listenOptions); + } } this._handle[owner_symbol] = this; @@ -4329,6 +4536,7 @@ function listenInCluster( contexts, onListen, fd, + address == null, ); return; } @@ -4501,6 +4709,11 @@ function initSocketHandle(self) { self._sockname = null; self[kclosed] = false; self[kended] = false; + // Bytes the onread callback declined belong to the previous connection. + if (self[kOnreadTail] !== undefined) { + self[kOnreadTail] = undefined; + self[kOnreadPendingEnd] = false; + } // Handle creation may be deferred to bind() or connect() time. const handle = self._handle; @@ -4557,6 +4770,8 @@ function uvListenErrorDescription(code) { return "address not available"; case "EINVAL": return "invalid argument"; + case "EAFNOSUPPORT": + return "address family not supported"; default: return undefined; } @@ -4617,6 +4832,10 @@ Server.prototype[kArmHandshakeTimeout] = function (socket) { initAcceptedTLSSocket(this, socket); }; +Server.prototype[kSelectSNIContext] = function (servername, socketHandle) { + return ServerHandlers.serverName(this, servername, socketHandle); +}; + const netExports = { createServer, Server, diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 8d08f24e8d1b..557f35716081 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -6,7 +6,7 @@ const EventEmitter = require("node:events"); const addServerName = $bindgen("Listener.bind.ts", "addServerName"); const setListenerSecureContext = $bindgen("Listener.bind.ts", "setSecureContext"); const firstFlightBeforeFin = $bindgen("socket_body.bind.ts", "firstFlightBeforeFin"); -const { throwNotImplemented } = require("internal/shared"); +const { throwNotImplemented, kInternalAssertionSuffix } = require("internal/shared"); const { idnaToASCII } = require("internal/url"); const { throwOnInvalidTLSArray, @@ -17,6 +17,7 @@ const { stripTls13CipherNames, validateSecureContextOptions, SSL_OP_CIPHER_SERVER_PREFERENCE, + hasStreamMethods, } = require("internal/tls"); const { validateString, @@ -29,9 +30,11 @@ const { const { Server: NetServer, Socket: NetSocket } = net; const { + kAcceptedTLS, kArmHandshakeTimeout, kPreHandshakeWrite, kSecureConnectDone, + kSNIContexts, kStandaloneWrap, kUpgradeClientTLS, kVerifyError, @@ -47,6 +50,7 @@ const parseCACertificates = $newCppFunction("NodeTLS.cpp", "parseCACertificates" const getTLSDefaultCiphers = $newCppFunction("NodeTLS.cpp", "getDefaultCiphers", 0); const setTLSDefaultCiphers = $newCppFunction("NodeTLS.cpp", "setDefaultCiphers", 1); +const setTLSDefaultCA = $newCppFunction("NodeTLS.cpp", "setDefaultCA", 1); const selectServerName = $newCppFunction("NodeTLS.cpp", "selectServerName", 2); let _VALID_CIPHERS_SET: Set | undefined; function getValidCiphersSet() { @@ -461,6 +465,8 @@ function newNativeSecureContext(options, cached = false) { // mutated context must not be the shared cached one. const pfxExtraCAs = options._pfxExtraCACerts; if (pfxExtraCAs) cached = false; + const ticketKeys = options.ticketKeys; + if (ticketKeys != null) cached = false; // ALPN protocols given as an array of strings are converted to the // length-prefixed wire format before crossing into native, the way Node's // convertALPNProtocols normalizes them on the socket options. @@ -527,6 +533,7 @@ function newNativeSecureContext(options, cached = false) { if (pfxExtraCAs) { for (const pem of pfxExtraCAs) ctx.addCACert(pem); } + if (ticketKeys != null) ctx.setTicketKeys(ticketKeys); return ctx; } @@ -602,7 +609,6 @@ const kcheckServerIdentity = Symbol("kcheckServerIdentity"); const ksession = Symbol("ksession"); const kservername = Symbol("kservername"); const krenegotiationDisabled = Symbol("renegotiationDisabled"); -const kcontexts = Symbol("kcontexts"); const buntls = Symbol.for("::buntls::"); // net.ts's SNI dispatch uses this to recognize a raw native SecureContext @@ -611,6 +617,7 @@ const buntls = Symbol.for("::buntls::"); const kNativeSecureContextCtor = Symbol.for("::buntlsnativesecurecontextctor::"); function TLSSocket(socket?, options?) { + this[kAcceptedTLS] = null; this[ksecureContext] = undefined; this.ALPNProtocols = undefined; this[kcheckServerIdentity] = undefined; @@ -633,15 +640,14 @@ function TLSSocket(socket?, options?) { this[krenegotiationDisabled] = undefined; this.encrypted = true; - const isNetSocketOrDuplex = socket instanceof Duplex; - - // A provided underlying socket must be a Duplex/net.Socket. An event emitter - // that isn't a stream (e.g. a bare EventEmitter) is not a valid socket — Node - // throws when wrapping it. Distinguished from a TLS options object, which is - // not an EventEmitter. - if (socket != null && !isNetSocketOrDuplex && socket instanceof EventEmitter) { + const isDuplex = socket instanceof Duplex; + // The first argument can be the options object instead of a stream. + const isOtherStream = !isDuplex && !!socket && hasStreamMethods(socket); + // Node throws when it tries to wrap something that isn't a stream. + if (!isDuplex && !isOtherStream && socket && (options !== undefined || socket instanceof EventEmitter)) { throw $ERR_INVALID_ARG_TYPE("socket", "Duplex", socket); } + const isNetSocketOrDuplex = isDuplex || isOtherStream; // A wrapped socket keeps its allowHalfOpen and drops onread: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L592-L596 if (isNetSocketOrDuplex) { @@ -649,19 +655,19 @@ function TLSSocket(socket?, options?) { } else { options = options || socket || {}; const wrapped = options.socket; - if (wrapped instanceof Duplex) { + if (wrapped instanceof Duplex || (wrapped && hasStreamMethods(wrapped))) { options = { ...options, allowHalfOpen: wrapped.allowHalfOpen, onread: null }; } } this._rejectUnauthorized = !!options.rejectUnauthorized; - // Never forward readable / writable: node's TLSSocket builds its own net.Socket options. https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L590-L600 + // Never forward readable / writable / typeOfService: node's TLSSocket builds its own net.Socket options. https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L590-L600 NetSocket.$call( this, - options.readable === undefined && options.writable === undefined + options.readable === undefined && options.writable === undefined && options.typeOfService === undefined ? options - : { ...options, readable: undefined, writable: undefined }, + : { ...options, readable: undefined, writable: undefined, typeOfService: undefined }, ); // Node's _init installs this as the first 'error' listener and removes it in @@ -915,7 +921,8 @@ TLSSocket.prototype.setKeyCert = function setKeyCert(context) { // from ALPNCallback/SNICallback before the certificate is sent). Accepts a // SecureContext or the same options object createSecureContext takes. const ctx = context?.context ? context : new InternalSecureContext(context, true); - this._handle?.setKeyCert?.(ctx.context); + // https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_tls.cc#L1679 + if (this.isServer) this._handle?.setKeyCert?.(ctx.context); }; TLSSocket.prototype.exportKeyingMaterial = function exportKeyingMaterial(length, label, context) { @@ -1028,7 +1035,7 @@ TLSSocket.prototype[buntls] = function (port, host) { // when host is not an IP. For IP hosts, pass "" so the native layer skips SNI instead of // falling back to the connection host. let servername = this.servername || this[kservername] || ctx?.servername; - if (servername === undefined) { + if (servername == null) { servername = host && !net.isIP(host) ? host : ""; } return { @@ -1064,6 +1071,7 @@ function buildSharedCreds(server, options) { secureOptions: server.secureOptions, allowPartialTrustChain: server.allowPartialTrustChain, sessionTimeout: server.sessionTimeout, + ticketKeys: server.ticketKeys, sigalgs: server.sigalgs, ecdhCurve: server.ecdhCurve ?? DEFAULT_ECDH_CURVE, passphrase: server.passphrase, @@ -1078,7 +1086,7 @@ function buildSharedCreds(server, options) { // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1599-L1611 function SNICallback(servername, callback) { - callback(null, selectServerName(this.server[kcontexts], servername)); + callback(null, selectServerName(this.server[kSNIContexts], servername)); } function Server(options, secureConnectionListener): void { @@ -1123,6 +1131,7 @@ function Server(options, secureConnectionListener): void { this.crl = undefined; this.allowPartialTrustChain = undefined; this.sessionTimeout = undefined; + this.ticketKeys = undefined; this.sigalgs = undefined; this.ecdhCurve = undefined; this.passphrase = undefined; @@ -1142,7 +1151,7 @@ function Server(options, secureConnectionListener): void { if (serverOptions?.ALPNProtocols) convertALPNProtocols(serverOptions.ALPNProtocols, this); this._sharedCreds = undefined; - const contexts = (this[kcontexts] = new Map>()); + const contexts = (this[kSNIContexts] = new Map>()); this.addContext = function (hostname, context) { // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1571-L1574 @@ -1175,20 +1184,20 @@ function Server(options, secureConnectionListener): void { options = options.context; } if (options) { - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1456: falsy counts as absent - const { minVersion, maxVersion, secureProtocol } = options; - if ( - (minVersion != null && !minVersion) || - (maxVersion != null && !maxVersion) || - (secureProtocol != null && !secureProtocol) - ) { - options = { - ...options, - minVersion: minVersion || undefined, - maxVersion: maxVersion || undefined, - secureProtocol: secureProtocol || undefined, - }; - } + // A falsy value counts as absent: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1423-L1518 + options = { + ...options, + pfx: options.pfx || undefined, + passphrase: options.passphrase || undefined, + clientCertEngine: options.clientCertEngine || undefined, + minVersion: options.minVersion || undefined, + maxVersion: options.maxVersion || undefined, + secureProtocol: options.secureProtocol || undefined, + ciphers: options.ciphers || undefined, + sessionTimeout: options.sessionTimeout || undefined, + ticketKeys: options.ticketKeys || undefined, + servername: options.servername || undefined, + }; validateSecureContextOptions(options); options = processPfxOptions(options); @@ -1233,6 +1242,9 @@ function Server(options, secureConnectionListener): void { next.sessionTimeout = options.sessionTimeout; + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1482-L1483: omitting it keeps the previous keys + next.ticketKeys = options.ticketKeys || this.ticketKeys; + const sigalgs = options.sigalgs; if (sigalgs !== undefined && sigalgs !== null) { validateString(sigalgs, "options.sigalgs"); @@ -1285,7 +1297,7 @@ function Server(options, secureConnectionListener): void { } const isContext = serverTLSOptions instanceof InternalSecureContext; // Reads the staged fields over the server's own. Throws on material BoringSSL rejects, so it runs before the fields change. - const own = buildSharedCreds({ __proto__: this, ...next }, isContext ? undefined : serverTLSOptions); + const own = buildSharedCreds({ __proto__: this, ...next }, isContext ? undefined : options); // Accepted sockets take their verify mode from the context, so the listener's is always this server's own. An instance names no key, as in Node. listenerContext = own.context; const handle = this._handle; @@ -1299,6 +1311,7 @@ function Server(options, secureConnectionListener): void { this.crl = next.crl; this.allowPartialTrustChain = next.allowPartialTrustChain; this.sessionTimeout = next.sessionTimeout; + this.ticketKeys = next.ticketKeys; this.sigalgs = next.sigalgs; this.ecdhCurve = next.ecdhCurve; this.passphrase = next.passphrase; @@ -1367,12 +1380,12 @@ function Server(options, secureConnectionListener): void { this._handshakeTimeout = handshakeTimeout; this.on("connection", socket => { - // Skip only sockets this server's own native accept path already wrapped - // (those arrive as an encrypted TLSSocket with .server preassigned). - // Anything else - plain or TLS from another server - gets a server-side - // TLS layer, like Node's tls.Server wraps any injected duplex - // (node v26.3.0 lib/_tls_wrap.js, Server's connection listener). - if (!socket || (socket.encrypted && socket.server === this)) return; + if (!socket) return; + // Skip only the accept path's own emit. A TLSSocket emitted again gets a TLS session inside it, like in Node. + if (socket[kAcceptedTLS] === this) { + socket[kAcceptedTLS] = null; + return; + } const wrapped = new TLSSocket(socket, { secureContext: this._sharedCreds, isServer: true, @@ -1402,18 +1415,18 @@ $toClass(Server, "Server", NetServer); // wrapper and the unwrapped native context). Server.prototype[kNativeSecureContextCtor] = NativeSecureContext; -Server.prototype.getTicketKeys = function () { - throw Error("Not implented in Bun yet"); +Server.prototype.getTicketKeys = function getTicketKeys() { + return this._sharedCreds.context.getTicketKeys(); }; -Server.prototype.setTicketKeys = function (keys) { - if (!ArrayBuffer.isView(keys)) { +Server.prototype.setTicketKeys = function setTicketKeys(keys) { + if (!isArrayBufferView(keys)) { throw $ERR_INVALID_ARG_TYPE("buffer", ["Buffer", "TypedArray", "DataView"], keys); } if (keys.byteLength !== 48) { - throw $ERR_INVALID_ARG_VALUE("buffer", keys, "Session ticket keys must be a 48-byte buffer"); + throw $ERR_INTERNAL_ASSERTION("Session ticket keys must be a 48-byte buffer" + kInternalAssertionSuffix); } - throw Error("Not implented in Bun yet"); + this._sharedCreds.context.setTicketKeys(keys); }; function createServer(options, connectionListener) { @@ -1478,6 +1491,11 @@ function connect(...args) { validateFunction(options.checkServerIdentity, "options.checkServerIdentity"); } + // BoringSSL has no finite-field DHE ciphers, so there's never a DH key to compare against this. + if (ObjectPrototypeHasOwnProperty.$call(options, "minDHSize")) { + validateNumber(options.minDHSize, "options.minDHSize", 1); + } + if (servername && net.isIP(servername)) { throw $ERR_INVALID_ARG_VALUE( "options.servername", @@ -1501,6 +1519,12 @@ function connect(...args) { convertALPNProtocols(ALPNProtocols, connectOptions); } + // win-ca, mac-ca and syswide-cas replace tls.createSecureContext to add CAs. Skip the built-in one so TLSSocket reuses its cached SSL_CTX. + const createContext = tlsExports.createSecureContext; + if (createContext !== createSecureContext && !connectOptions.secureContext) { + connectOptions.secureContext = createContext(connectOptions); + } + const tlssock = new TLSSocket(connectOptions); // A client socket is the caller's from the start, so its errors are theirs // to handle: node releases control before the connection is even started. @@ -1583,6 +1607,7 @@ function cacheBundledRootCertificates(): string[] { bundledRootCertificates ||= getBundledRootCertificates() as string[]; return bundledRootCertificates; } +const getUseOpensslCA = $bindgen("node_tls_binding.bind.ts", "getUseOpensslCA"); const getUseSystemCA = $bindgen("node_tls_binding.bind.ts", "getUseSystemCA"); let defaultCACertificates: string[] | undefined; @@ -1590,16 +1615,18 @@ function cacheDefaultCACertificates() { if (defaultCACertificates) return defaultCACertificates; defaultCACertificates = []; - const bundled = cacheBundledRootCertificates(); - for (let i = 0; i < bundled.length; ++i) { - ArrayPrototypePush.$call(defaultCACertificates, bundled[i]); - } + if (!getUseOpensslCA()) { + const bundled = cacheBundledRootCertificates(); + for (let i = 0; i < bundled.length; ++i) { + ArrayPrototypePush.$call(defaultCACertificates, bundled[i]); + } - // Include system certificates when --use-system-ca is set or NODE_USE_SYSTEM_CA=1 - if (getUseSystemCA() || process.env.NODE_USE_SYSTEM_CA === "1") { - const system = cacheSystemCACertificates(); - for (let i = 0; i < system.length; ++i) { - ArrayPrototypePush.$call(defaultCACertificates, system[i]); + // Include system certificates when --use-system-ca is set or NODE_USE_SYSTEM_CA=1 + if (getUseSystemCA() || process.env.NODE_USE_SYSTEM_CA === "1") { + const system = cacheSystemCACertificates(); + for (let i = 0; i < system.length; ++i) { + ArrayPrototypePush.$call(defaultCACertificates, system[i]); + } } } @@ -1641,7 +1668,7 @@ type CACertInput = string | NodeJS.ArrayBufferView; // name as "of type Array", so build the error directly to match Node here), // then hands the certs to the native root store. Bun has no equivalent native // store override, so keep a JS-side override that getCACertificates('default') -// and createSecureContext() read. +// and createSecureContext() read, and a native copy for fetch and WebSocket. function setDefaultCACertificates(certs: ReadonlyArray): void { if (!$isArray(certs)) { let received: string; @@ -1668,6 +1695,7 @@ function setDefaultCACertificates(certs: ReadonlyArray): void { throw $ERR_CRYPTO_OPERATION_FAILED("No valid certificates found in the provided array"); } _defaultCACertificatesOverride = normalized; + setTLSDefaultCA(normalized); } function getCACertificates(type = "default") { diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 869f8eb4b873..f2bae7455132 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4334,6 +4334,16 @@ impl VirtualMachine { .and_then(RareData::tls_default_ciphers) } + /// Returns the `tls.setDefaultCACertificates()` list, or `None` if it was never called. + pub fn tls_default_ca(&self) -> Option<&[Box<[u8]>]> { + self.rare_data.as_deref().and_then(RareData::tls_default_ca) + } + + /// Returns true if `tls.DEFAULT_CIPHERS` or `tls.setDefaultCACertificates()` overrides what a client with no TLS options uses. + pub fn tls_client_defaults_set(&self) -> bool { + self.tls_default_ciphers().is_some() || self.tls_default_ca().is_some() + } + /// Registers a spawned subprocess with the auto-killer. pub fn on_subprocess_spawn(&mut self, process: core::ptr::NonNull) { self.auto_killer.on_subprocess_spawn(process); @@ -6058,6 +6068,10 @@ impl VirtualMachine { // global makes the next file's call a no-op and dispatches // node:quic events into the dead realm. rare.node_quic_callbacks.deinit(); + // The next file's `node:tls` starts with no override, so native clients must too. + if rare.tls_default_ca.take().is_some() { + rare.default_client_ssl_ctx = None; + } } let _ = self.event_loop_mut().drain_microtasks(); diff --git a/src/jsc/bindings/NodeTLS.cpp b/src/jsc/bindings/NodeTLS.cpp index e9098ab7f62a..a397304d0319 100644 --- a/src/jsc/bindings/NodeTLS.cpp +++ b/src/jsc/bindings/NodeTLS.cpp @@ -311,6 +311,7 @@ JSC_DEFINE_HOST_FUNCTION(parseCACertificates, (JSC::JSGlobalObject * globalObjec // win-x64; BUN_DECLARE_HOST_FUNCTION carries SYSV_ABI so both sides agree. BUN_DECLARE_HOST_FUNCTION(Bun__getTLSDefaultCiphers); BUN_DECLARE_HOST_FUNCTION(Bun__setTLSDefaultCiphers); +BUN_DECLARE_HOST_FUNCTION(Bun__setTLSDefaultCA); JSC_DEFINE_HOST_FUNCTION(getDefaultCiphers, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { @@ -322,6 +323,11 @@ JSC_DEFINE_HOST_FUNCTION(setDefaultCiphers, (JSC::JSGlobalObject * globalObject, return Bun__setTLSDefaultCiphers(globalObject, callFrame); } +JSC_DEFINE_HOST_FUNCTION(setDefaultCA, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + return Bun__setTLSDefaultCA(globalObject, callFrame); +} + // (names: Map, servername: string) => T | undefined, matched by the tree a listener keeps its names in. JSC_DEFINE_HOST_FUNCTION(selectServerName, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { diff --git a/src/jsc/bindings/NodeTLS.h b/src/jsc/bindings/NodeTLS.h index 9fe8c22a72d0..1f64c23cf3a8 100644 --- a/src/jsc/bindings/NodeTLS.h +++ b/src/jsc/bindings/NodeTLS.h @@ -11,6 +11,7 @@ JSC_DECLARE_HOST_FUNCTION(getSystemCACertificates); JSC_DECLARE_HOST_FUNCTION(parseCACertificates); JSC_DECLARE_HOST_FUNCTION(getDefaultCiphers); JSC_DECLARE_HOST_FUNCTION(setDefaultCiphers); +JSC_DECLARE_HOST_FUNCTION(setDefaultCA); JSC_DECLARE_HOST_FUNCTION(selectServerName); JSC_DECLARE_HOST_FUNCTION(addBlockedSPKIForTesting); JSC_DECLARE_HOST_FUNCTION(clearBlockedSPKIsForTesting); diff --git a/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp b/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp index 794721a63b45..6e2119223cb2 100644 --- a/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp +++ b/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp @@ -27,6 +27,7 @@ extern "C" uint64_t uws_res_get_local_address_info(void* res, const char** dest, extern "C" EncodedJSValue us_socket_buffered_js_write(void* socket, bool is_ssl, bool ended, bool hold, bool flushesBufferOnDrain, us_socket_stream_buffer_t* streamBuffer, JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue data, JSC::EncodedJSValue encoding); extern "C" int us_socket_is_ssl_handshake_finished(struct us_socket_t* s); extern "C" int us_socket_ssl_handshake_callback_has_fired(struct us_socket_t* s); +extern "C" void* Bun__ServerNameAnswer__fromJS(JSC::EncodedJSValue result, int* abortHandshake); namespace Bun { @@ -1135,6 +1136,41 @@ extern "C" JSC::EncodedJSValue Bun__getOrCreateNodeHTTPServerSocket(bool isSSL, return JSValue::encode(JSC::jsNull()); } +// SNICallback: returns the SSL_CTX to serve `hostname` with on this handshake. Same contract as us_dispatch_server_name in Listener.rs. +extern "C" void* Bun__NodeHTTP__onServerName(us_socket_t* us_socket, const char* hostname, int* abortHandshake) +{ + auto* serverSocket = getNodeHTTPServerSocket(us_socket); + if (!serverSocket) { + return nullptr; + } + auto* globalObject = defaultGlobalObject(serverSocket->globalObject()); + auto& vm = JSC::getVM(globalObject); + JSValue callback = serverSocket->getDirect(vm, Identifier::fromString(vm, "onservername"_s)); + if (!callback || !callback.isCallable()) { + return nullptr; + } + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + MarkedArgumentBuffer args; + args.append(jsString(vm, WTF::String::fromLatin1(hostname))); + args.append(serverSocket); + JSValue result = AsyncContextFrame::call(globalObject, callback, jsUndefined(), args); + if (auto* exception = scope.exception()) { + (void)scope.tryClearException(); + result = exception->value(); + } + return Bun__ServerNameAnswer__fromJS(JSValue::encode(result), abortHandshake); +} + +// At the end of a TLS handshake: the socket attached at accept. Empty if the handshake failed or nothing is attached. +extern "C" JSC::EncodedJSValue Bun__NodeHTTP__securedServerSocket(us_socket_t* us_socket) +{ + auto* serverSocket = getNodeHTTPServerSocket(us_socket); + if (!serverSocket || !serverSocket->isAuthorized()) { + return {}; + } + return JSValue::encode(serverSocket); +} + JSC::Structure* createNodeHTTPServerSocketStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject) { return JSNodeHTTPServerSocket::createStructure(vm, globalObject); diff --git a/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp b/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp index 13546415dab7..69551d7f979b 100644 --- a/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp +++ b/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp @@ -14,6 +14,20 @@ extern "C" uint64_t uws_res_get_remote_address_info(void* res, const char** dest extern "C" uint64_t uws_res_get_local_address_info(void* res, const char** dest, int* port, bool* is_ipv6); extern "C" void us_socket_shutdown(us_socket_t*); extern "C" JSC::EncodedJSValue Bun__socketReadErrorFromCloseCode(JSC::JSGlobalObject* globalObject, int code); +extern "C" void us_socket_sni_resolve(us_socket_t*, struct ssl_ctx_st*, int error); +extern "C" void* us_socket_get_native_handle(us_socket_t*); +// tls_socket_functions.rs +BUN_DECLARE_HOST_FUNCTION(Bun__NodeHTTPServerSocket__getPeerCertificate); +BUN_DECLARE_HOST_FUNCTION(Bun__NodeHTTPServerSocket__getCertificate); +BUN_DECLARE_HOST_FUNCTION(Bun__NodeHTTPServerSocket__getX509Certificate); +BUN_DECLARE_HOST_FUNCTION(Bun__NodeHTTPServerSocket__getCipher); +BUN_DECLARE_HOST_FUNCTION(Bun__NodeHTTPServerSocket__getTLSVersion); +BUN_DECLARE_HOST_FUNCTION(Bun__NodeHTTPServerSocket__getEphemeralKeyInfo); +BUN_DECLARE_HOST_FUNCTION(Bun__NodeHTTPServerSocket__getTLSFinishedMessage); +BUN_DECLARE_HOST_FUNCTION(Bun__NodeHTTPServerSocket__getTLSPeerFinishedMessage); +BUN_DECLARE_HOST_FUNCTION(Bun__NodeHTTPServerSocket__isSessionReused); +BUN_DECLARE_HOST_FUNCTION(Bun__NodeHTTPServerSocket__exportKeyingMaterial); +extern "C" void* Bun__ServerNameAnswer__fromJS(JSC::EncodedJSValue result, int* abortHandshake); namespace Bun { @@ -52,6 +66,7 @@ JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketStartPipelinedResponse); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketStopParsing); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketCloseWhenDrained); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketCloseIfIdle); +JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketResumeSNI); JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterResponse); JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterRemoteAddress); JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterLocalAddress); @@ -98,6 +113,17 @@ static const JSC::HashTableValue JSNodeHTTPServerSocketPrototypeTableValues[] = { "stopParsing"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketStopParsing, 0 } }, { "closeWhenDrained"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketCloseWhenDrained, 0 } }, { "closeIfIdle"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketCloseIfIdle, 0 } }, + { "resumeSNI"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketResumeSNI, 2 } }, + { "getPeerCertificate"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, Bun__NodeHTTPServerSocket__getPeerCertificate, 1 } }, + { "getCertificate"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, Bun__NodeHTTPServerSocket__getCertificate, 0 } }, + { "getX509Certificate"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, Bun__NodeHTTPServerSocket__getX509Certificate, 0 } }, + { "getCipher"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, Bun__NodeHTTPServerSocket__getCipher, 0 } }, + { "getTLSVersion"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, Bun__NodeHTTPServerSocket__getTLSVersion, 0 } }, + { "getEphemeralKeyInfo"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, Bun__NodeHTTPServerSocket__getEphemeralKeyInfo, 0 } }, + { "getTLSFinishedMessage"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, Bun__NodeHTTPServerSocket__getTLSFinishedMessage, 0 } }, + { "getTLSPeerFinishedMessage"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, Bun__NodeHTTPServerSocket__getTLSPeerFinishedMessage, 0 } }, + { "isSessionReused"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, Bun__NodeHTTPServerSocket__isSessionReused, 0 } }, + { "exportKeyingMaterial"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, Bun__NodeHTTPServerSocket__exportKeyingMaterial, 3 } }, { "secureEstablished"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterIsSecureEstablished, noOpSetter } }, { "servername"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterServername, noOpSetter } }, { "authorizationError"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterAuthorizationError, noOpSetter } }, @@ -156,6 +182,29 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketUpgradeToTunnel, (JSC::JS return JSValue::encode(JSC::jsUndefined()); } +// The SSL* the methods in tls_socket_functions.rs read. Null unless `value` is the handle of an open TLS connection. +extern "C" void* Bun__NodeHTTPServerSocket__ssl(JSC::EncodedJSValue value) +{ + auto* thisObject = dynamicDowncast(JSValue::decode(value)); + if (!thisObject || !thisObject->is_ssl || thisObject->isClosed()) { + return nullptr; + } + return us_socket_get_native_handle(thisObject->socket); +} + +// (secureContext, isError): resumes the handshake that an asynchronous SNICallback suspended. +JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketResumeSNI, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + auto* thisObject = dynamicDowncast(callFrame->thisValue()); + if (!thisObject || !thisObject->is_ssl || thisObject->isClosed()) [[unlikely]] { + return JSValue::encode(JSC::jsUndefined()); + } + int abortHandshake = callFrame->argument(1).toBoolean(globalObject); + void* sslCtx = abortHandshake ? nullptr : Bun__ServerNameAnswer__fromJS(JSValue::encode(callFrame->argument(0)), &abortHandshake); + us_socket_sni_resolve(thisObject->socket, static_cast(sslCtx), abortHandshake); + return JSValue::encode(JSC::jsUndefined()); +} + JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketReadStop, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { if (auto* thisObject = dynamicDowncast(callFrame->thisValue())) [[likely]] { diff --git a/src/jsc/rare_data.rs b/src/jsc/rare_data.rs index 9cb834e56697..19fa36db9c3d 100644 --- a/src/jsc/rare_data.rs +++ b/src/jsc/rare_data.rs @@ -273,6 +273,9 @@ pub struct RareData { /// [`Self::tls_default_ciphers`] strips it. pub(crate) tls_default_ciphers: Option>, + /// PEM blocks given to `tls.setDefaultCACertificates`, for clients built natively. + pub(crate) tls_default_ca: Option]>>, + // proxy_env_storage moved to VirtualMachine — see comment there on why // lazy RareData creation raced with worker spawn. spawn_sync_event_loop_: Option>, @@ -313,6 +316,7 @@ impl Default for RareData { node_quic_callbacks: Strong::empty(), default_csrf_secret: Box::default(), tls_default_ciphers: None, + tls_default_ca: None, spawn_sync_event_loop_: None, path_buf: PathBuf::default(), } @@ -827,6 +831,17 @@ impl RareData { self.default_client_ssl_ctx = None; } + pub(crate) fn tls_default_ca(&self) -> Option<&[Box<[u8]>]> { + self.tls_default_ca.as_deref() + } + + /// An empty list trusts nothing, as in Node. + pub(crate) fn set_tls_default_ca(&mut self, certs: Vec>) { + self.tls_default_ca = Some(certs.into_boxed_slice()); + // Built from the previous list. Open connections hold their own reference. + self.default_client_ssl_ctx = None; + } + pub fn push_cleanup_hook( &mut self, global_this: &JSGlobalObject, @@ -1102,6 +1117,33 @@ fn set_tls_default_ciphers_from_js( Ok(JSValue::UNDEFINED) } +#[crate::host_fn(export = "Bun__setTLSDefaultCA")] +fn set_tls_default_ca_from_js( + global_this: &JSGlobalObject, + callframe: &CallFrame, +) -> JsResult { + let args = callframe.arguments(); + let list = if !args.is_empty() { + args[0] + } else { + JSValue::UNDEFINED + }; + if !list.is_array() { + return Err(global_this.throw_invalid_argument_type_value(b"certs", b"array", list)); + } + let mut certs = Vec::new(); + let mut iter = list.array_iterator(global_this)?; + while let Some(cert) = iter.next()? { + certs.push(Box::<[u8]>::from(cert.to_utf8(global_this)?.slice())); + } + global_this + .bun_vm() + .as_mut() + .rare_data() + .set_tls_default_ca(certs); + Ok(JSValue::UNDEFINED) +} + #[crate::host_fn(export = "Bun__getTLSDefaultCiphers")] fn get_tls_default_ciphers_from_js( global_this: &JSGlobalObject, diff --git a/src/libuv_sys/libuv.rs b/src/libuv_sys/libuv.rs index 19eaf5b60d6d..bff920eba4ed 100644 --- a/src/libuv_sys/libuv.rs +++ b/src/libuv_sys/libuv.rs @@ -2269,6 +2269,7 @@ unsafe impl bun_core::ffi::Zeroable for uv_buf_t {} unsafe impl bun_core::ffi::Zeroable for uv_req_t {} unsafe impl bun_core::ffi::Zeroable for uv_write_t {} unsafe impl bun_core::ffi::Zeroable for uv_connect_t {} +unsafe impl bun_core::ffi::Zeroable for uv_shutdown_t {} unsafe impl bun_core::ffi::Zeroable for Handle {} unsafe impl bun_core::ffi::Zeroable for Timer {} unsafe impl bun_core::ffi::Zeroable for Pipe {} @@ -2679,6 +2680,11 @@ unsafe extern "C" { read_cb: uv_read_cb, ) -> ReturnCode; pub fn uv_read_stop(stream: *mut uv_stream_t) -> ReturnCode; + pub fn uv_shutdown( + req: *mut uv_shutdown_t, + handle: *mut uv_stream_t, + cb: uv_shutdown_cb, + ) -> ReturnCode; pub fn uv_write( req: *mut uv_write_t, handle: *mut uv_stream_t, diff --git a/src/runtime/api/SecureContext.classes.ts b/src/runtime/api/SecureContext.classes.ts index 3acd3baf1988..26ebfbf5c268 100644 --- a/src/runtime/api/SecureContext.classes.ts +++ b/src/runtime/api/SecureContext.classes.ts @@ -35,6 +35,14 @@ export default [ fn: "add_ca_cert", length: 1, }, + getTicketKeys: { + fn: "get_ticket_keys", + length: 0, + }, + setTicketKeys: { + fn: "set_ticket_keys", + length: 1, + }, }, }), ]; diff --git a/src/runtime/api/bun/SecureContext.rs b/src/runtime/api/bun/SecureContext.rs index 7a5011a64578..e32b2ee3250b 100644 --- a/src/runtime/api/bun/SecureContext.rs +++ b/src/runtime/api/bun/SecureContext.rs @@ -76,7 +76,9 @@ impl SecureContext { // SAFETY: `bun_vm()` returns the live per-global VM pointer; valid for the call. let vm = global.bun_vm().as_mut(); // Only a server reads `rejectUnauthorized` back from a context (`server_ctx_rejects_unauthorized`). - let config = SSLConfig::from_js(vm, global, opts, true)?.unwrap_or_else(SSLConfig::zero); + let mut config = + SSLConfig::from_js(vm, global, opts, true)?.unwrap_or_else(SSLConfig::zero); + crate::socket::apply_empty_default_ca(vm, &mut config); // `defer config.deinit()` — handled by Drop. SecureContext::create(global, &config) @@ -235,7 +237,9 @@ impl SecureContext { // SAFETY: `bun_vm()` returns the live per-global VM pointer; valid for the call. let vm = global.bun_vm().as_mut(); // Only a server reads `rejectUnauthorized` back from a context (`server_ctx_rejects_unauthorized`). - let config = SSLConfig::from_js(vm, global, opts, true)?.unwrap_or_else(SSLConfig::zero); + let mut config = + SSLConfig::from_js(vm, global, opts, true)?.unwrap_or_else(SSLConfig::zero); + crate::socket::apply_empty_default_ca(vm, &mut config); // `defer config.deinit()` — handled by Drop. let ctx_opts = config.as_usockets(); @@ -276,7 +280,9 @@ impl SecureContext { // SAFETY: `bun_vm()` returns the live per-global VM pointer; valid for the call. let vm = global.bun_vm().as_mut(); // Only a server reads `rejectUnauthorized` back from a context (`server_ctx_rejects_unauthorized`). - let config = SSLConfig::from_js(vm, global, opts, true)?.unwrap_or_else(SSLConfig::zero); + let mut config = + SSLConfig::from_js(vm, global, opts, true)?.unwrap_or_else(SSLConfig::zero); + crate::socket::apply_empty_default_ca(vm, &mut config); // `defer config.deinit()` — handled by Drop. let ctx_opts = config.as_usockets(); @@ -435,12 +441,77 @@ impl SecureContext { Ok(JSValue::UNDEFINED) } + /// `secureContext.context.getTicketKeys()`, for `tls.Server#getTicketKeys()`. + #[bun_jsc::host_fn(method)] + pub(crate) fn get_ticket_keys( + this: &Self, + global: &JSGlobalObject, + _frame: &CallFrame, + ) -> JsResult { + let mut keys = [0u8; TICKET_KEYS_LEN]; + // SAFETY: `this.ctx` is live and `keys` has the length BoringSSL requires. + let ok = unsafe { + boringssl::SSL_CTX_get_tlsext_ticket_keys( + this.ctx.as_ptr(), + keys.as_mut_ptr().cast(), + keys.len(), + ) + }; + // Setting the keys stops BoringSSL's 48-hour rotation, so a copy given to another server stays valid as in Node. + if ok != 1 || (!this.shared && !this.apply_ticket_keys(&keys)) { + return Err(global.throw(format_args!("Failed to get the session ticket keys"))); + } + bun_jsc::ArrayBuffer::create_buffer(global, &keys) + } + + /// `secureContext.context.setTicketKeys(keys)`, for `tls.Server#setTicketKeys()` and the `ticketKeys` option. + #[bun_jsc::host_fn(method)] + pub(crate) fn set_ticket_keys( + this: &Self, + global: &JSGlobalObject, + frame: &CallFrame, + ) -> JsResult { + if this.shared { + return Err(global.throw(format_args!( + "cannot mutate a shared SecureContext; use tls.createSecureContext()" + ))); + } + let keys = frame.argument(0).as_array_buffer(global); + let Some(keys) = keys.as_ref().map(|keys| keys.byte_slice()) else { + return Err(global.throw_invalid_arguments(format_args!( + "setTicketKeys requires a Buffer, TypedArray, or DataView" + ))); + }; + if keys.len() != TICKET_KEYS_LEN { + return Err(global.throw_invalid_arguments(format_args!( + "Session ticket keys must be a 48-byte buffer" + ))); + } + if !this.apply_ticket_keys(keys) { + return Err(global.throw(format_args!("Failed to set the session ticket keys"))); + } + Ok(JSValue::TRUE) + } + + fn apply_ticket_keys(&self, keys: &[u8]) -> bool { + // SAFETY: `self.ctx` is live; the call copies `keys` and rejects any length but 48. + unsafe { + boringssl::SSL_CTX_set_tlsext_ticket_keys( + self.ctx.as_ptr(), + keys.as_ptr().cast(), + keys.len(), + ) == 1 + } + } + pub(crate) fn memory_cost(&self) -> usize { core::mem::size_of::() + self.extra_memory } } const SSL_CTX_BASE_COST: usize = 50 * 1024; +/// A 16-byte name, a 16-byte HMAC key and a 16-byte AES key. +const TICKET_KEYS_LEN: usize = 48; use bun_uws_sys::socket_context::c; diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index d2a105468ec6..dea6f1a3c910 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -315,6 +315,9 @@ const RUNTIME_PARAMS_: &[ParamType] = &[ parse_param!("--trace-exit"), parse_param!("--expose-internals"), parse_param!("--stack-trace-limit "), + // node:net reads these two from `process.execArgv`. + parse_param!("--network-family-autoselection-attempt-timeout "), + parse_param!("--tls-keylog "), ]; const AUTO_OR_RUN_PARAMS: &[ParamType] = &[ @@ -764,7 +767,11 @@ fn replace_pid_placeholder(name: &[u8]) -> Box<[u8]> { out.into_boxed_slice() } -use bun_core::Global::Bun__Node__UseSystemCA; +use bun_core::Global::{Bun__Node__UseOpenSSLCA, Bun__Node__UseSystemCA}; + +pub(crate) fn get_use_openssl_ca() -> bool { + Bun__Node__UseOpenSSLCA.load(core::sync::atomic::Ordering::Relaxed) +} pub(crate) fn get_use_system_ca() -> bool { Bun__Node__UseSystemCA.load(core::sync::atomic::Ordering::Relaxed) @@ -1277,6 +1284,10 @@ pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result Box<[ResultEntry]> { let mut count: usize = 0; let mut n_first: usize = 0; - let first_family = if info.is_null() { - netc::AF_UNSPEC - } else { + let first_family = match Order::for_connect() { + Order::Ipv4first => netc::AF_INET, + Order::Ipv6first => netc::AF_INET6, + Order::Verbatim if info.is_null() => netc::AF_UNSPEC, // SAFETY: info is a live addrinfo node; freed by caller after we return. - unsafe { (*info).ai_family } + Order::Verbatim => unsafe { (*info).ai_family }, }; let mut info_: *mut AddrInfo = info; while !info_.is_null() { @@ -3211,6 +3212,32 @@ pub(crate) mod internal { } } + /// Invalidates every cache entry too: `process_results` packed them, or is about to, in the old order. + pub(crate) fn set_connect_order(order: Order) { + if Order::for_connect() == order { + return; + } + Order::set_for_connect(order); + + let mut guard = global_cache().lock(); + let mut i: usize = 0; + while i < guard.len { + let entry = guard.cache[i]; + // SAFETY: entries 0..len are valid heap Requests, only mutated under `global_cache().lock()`, held here. + unsafe { + (*entry).valid = false; + if (*entry).result.is_some() && (*entry).refcount == 0 { + // Swaps the last entry into slot `i`. + let len = guard.len; + let _ = guard.delete_entry_at(len, i); + Request::deinit(entry); + continue; + } + } + i += 1; + } + } + extern "C" fn get_request_result(req: *mut Request) -> *mut RequestResult { // SAFETY: caller (usockets) only invokes this after notify, when result is set unsafe { std::ptr::from_mut::((*req).result.as_mut().unwrap()) } @@ -5894,6 +5921,12 @@ impl Resolver { }; bun::String::static_(<&'static str>::from(order)) } + + pub(crate) fn set_connect_result_order(order: &[u8]) { + if let Some(order) = Order::from_string(order) { + internal::set_connect_order(order); + } + } } // ───────── JS host-fn FFI exports ───────── diff --git a/src/runtime/dns_jsc/dns_internals.bind.ts b/src/runtime/dns_jsc/dns_internals.bind.ts index 6d074888e254..b50f44622524 100644 --- a/src/runtime/dns_jsc/dns_internals.bind.ts +++ b/src/runtime/dns_jsc/dns_internals.bind.ts @@ -3,6 +3,7 @@ import * as b from "../../codegen/bindgen-lib.ts"; export default b.module({ rustPath: "crate::dns_jsc::Resolver" }); export const getRuntimeDefaultResultOrderOption = b.fn({ returns: b.string }); +export const setConnectResultOrder = b.fn({ args: { order: b.string.utf8 } }); /** A non-string means "A". */ export const RecordTypeArgument = b.union({ label: b.string.utf8, ignored: b.any }); diff --git a/src/runtime/ipc_host.rs b/src/runtime/ipc_host.rs index ca0646ae5805..b66aa7b4a4e4 100644 --- a/src/runtime/ipc_host.rs +++ b/src/runtime/ipc_host.rs @@ -175,10 +175,10 @@ pub(crate) fn do_send( match unsafe { (*listener).listener.get() } { crate::socket::listener::ListenerType::Uws(socket_uws) => { // may need to handle ssl case - // SAFETY: `socket_uws` is a live non-null `*mut ListenSocket` - // owned by uSockets; `get_socket` only reinterpret-casts to - // `&mut us_socket_t` and `get_fd` is a read-only FFI call. - let fd = unsafe { &mut *socket_uws }.get_socket().get_fd(); + // SAFETY: `socket_uws` is a live non-null `*mut ListenSocket` owned by uSockets. + let listen_socket = unsafe { &mut *socket_uws }; + listen_socket.set_shared(); + let fd = listen_socket.get_socket().get_fd(); #[cfg(not(windows))] match Handle::init_dup(fd, handle, false) { Ok(h) => zig_handle = Some(h), diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 920f9c462944..b59534d5a5ea 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -260,7 +260,7 @@ pub(crate) fn default_client_ssl_ctx(vm: &VirtualMachine) -> *mut bun_uws::SslCt // weak cache so a `tls.connect()` with default options later resolves // to the same CTX rather than building a second one with the same // digest. The +1 ref returned here is held, so the entry never tombstones. - let ctx = if vm.tls_default_ciphers().is_some() { + let ctx = if vm.tls_client_defaults_set() { cache.get_or_create(&crate::socket::tls_true_defaults(vm, false), &mut err) } else { cache.get_or_create_opts(&Default::default(), &mut err) diff --git a/src/runtime/node/node_cluster_binding.rs b/src/runtime/node/node_cluster_binding.rs index 0fff06cb24f8..4f52ee2aedb5 100644 --- a/src/runtime/node/node_cluster_binding.rs +++ b/src/runtime/node/node_cluster_binding.rs @@ -446,6 +446,10 @@ pub(crate) fn cluster_raw_bind(global: &JSGlobalObject, frame: &CallFrame) -> Js if path_bytes.len() >= sun.sun_path.len() { return Ok(JSValue::js_number_from_int32(-(libc::ENAMETOOLONG))); } + let is_abstract = cfg!(target_os = "linux") && path_bytes.first() == Some(&0); + if !is_abstract && bun_core::strings::contains_char(path_bytes, 0) { + return Ok(JSValue::js_number_from_int32(-(libc::EINVAL))); + } for (i, b) in path_bytes.iter().enumerate() { sun.sun_path[i] = *b as _; } diff --git a/src/runtime/node/node_http_binding.bind.ts b/src/runtime/node/node_http_binding.bind.ts index b51fa1555f0c..2b342d91e6a9 100644 --- a/src/runtime/node/node_http_binding.bind.ts +++ b/src/runtime/node/node_http_binding.bind.ts @@ -19,6 +19,19 @@ export const BunServer = b.union({ }); export const getBunServerOpenCount = b.fn({ args: { server: BunServer }, returns: b.f64 }); +export const BunTLSServer = b.union({ + https: b.classRef("HTTPSServer"), + debugHttps: b.classRef("DebugHTTPSServer"), +}); +/** `https.Server#setSecureContext()` while listening. Accepted sockets keep their context. */ +export const setSecureContext = b.fn({ args: { server: BunTLSServer, context: b.classRef("SecureContext") } }); +/** Calls `onservername(hostname, handle)` of each connection's handle during its handshake, for `SNICallback`. */ +export const dispatchServerNames = b.fn({ args: { server: BunTLSServer } }); +/** `https.Server#addContext()` while listening. */ +export const addServerName = b.fn({ + args: { server: BunTLSServer, hostname: b.string.utf8, context: b.classRef("SecureContext") }, +}); + /** `kInternalSocketData` of the socket given to `handleUpgrade()` of `ws`, which can be any `Duplex`. */ export const UpgradeResponse = b.union({ response: NodeHTTPResponse, ignored: b.any }); export const DeflateParams = b.dictionary({ diff --git a/src/runtime/node/node_http_binding.rs b/src/runtime/node/node_http_binding.rs index 6986d8c1230c..32b028aefaf8 100644 --- a/src/runtime/node/node_http_binding.rs +++ b/src/runtime/node/node_http_binding.rs @@ -7,7 +7,10 @@ use bun_jsc::{CallFrame, JSGlobalObject, JSValue, JsResult}; use bun_uws_sys::Opcode; use bun_uws_sys::response::PerMessageDeflateParams; -use crate::generated_bindgen::node_http_binding::{BunServer, DeflateParams, UpgradeResponse}; +use crate::api::bun_secure_context::SecureContext; +use crate::generated_bindgen::node_http_binding::{ + BunServer, BunTLSServer, DeflateParams, UpgradeResponse, +}; use crate::server::{DebugHTTPSServer, DebugHTTPServer, HTTPSServer, HTTPServer, ServerWebSocket}; pub(crate) fn get_bun_server_all_closed_promise( @@ -61,6 +64,38 @@ pub(crate) fn get_bun_server_open_count(server: BunServer<'_>) -> f64 { } } +/// `None` once the server has stopped listening. +fn tls_listen_socket(server: BunTLSServer<'_>) -> Option<&mut bun_uws_sys::ListenSocket> { + let listener = match server { + BunTLSServer::Https(server) => server.listener?.cast::(), + BunTLSServer::DebugHttps(server) => server.listener?.cast::(), + }; + Some(bun_opaque::opaque_deref_mut(listener)) +} + +pub(crate) fn set_secure_context(server: BunTLSServer<'_>, context: &SecureContext) { + if let Some(listener) = tls_listen_socket(server) { + listener.set_default_ssl_ctx(&context.ctx); + } +} + +pub(crate) fn dispatch_server_names(server: BunTLSServer<'_>) { + if let Some(listener) = tls_listen_socket(server) { + listener.on_server_name(crate::socket::listener::us_dispatch_server_name); + } +} + +pub(crate) fn add_server_name(server: BunTLSServer<'_>, hostname: &[u8], context: &SecureContext) { + // No ClientHello can carry a NUL, so this pattern matches nothing, as in Node. + if bun_core::strings::contains_char(hostname, 0) { + return; + } + if let Some(listener) = tls_listen_socket(server) { + let hostname = bun_core::ZBox::from_bytes(hostname); + listener.add_server_name(hostname.as_zstr().as_cstr(), context.ctx.as_ptr()); + } +} + /// `(response, websocket, handlers, handshake, maxPayload, flags, deflate)`: upgrades /// `response`'s connection to a WebSocket and writes `handshake` as the response head. `deflate` is /// the negotiated permessage-deflate parameters, or `undefined`. Returns the `ServerWebSocket`. diff --git a/src/runtime/node/node_net_binding.bind.ts b/src/runtime/node/node_net_binding.bind.ts index f5aa18a902ce..a1e7d9a4164c 100644 --- a/src/runtime/node/node_net_binding.bind.ts +++ b/src/runtime/node/node_net_binding.bind.ts @@ -2,18 +2,4 @@ import * as b from "../../codegen/bindgen-lib.ts"; export default b.module({ rustPath: "crate::node::node_net_binding" }); -export const getDefaultAutoSelectFamily = b.fn({ returns: b.bool }); -export const setDefaultAutoSelectFamily = b.fn({ - args: { value: b.bool }, - expected: { value: ["boolean"] }, - returns: b.bool, -}); - -const INT32_MAX = 2 ** 31 - 1; -export const getDefaultAutoSelectFamilyAttemptTimeout = b.fn({ returns: b.u32 }); -export const setDefaultAutoSelectFamilyAttemptTimeout = b.fn({ - args: { value: b.u32.validateInteger(1, INT32_MAX) }, - returns: b.u32, -}); - export const newDetachedSocket = b.fn({ args: { isSSL: b.bool }, returns: b.any, capability: "js" }); diff --git a/src/runtime/node/node_net_binding.rs b/src/runtime/node/node_net_binding.rs index 214e6a71b8da..6a6c21fbc455 100644 --- a/src/runtime/node/node_net_binding.rs +++ b/src/runtime/node/node_net_binding.rs @@ -2,7 +2,6 @@ // use core::cell::Cell; -use core::sync::atomic::{AtomicBool, Ordering}; use bun_io::KeepAlive; use bun_jsc::bindgen::JsScope; @@ -11,41 +10,6 @@ use bun_uws as uws; use crate::socket::{Listener, NativeCallbacks, NewSocket, SocketFlags, TCPSocket, TLSSocket}; -static AUTO_SELECT_FAMILY_DEFAULT: AtomicBool = AtomicBool::new(true); - -// This is only used to provide the getDefaultAutoSelectFamilyAttemptTimeout and -// setDefaultAutoSelectFamilyAttemptTimeout functions, not currently read by any other code. It's -// `threadlocal` because Node.js expects each Worker to have its own copy of this, and currently -// it can only be accessed by accessor functions which run on each Worker's main JavaScript thread. -// -// If this becomes used in more places, and especially if it can be read by other threads, we may -// need to store it as a field in the VirtualMachine instead of in a `threadlocal`. -thread_local! { - // Node's default is 250ms with a documented floor of 10ms, but the CLI - // default in node_options.h is 500ms; the vendored test/common multiplies - // the default by 5 (upstream) assuming 500. - pub(crate) static AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_DEFAULT: Cell = const { Cell::new(500) }; -} - -pub(crate) fn get_default_auto_select_family() -> bool { - AUTO_SELECT_FAMILY_DEFAULT.load(Ordering::Relaxed) -} - -pub(crate) fn set_default_auto_select_family(value: bool) -> bool { - AUTO_SELECT_FAMILY_DEFAULT.store(value, Ordering::Relaxed); - value -} - -pub(crate) fn get_default_auto_select_family_attempt_timeout() -> u32 { - AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_DEFAULT.get() -} - -pub(crate) fn set_default_auto_select_family_attempt_timeout(value: u32) -> u32 { - let value = value.max(10); - AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_DEFAULT.set(value); - value -} - // codegen (`generated_js2native.rs`) snake-cases the symbol; alias the // PascalCase fns so both spellings resolve. pub(crate) use self::{BlockList as block_list, SocketAddress as socket_address}; @@ -86,6 +50,7 @@ pub(crate) fn new_detached_socket(cx: &mut JsScope<'_>, is_ssl: bool) -> JSValue server_name: JsCell::new(None), buffered_data_for_node_net: Default::default(), bytes_written: Cell::new(0), + bytes_read: Cell::new(0), native_callback: JsCell::new(NativeCallbacks::None), twin: JsCell::new(None), verify_error: JsCell::new(None), diff --git a/src/runtime/node/node_tls_binding.bind.ts b/src/runtime/node/node_tls_binding.bind.ts index 1793f57f7ecd..346edafc3320 100644 --- a/src/runtime/node/node_tls_binding.bind.ts +++ b/src/runtime/node/node_tls_binding.bind.ts @@ -2,4 +2,5 @@ import * as b from "../../codegen/bindgen-lib.ts"; export default b.module({ rustPath: "crate::cli::Arguments" }); +export const getUseOpensslCA = b.fn({ returns: b.bool }); export const getUseSystemCA = b.fn({ returns: b.bool }); diff --git a/src/runtime/node/quic/tls.rs b/src/runtime/node/quic/tls.rs index 3c9add95e18b..46bef08df8f4 100644 --- a/src/runtime/node/quic/tls.rs +++ b/src/runtime/node/quic/tls.rs @@ -453,7 +453,16 @@ impl TlsContext { } } if config.ca_pem.is_empty() { - ssl::SSL_CTX_set_default_verify_paths(ctx); + // `load_crl_store` writes to the context's store, and every context shares the default one. + let store = if config.crl_pem.is_empty() { + us_get_shared_default_ca_store() + } else { + us_get_default_ca_store() + }; + if store.is_null() { + return Err("failed to load the default CA store"); + } + ssl::SSL_CTX_set_cert_store(ctx, store); } // Node pairs `certs[i]` with `keys[i]`. @@ -578,6 +587,10 @@ unsafe extern "C" { fn us_ssl_ctx_set_verify_signature_algorithms(ctx: *mut ssl::SSL_CTX) -> c_int; /// openssl.c: the blocked public keys every other TLS context of Bun rejects in a peer's chain. fn us_ssl_ctx_reject_blocked_keys(ctx: *mut ssl::SSL_CTX); + /// root_certs.cpp: the CA store every other TLS context of Bun verifies against without a `ca`. The caller owns a reference. + fn us_get_shared_default_ca_store() -> *mut ssl::X509_STORE; + /// root_certs.cpp: a new store with the same contents. The caller owns it. + fn us_get_default_ca_store() -> *mut ssl::X509_STORE; } /// node's `X509_V_ERR_UNSPECIFIED`, reported when a peer sent no certificate diff --git a/src/runtime/server/ServerConfig.rs b/src/runtime/server/ServerConfig.rs index 27575cf6cd4e..c4a07490eb9c 100644 --- a/src/runtime/server/ServerConfig.rs +++ b/src/runtime/server/ServerConfig.rs @@ -18,6 +18,7 @@ use super::{AnyRoute, AnyServer}; use crate::server::jsc::{JSGlobalObject, JSPropertyIterator, JSValue, JsResult, Strong}; use bun_core::fmt as bun_fmt; +use crate::api::bun_secure_context::SecureContext; pub(crate) use crate::socket::ssl_config::SSLConfig; use crate::socket::ssl_config::SSLConfigFromJs; use bun_collections::index_sort; @@ -33,6 +34,8 @@ pub(crate) struct ServerConfig { pub(crate) base_uri: Box<[u8]>, pub(crate) ssl_config: Option, + /// The `SSL_CTX` a node:http server built in its constructor. `ssl_config` then has no key material. + pub(crate) ssl_ctx: Option, // Vec drop runs each element's `Drop`, and `SSLConfig: Drop` calls // `deinit()` (frees the owned C strings). pub(crate) sni: Option>, @@ -83,6 +86,7 @@ impl Default for ServerConfig { has_idle_timeout: false, base_uri: Box::default(), ssl_config: None, + ssl_ctx: None, sni: None, max_request_body_size: 1024 * 1024 * 128, development: DevelopmentOption::Development, @@ -283,6 +287,7 @@ impl ServerConfig { has_idle_timeout: self.has_idle_timeout, base_uri: core::mem::take(&mut self.base_uri), ssl_config: self.ssl_config.take(), + ssl_ctx: self.ssl_ctx.take(), sni: self.sni.take(), max_request_body_size: self.max_request_body_size, development: self.development, @@ -575,6 +580,26 @@ fn get_routes_object(global: &JSGlobalObject, arg: JSValue) -> JsResult JsResult> { + if !args.is_node_http_server || !tls.is_object() { + return Ok(None); + } + let Some(value) = tls.get_truthy(global, "secureContext")? else { + return Ok(None); + }; + match value.as_class_ref::() { + Some(secure_context) => Ok(Some(secure_context.ctx.clone())), + None => { + Err(global.throw_invalid_argument_type_value(b"secureContext", b"SecureContext", value)) + } + } +} + impl ServerConfig { pub(crate) fn from_js( global: &JSGlobalObject, @@ -1217,10 +1242,12 @@ impl ServerConfig { } } } - } else { - if let Some(ssl_config) = SSLConfig::from_js(vm, global, tls, true)? { - args.ssl_config = Some(ssl_config); - } + } else if let Some(ctx) = node_http_ssl_ctx(global, &args, tls)? { + args.ssl_ctx = Some(ctx); + // Marks the server as TLS for every reader of `ssl_config`. + args.ssl_config = Some(crate::socket::tls_true_defaults(vm, true)); + } else if let Some(ssl_config) = SSLConfig::from_js(vm, global, tls, true)? { + args.ssl_config = Some(ssl_config); } } diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index c4c759b62293..ca960c9a5b28 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -2895,7 +2895,14 @@ impl NewServer { return JSValue::ZERO; }; - app = match uws_sys::NewApp::::create(&ssl_options) { + let created = match this_ref.config.ssl_ctx.as_ref() { + Some(ctx) => uws_sys::NewApp::::create_with_ssl_ctx( + ctx, + crate::socket::server_ctx_rejects_unauthorized(Some(ctx.as_ptr())), + ), + None => uws_sys::NewApp::::create(&ssl_options), + }; + app = match created { Some(a) => a, None => { if !global.has_exception() && !throw_ssl_error_if_necessary(global) { diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index 81a6905cdffb..02d40b141e9a 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -3282,22 +3282,25 @@ where callback.call( &global, JSValue::UNDEFINED, - &[ - JSValue::from(is_ssl), - node_socket, - error_code_value, - raw_packet_value, - ], + &[node_socket, error_code_value, raw_packet_value], )?; } Ok(()) } - /// node:http compat: a connection was accepted on this server (for TLS, - /// its handshake completed). Hands the JSNodeHTTPServerSocket to the JS - /// `onConnection` callback so `node:http` can emit 'connection' before any - /// request bytes arrive. - pub(crate) fn on_connection_callback(&self, socket: *mut c_void) -> JsResult<()> { + /// node:http: reports a connection to JS at TCP accept (`2`), and a TLS one again, as `secured`, when its handshake ends (`1`). + pub(crate) fn on_connection_event(&self, socket: *mut c_void, event: i32) { + let secured = match event { + // Plain TCP sends `1` right after. + 2 if SSL => false, + 1 => SSL, + _ => return, + }; + crate::dispatch::fold(self.on_connection_callback(socket, secured)); + } + + /// Calls the JS `onConnection` callback with the JSNodeHTTPServerSocket and `secured`. Skips a failed handshake. + fn on_connection_callback(&self, socket: *mut c_void, secured: bool) -> JsResult<()> { if self.js_value_for_dispatch().is_none() { return Ok(()); } @@ -3306,16 +3309,24 @@ where return Ok(()); } let global = self.global(); - let node_socket = jsc::from_js_host_call(&global, || { - Bun__getOrCreateNodeHTTPServerSocket(SSL, socket, &global) - })?; - if node_socket.is_undefined_or_null() { + let node_socket = if secured { + Bun__NodeHTTP__securedServerSocket(socket) + } else { + jsc::from_js_host_call(&global, || { + Bun__getOrCreateNodeHTTPServerSocket(SSL, socket, &global) + })? + }; + if node_socket.is_empty_or_undefined_or_null() { return Ok(()); } // SAFETY: event_loop() returns a live raw pointer tied to the global. let _scope = unsafe { jsc::event_loop::EventLoop::enter_scope(global.bun_vm().event_loop()) }; - callback.call(&global, JSValue::UNDEFINED, &[node_socket])?; + callback.call( + &global, + JSValue::UNDEFINED, + &[node_socket, JSValue::from(secured)], + )?; Ok(()) } @@ -3453,22 +3464,16 @@ fn server_set_on_connection( <$T>::js_gc_on_connection_set, ); } - // uws filters fire with `1` when an HTTP connection is opened - // (for TLS, when its handshake completes) and `-1` on close; - // only the open notification is forwarded to JS. extern "C" fn thunk( socket: *mut uws_sys::us_socket_t, opened: i32, user_data: *mut c_void, ) { - if opened != 1 { - return; - } // SAFETY: user_data is the `*mut Self` registered below; // socket is a live uWS socket for this server's group. // Shared — the callback re-enters JS. let this = unsafe { &*user_data.cast::<$T>() }; - crate::dispatch::fold(this.on_connection_callback(socket.cast::())); + this.on_connection_event(socket.cast::(), opened); } if first { // S008: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. @@ -3693,6 +3698,9 @@ unsafe extern "C" { global: &JSGlobalObject, ) -> JSValue; + // `socket` is a live TLS `uws::Socket*`. Runs no JS. + safe fn Bun__NodeHTTP__securedServerSocket(socket: *mut c_void) -> JSValue; + // `&JSGlobalObject` / `&mut JSValue` discharge the deref'd-param // preconditions; `request_ptr`/`req` are opaque handles that C++ stores or // forwards (module-private — sole callers pass live pointers). diff --git a/src/runtime/socket/Handlers.rs b/src/runtime/socket/Handlers.rs index 74802e4be6d7..32ae594a2b6d 100644 --- a/src/runtime/socket/Handlers.rs +++ b/src/runtime/socket/Handlers.rs @@ -423,6 +423,13 @@ impl Handlers { self.cell.set_callbacks(global_object, &wrapped); self.binary_type.set(reloaded.binary_type); } + + /// Like [`apply_reload`](Self::apply_reload), but copies `source`'s callbacks, which are already context-wrapped. + pub(crate) fn copy_callbacks_from(&self, global_object: &JSGlobalObject, source: &Handlers) { + self.cell + .set_callbacks(global_object, &source.cell.callbacks()); + self.binary_type.set(source.binary_type.get()); + } } /// One in-flight dispatch into JS. Holds an `Rc` so the callbacks it is about @@ -479,9 +486,8 @@ impl SocketConfig { uws::LIBUS_LISTEN_DEFAULT }; - if self.allow_half_open { - flags |= uws::LIBUS_SOCKET_ALLOW_HALF_OPEN; - } + // The loop never closes on a FIN. `NewSocket::on_end` applies `allow_half_open`. + flags |= uws::LIBUS_SOCKET_ALLOW_HALF_OPEN; if self.ipv6_only { flags |= uws::LIBUS_SOCKET_IPV6_ONLY; } @@ -509,7 +515,12 @@ impl SocketConfig { } } SocketOptionsTLS::Object(ssl) => { - SSLConfig::from_generated(vm, global, ssl, mode.is_server())? + match SSLConfig::from_generated(vm, global, ssl, mode.is_server()) { + // `None` means the object sets no option. A client that passed one still asked for TLS. + Ok(None) if !mode.is_server() => Some(super::tls_true_defaults(vm, false)), + Ok(configured) => configured, + Err(err) => return Err(err), + } } }; break 'blk SocketConfig { diff --git a/src/runtime/socket/JSSocketHandlers.rs b/src/runtime/socket/JSSocketHandlers.rs index 35edd9cb2c01..708a577c9f21 100644 --- a/src/runtime/socket/JSSocketHandlers.rs +++ b/src/runtime/socket/JSSocketHandlers.rs @@ -136,6 +136,18 @@ impl JSSocketHandlers { Bun__SocketHandlers__setCallbacks(global, self.0, callbacks.as_ptr()); } + /// The inverse of [`set_callbacks`](Self::set_callbacks): unset fields read as `JSValue::ZERO`. + pub(crate) fn callbacks(self) -> [JSValue; CALLBACK_COUNT] { + core::array::from_fn(|i| { + let value = Bun__SocketHandlers__getField(self.0, i as u32); + if value.is_undefined() { + JSValue::ZERO + } else { + value + } + }) + } + /// Drops the `open` callback: a client socket clears it after its first TLS /// handshake so renegotiations do not fire it again. #[inline] diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index d4e345f386eb..8ae5b6804d11 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -70,6 +70,9 @@ fn with_ssl_ctx_cache( // `to_js(self)` impl does would invalidate that link). use crate::generated_classes::js_Listener; +/// `HotMapEntry::tag` of a `Listener`. `crate::server::AnyServerTag` owns 0..=3. +const HOT_MAP_TAG_LISTENER: u8 = 4; + // R-2 (host-fn re-entrancy): every JS-exposed method takes `&self`; per-field // interior mutability via `Cell` (Copy) / `JsCell` (non-Copy). The codegen // shim still emits `this: &mut Listener` — `&mut T` auto-derefs to `&T` @@ -94,6 +97,10 @@ pub(crate) struct Listener { pub(crate) reject_unauthorized: bool, /// Accepted sockets carry `Flags::PAUSE_ON_CONNECT` (see `NewSocket::on_open`). pub(crate) pause_on_connect: bool, + /// Unless set, accepted sockets carry `Flags::ENDS_ON_PEER_FIN`. + pub(crate) allow_half_open: bool, + /// The `SocketConfig::socket_flags()` this listener was opened with. + pub(crate) socket_flags: i32, pub(crate) strong_data: JsCell, /// Reference to this listener's JS wrapper. Strong while it is listening or /// has connections, downgraded to weak once idle so GC can reclaim it. @@ -102,6 +109,8 @@ pub(crate) struct Listener { pub(crate) abort_handle: bun_jsc::AbortHandle, /// That context: where an accepted socket that leaves the listener's group (a TLS upgrade) goes. pub(crate) context: bun_jsc::ContextId, + /// This listener's key in `VirtualMachine::hot_map()` while it's registered there. + pub(crate) hot_key: JsCell>>, } bun_jsc::impl_abort_handle_owner!(Listener, abort_handle, |this, _cause| { @@ -287,6 +296,40 @@ impl Listener { let ssl_enabled = socket_config.ssl.is_some(); let socket_flags = socket_config.socket_flags(); let pause_on_connect = socket_config.pause_on_connect; + let allow_half_open = socket_config.allow_half_open; + + // `--hot` runs the module again while the previous run's listener still holds the address. + let hot_key = hot_reload_key(&socket_config); + if let Some(entry) = hot_key + .as_deref() + .and_then(|key| VirtualMachine::get().as_mut().hot_map()?.get_entry(key)) + .filter(|entry| entry.tag == HOT_MAP_TAG_LISTENER) + { + // SAFETY: `do_stop` and `deinit` remove the entry, so a registered listener is live. + let previous = unsafe { &*entry.ptr.cast::() }; + // Its connections stay open and run the new handlers. + previous + .handlers + .copy_callbacks_from(cx.global(), &socket_config.handlers); + // With the same options it keeps listening, so no connection is refused. TLS options can't be compared. + let unchanged = !ssl_enabled + && !previous.ssl + && previous.socket_flags == socket_flags + && previous.allow_half_open == allow_half_open + && previous.context == cx.context().id(); + if let Some(this_value) = previous.this_value.get().try_get().filter(|_| unchanged) { + let default_data = socket_config.default_data; + previous.strong_data.with_mut(|data| { + if default_data.is_empty() { + data.deinit(); + } else { + data.set(cx.global(), default_data); + } + }); + return Ok(this_value); + } + Listener::do_stop(previous, false); + } #[cfg(windows)] if port.is_none() { @@ -329,11 +372,14 @@ impl Listener { true, ), pause_on_connect, + allow_half_open, + socket_flags, poll_ref: JsCell::new(KeepAlive::init()), group: JsCell::new(uws::SocketGroup::default()), secure_ctx: JsCell::new(None), strong_data: JsCell::new(Strong::empty()), this_value: JsCell::new(JsRef::empty()), + hot_key: JsCell::new(None), abort_handle: bun_jsc::AbortHandle::for_owner::(), context: cx.context().id(), })); @@ -426,6 +472,7 @@ impl Listener { this_ref.poll_ref.with_mut(|p| p.ref_(bun_io::js_vm_ctx())); // SAFETY: heap-allocated above; owned by the JS wrapper from here. unsafe { bun_jsc::AbortHandle::arm_owner(this, cx.context()) }; + this_ref.register_for_hot_reload(hot_key); return Ok(this_value); } } @@ -457,12 +504,15 @@ impl Listener { true, ), pause_on_connect, + allow_half_open, + socket_flags, listener: Cell::new(ListenerType::None), poll_ref: JsCell::new(KeepAlive::init()), group: JsCell::new(uws::SocketGroup::default()), secure_ctx: JsCell::new(None), strong_data: JsCell::new(Strong::empty()), this_value: JsCell::new(JsRef::empty()), + hot_key: JsCell::new(None), abort_handle: bun_jsc::AbortHandle::for_owner::(), context: cx.context().id(), })); @@ -635,14 +685,7 @@ impl Listener { } if ssl_enabled { - // Register the dynamic SNI dispatch when the JS config provided a - // `serverName` handler - `us_select_cert_cb` invokes it FIRST for - // every ClientHello carrying a servername (the user callback takes - // precedence over the static SNI tree, Node semantics) and - // installs whichever context it returns on the in-flight SSL. A - // null return falls back to the static tree (addContext entries), - // then the default context; an asynchronous resolution suspends - // the handshake until resumeSNI. + // As in Node, a `serverName` handler (the user's SNICallback) replaces the lookup in the addContext() entries. if !this_ref.handlers.on_server_name().is_empty() { // S008: `ListenSocket` is an `opaque_ffi!` ZST - safe deref. bun_opaque::opaque_deref_mut(listen_socket).on_server_name(us_dispatch_server_name); @@ -664,10 +707,39 @@ impl Listener { this_ref.poll_ref.with_mut(|p| p.ref_(bun_io::js_vm_ctx())); // SAFETY: heap-allocated above; owned by the JS wrapper from here. unsafe { bun_jsc::AbortHandle::arm_owner(this, cx.context()) }; + this_ref.register_for_hot_reload(hot_key); Ok(this_value) } + /// Lets the next `--hot` run that listens on this address find this listener. + fn register_for_hot_reload(&self, key: Option>) { + let Some(key) = key else { return }; + let Some(hot) = VirtualMachine::get().as_mut().hot_map() else { + return; + }; + if hot.get_entry(&key).is_some() { + return; + } + hot.insert_raw( + &key, + bun_jsc::rare_data::HotMapEntry { + tag: HOT_MAP_TAG_LISTENER, + ptr: std::ptr::from_ref(self).cast_mut().cast::<()>(), + }, + ); + self.hot_key.set(Some(key)); + } + + fn unregister_for_hot_reload(&self) { + let Some(key) = self.hot_key.with_mut(Option::take) else { + return; + }; + if let Some(hot) = VirtualMachine::get().as_mut().hot_map() { + hot.remove(&key); + } + } + // `OWNED_PROTOS` stays unset: accepted sockets clone the listener's `protos`. fn accepted_socket_flags(&self) -> SocketFlags { let mut flags = SocketFlags::empty(); @@ -678,6 +750,7 @@ impl Listener { self.reject_unauthorized || crate::socket::server_ctx_rejects_unauthorized(ctx), ); flags.set(SocketFlags::PAUSE_ON_CONNECT, self.pause_on_connect); + flags.set(SocketFlags::ENDS_ON_PEER_FIN, !self.allow_half_open); flags } @@ -703,6 +776,7 @@ impl Listener { server_name: JsCell::new(None), buffered_data_for_node_net: Default::default(), bytes_written: Cell::new(0), + bytes_read: Cell::new(0), native_callback: JsCell::new(crate::socket::NativeCallbacks::None), twin: JsCell::new(None), verify_error: JsCell::new(None), @@ -750,6 +824,7 @@ impl Listener { server_name: JsCell::new(None), buffered_data_for_node_net: Default::default(), bytes_written: Cell::new(0), + bytes_read: Cell::new(0), native_callback: JsCell::new(crate::socket::NativeCallbacks::None), twin: JsCell::new(None), verify_error: JsCell::new(None), @@ -886,6 +961,7 @@ impl Listener { } fn do_stop(this: &Self, force_close: bool) { + this.unregister_for_hot_reload(); let connections = this.handlers.active_connections.get(); // Accepted sockets have no handle of their own: this one is how their context's stop // reaches them, so it stays until they are gone (`Handlers::mark_inactive`). @@ -981,6 +1057,7 @@ impl Listener { // and `close_all()` can fire JS `close` handlers that re-derive // `&Listener` — no `&mut` may span that. let this_ref = unsafe { &*this }; + this_ref.unregister_for_hot_reload(); this_ref.this_value.with_mut(|r| r.finalize()); this_ref.strong_data.with_mut(|s| s.deinit()); this_ref.poll_ref.with_mut(|p| p.unref(bun_io::js_vm_ctx())); @@ -1276,6 +1353,7 @@ impl Listener { ref_pollref_on_connect: Cell::new(true), buffered_data_for_node_net: Default::default(), bytes_written: Cell::new(0), + bytes_read: Cell::new(0), native_callback: JsCell::new(crate::socket::NativeCallbacks::None), twin: JsCell::new(None), verify_error: JsCell::new(None), @@ -1370,6 +1448,7 @@ impl Listener { ref_pollref_on_connect: Cell::new(true), buffered_data_for_node_net: Default::default(), bytes_written: Cell::new(0), + bytes_read: Cell::new(0), native_callback: JsCell::new(crate::socket::NativeCallbacks::None), twin: JsCell::new(None), verify_error: JsCell::new(None), @@ -1613,6 +1692,7 @@ fn connect_finish( ref_pollref_on_connect: Cell::new(true), buffered_data_for_node_net: Default::default(), bytes_written: Cell::new(0), + bytes_read: Cell::new(0), native_callback: JsCell::new(crate::socket::NativeCallbacks::None), twin: JsCell::new(None), verify_error: JsCell::new(None), @@ -1631,7 +1711,7 @@ fn connect_finish( IS_SSL && crate::socket::resolve_reject_unauthorized(vm, ssl.as_deref(), false), ); socket_ref.update_flags(|f| { - f.set(SocketFlags::ALLOW_HALF_OPEN, allow_half_open); + f.set(SocketFlags::ENDS_ON_PEER_FIN, !allow_half_open); f.set(SocketFlags::PAUSE_ON_CONNECT, pause_on_connect); }); // Held for the connect attempt regardless of `ref_pollref_on_connect`; `on_open` applies that. @@ -1687,6 +1767,9 @@ fn connect_finish( || os_errno == bun_sys::SystemErrno::EADDRNOTAVAIL as c_int || os_errno == bun_sys::SystemErrno::EACCES as c_int || os_errno == bun_sys::SystemErrno::EINVAL as c_int + // connect() itself fails with these when there's no route. + || os_errno == bun_sys::SystemErrno::ENETUNREACH as c_int + || os_errno == bun_sys::SystemErrno::EHOSTUNREACH as c_int { os_errno } else { @@ -1740,6 +1823,20 @@ pub(crate) fn set_secure_context(handle: ServerHandle<'_>, context: ListenerCont } } +/// The address's key in `VirtualMachine::hot_map()`. `None` without `--hot`, or if two listeners can hold the address at once. +fn hot_reload_key(config: &SocketConfig) -> Option> { + VirtualMachine::get().as_mut().hot_map()?; + if config.reuse_port || config.fd.is_some() || config.port == Some(0) { + return None; + } + let mut key = b"[listen]-".to_vec(); + key.extend_from_slice(config.hostname_or_unix.slice()); + if let Some(port) = config.port { + key.extend_from_slice(format!(":{port}").as_bytes()); + } + Some(key.into_boxed_slice()) +} + #[cfg(windows)] fn is_valid_pipe_name(pipe_name: &[u8]) -> bool { // check for valid pipe names @@ -2009,58 +2106,63 @@ pub(crate) extern "C" fn us_dispatch_socket_server_name( dispatch_server_name(socket, hostname, abort_handshake).cast() } -/// Shared decoding of what the JS SNI handler returned. See -/// `us_dispatch_server_name` for the contract. -fn decode_sni_result(result: JSValue, abort_handshake: *mut core::ffi::c_int) -> *mut c_void { - if result.is_boolean() && result.to_boolean() { - if !abort_handshake.is_null() { - // SAFETY: live out-parameter for the duration of this dispatch. - unsafe { *abort_handshake = 2 }; +/// What the JS `serverName` handler answered. +pub(crate) enum ServerNameAnswer { + /// undefined / null: the default context. + Default, + /// A native SecureContext, for this handshake only. + Context(boring_sys::OwnedSslCtx), + /// `true`: the SNICallback is asynchronous. `handle.resumeSNI(...)` answers. + Pending, + /// An Error, a throw, or any other value: the connection is dropped with no TLS alert. + Refused, +} + +impl ServerNameAnswer { + fn from_js(result: JSValue) -> Self { + if result.is_boolean() && result.to_boolean() { + return Self::Pending; + } + if result.to_error().is_some() { + return Self::Refused; + } + if result.is_undefined_or_null() { + return Self::Default; + } + match result.as_class_ref::() { + Some(sc) => { + let ctx = sc.ctx.clone(); + super::socket_body::install_sni_alpn_selector(ctx.as_ptr()); + Self::Context(ctx) + } + // Node treats anything else as an invalid SNI context. + None => Self::Refused, } - return core::ptr::null_mut(); } - if result.to_error().is_some() { + + /// Converts the answer to what `us_dispatch_server_name` returns to openssl.c. + fn into_raw(self, abort_handshake: *mut core::ffi::c_int) -> *mut c_void { + let code = match self { + Self::Default => return core::ptr::null_mut(), + // The C dispatcher frees this +1 after `SSL_set_SSL_CTX` takes its own. + Self::Context(ctx) => return ctx.into_raw().cast(), + Self::Refused => 1, + Self::Pending => 2, + }; if !abort_handshake.is_null() { // SAFETY: live out-parameter for the duration of this dispatch. - unsafe { *abort_handshake = 1 }; + unsafe { *abort_handshake = code }; } - return core::ptr::null_mut(); - } - if result.is_undefined_or_null() { - return core::ptr::null_mut(); - } - if let Some(sc) = result.as_class_ref::() { - // The C dispatcher frees this +1 after `SSL_set_SSL_CTX` takes its own. - let ctx = sc.ctx.clone().into_raw(); - super::socket_body::install_sni_alpn_selector(ctx); - return ctx.cast(); + core::ptr::null_mut() } - // Anything else is not a SecureContext: Node treats this as an invalid SNI - // context and drops the connection. - if !abort_handshake.is_null() { - // SAFETY: live out-parameter for the duration of this dispatch. - unsafe { *abort_handshake = 1 }; - } - core::ptr::null_mut() } -/// `openssl.c`'s `us_select_cert_cb` (the early select-certificate callback) -/// calls this FIRST for every ClientHello carrying a servername - the user -/// SNICallback takes precedence over the static SNI tree (Node semantics) - -/// so the JS callback can pick a context for the requested hostname. The -/// returned `SSL_CTX*` applies to the in-flight handshake only - the caller -/// installs it with `SSL_set_SSL_CTX`, which takes its own reference, and -/// nothing is cached in the SNI tree, so the callback runs per-connection the -/// way Node's does. A null return falls back to the static tree -/// (addContext entries), then the default context. An asynchronous -/// SNICallback sets `*abort_handshake = 2` instead: the handshake suspends -/// (select-certificate retry) until the JS resolution calls -/// `handle.resumeSNI(...)` -> `us_socket_sni_resolve()`. +/// `us_select_cert_cb` (openssl.c) calls this for every ClientHello with a server name. `ServerNameAnswer` lists the answers. /// /// # Safety /// `socket` is the live us_socket_t processing this ClientHello and `hostname` /// is NUL-terminated for the call. JS-thread only. -extern "C" fn us_dispatch_server_name( +pub(crate) extern "C" fn us_dispatch_server_name( _ls: *mut uws_sys::ListenSocket, hostname: *const core::ffi::c_char, abort_handshake: *mut core::ffi::c_int, @@ -2069,6 +2171,24 @@ extern "C" fn us_dispatch_server_name( dispatch_server_name(socket.cast(), hostname, abort_handshake) } +unsafe extern "C" { + // JSNodeHTTPServerSocket.cpp: `dispatch_server_name` for a node:https connection. Its caller checked the pointers. + safe fn Bun__NodeHTTP__onServerName( + socket: *mut uws_sys::us_socket_t, + hostname: *const core::ffi::c_char, + abort_handshake: *mut core::ffi::c_int, + ) -> *mut c_void; +} + +/// For JSNodeHTTPServerSocket.cpp, which calls the `serverName` handler of a node:https connection itself. +#[unsafe(export_name = "Bun__ServerNameAnswer__fromJS")] +extern "C" fn server_name_answer_from_js( + result: JSValue, + abort_handshake: *mut core::ffi::c_int, +) -> *mut c_void { + ServerNameAnswer::from_js(result).into_raw(abort_handshake) +} + fn dispatch_server_name( socket: *mut uws_sys::us_socket_t, hostname: *const core::ffi::c_char, @@ -2079,17 +2199,31 @@ fn dispatch_server_name( return core::ptr::null_mut(); } let s_ref = uws_sys::us_socket_t::opaque_mut(socket); - if s_ref.kind() != uws_sys::SocketKind::BunSocketTls { - return core::ptr::null_mut(); + match s_ref.kind() { + uws_sys::SocketKind::BunSocketTls => {} + uws_sys::SocketKind::UwsHttpTls => { + return Bun__NodeHTTP__onServerName(socket, hostname, abort_handshake); + } + _ => return core::ptr::null_mut(), } let tls = match *s_ref.ext::>>() { Some(tls) => tls, None => return core::ptr::null_mut(), }; + // SAFETY: `hostname` is NUL-terminated per the fn contract. + let name = unsafe { core::ffi::CStr::from_ptr(hostname) }; + ask_server_name(tls, name).into_raw(abort_handshake) +} + +/// Asks the `serverName` handler of a server-side socket, for both TLS engines. +pub(crate) fn ask_server_name( + tls: bun_ptr::ThisPtr, + name: &core::ffi::CStr, +) -> ServerNameAnswer { // An idle socket can drop its Handlers while the us_socket_t lives on; // `get_handlers()` would panic. Same guard as `select_alpn_callback`. if !tls.has_handlers() { - return core::ptr::null_mut(); + return ServerNameAnswer::Default; } // No `Handlers::enter`/`exit` scope here: that protocol tracks the // accepted-socket callback lifecycle, and running it from inside the @@ -2097,7 +2231,7 @@ fn dispatch_server_name( let handlers = tls.get_handlers(); let callback = handlers.on_server_name(); if callback.is_empty() { - return core::ptr::null_mut(); + return ServerNameAnswer::Default; } let global = handlers.global_object; // The resume handle an asynchronous SNICallback uses (`handle.resumeSNI(...)`). @@ -2111,8 +2245,6 @@ fn dispatch_server_name( None => TLSSocket::data_get_cached(socket_handle), } .unwrap_or(JSValue::UNDEFINED); - // SAFETY: `hostname` is NUL-terminated per the fn contract. - let name = unsafe { core::ffi::CStr::from_ptr(hostname) }; // Peer-supplied SNI bytes, decoded as Latin-1 like Node's `OneByteString`. let js_name = EncodedSlice::latin1(name.to_bytes()).to_js(&global); let result = match callback.call(&global, this_value, &[this_value, js_name, socket_handle]) { @@ -2128,5 +2260,5 @@ fn dispatch_server_name( // threw) -> abort the handshake; the connection is dropped without an // alert and the JS side emits 'tlsClientError' from the // handshake-failure path with the stashed error. - decode_sni_result(result, abort_handshake) + ServerNameAnswer::from_js(result) } diff --git a/src/runtime/socket/SSLConfig.bind.ts b/src/runtime/socket/SSLConfig.bind.ts index 0ae713498911..6079e71ac5cd 100644 --- a/src/runtime/socket/SSLConfig.bind.ts +++ b/src/runtime/socket/SSLConfig.bind.ts @@ -20,6 +20,13 @@ export const ALPNProtocols = b.union({ buffer: b.bufferSource.arrayBuffer, }); +/** One of node:tls's names, such as "TLSv1.2", or the protocol code node:tls itself passes. */ +export const TLSVersion = b.union({ + none: b.null, + name: b.string, + code: b.i32.allowBigInt, +}); + export const TLSOptions = b.dictionary( { passphrase: b.string.nullable, @@ -32,8 +39,8 @@ export const TLSOptions = b.dictionary( cert: TLSOptionsFile, key: TLSOptionsFile, secureOptions: b.u32.allowBigInt.default(0), - minVersion: { type: b.i32.allowBigInt.default(0), rustName: "ssl_min_version" }, - maxVersion: { type: b.i32.allowBigInt.default(0), rustName: "ssl_max_version" }, + minVersion: { type: TLSVersion, rustName: "ssl_min_version" }, + maxVersion: { type: TLSVersion, rustName: "ssl_max_version" }, keyFile: b.string.nullable, certFile: b.string.nullable, caFile: b.string.nullable, diff --git a/src/runtime/socket/SSLConfig.rs b/src/runtime/socket/SSLConfig.rs index 9b0bcbbab85a..2284aab9c5bf 100644 --- a/src/runtime/socket/SSLConfig.rs +++ b/src/runtime/socket/SSLConfig.rs @@ -119,6 +119,31 @@ fn read_from_blob( // fromJS / fromGenerated — extension trait (orphan-rule workaround) // ────────────────────────────────────────────────────────────────────────── +/// The protocol code for a `minVersion` / `maxVersion` option, 0 if it isn't set. +fn protocol_version( + global: &JSGlobalObject, + bound: &str, + version: &generated::TLSVersion, +) -> JsResult { + let name = match version { + generated::TLSVersion::None => return Ok(0), + generated::TLSVersion::Code(code) => return Ok(*code), + generated::TLSVersion::Name(name) => name, + }; + match name.to_utf8().slice() { + b"TLSv1" => Ok(0x0301), + b"TLSv1.1" => Ok(0x0302), + b"TLSv1.2" => Ok(0x0303), + b"TLSv1.3" => Ok(0x0304), + _ => Err(global + .err( + jsc::ErrorCode::TLS_INVALID_PROTOCOL_VERSION, + format_args!("\"{name}\" is not a valid {bound} TLS protocol version"), + ) + .throw()), + } +} + /// JSC-dependent constructors for the canonical `bun_http::SSLConfig`. /// Import this trait to call `SSLConfig::from_js(..)` / `::from_generated(..)`. /// An unset `rejectUnauthorized` is `true` for a server and `NODE_TLS_REJECT_UNAUTHORIZED` for a client, as in Node.js. @@ -186,8 +211,8 @@ impl SSLConfigFromJs for SSLConfig { as i32; result.request_cert = generated.request_cert as i32; result.secure_options = generated.secure_options; - result.ssl_min_version = generated.ssl_min_version; - result.ssl_max_version = generated.ssl_max_version; + result.ssl_min_version = protocol_version(global, "minimum", &generated.ssl_min_version)?; + result.ssl_max_version = protocol_version(global, "maximum", &generated.ssl_max_version)?; result.session_timeout = generated.session_timeout; result.allow_partial_trust_chain = generated.allow_partial_trust_chain; if let Some(sigalgs) = generated.sigalgs.as_ref() { @@ -277,6 +302,12 @@ impl SSLConfigFromJs for SSLConfig { if generated.ciphers.is_none() { apply_default_ciphers(vm, &mut result); } + // `ca: []` parses to no list, but it was given, so it skips the default. + if matches!(generated.ca, generated::TLSOptionsFile::None) && result.ca_file_name.is_null() + { + apply_default_ca(vm, &mut result); + } + apply_empty_default_ca(vm, &mut result); Ok(Some(result)) } } @@ -287,12 +318,15 @@ pub(crate) fn tls_true_defaults(vm: &VirtualMachine, is_server: bool) -> SSLConf let mut cfg = SSLConfig::zero(); cfg.reject_unauthorized = (is_server || vm.get_tls_reject_unauthorized()) as i32; apply_default_ciphers(vm, &mut cfg); + apply_default_ca(vm, &mut cfg); cfg } -/// For a request without TLS options: its default context is built on the HTTP thread, which does not see `tls.DEFAULT_CIPHERS`. +/// For a request without TLS options: its default context is built on the HTTP thread, which sees neither `tls.DEFAULT_CIPHERS` nor `tls.setDefaultCACertificates`. pub(crate) fn http_client_defaults(vm: &VirtualMachine) -> Option { - vm.tls_default_ciphers()?; + if !vm.tls_client_defaults_set() { + return None; + } Some(bun_http::ssl_config::global_registry::intern( tls_true_defaults(vm, false), )) @@ -305,6 +339,23 @@ fn apply_default_ciphers(vm: &VirtualMachine, cfg: &mut SSLConfig) { } } +/// No `ca` means `tls.setDefaultCACertificates`' list, as in Node's `configSecureContext`. +fn apply_default_ca(vm: &VirtualMachine, cfg: &mut SSLConfig) { + if let Some(certs) = vm.tls_default_ca() { + cfg.set_default_ca(certs); + } +} + +/// `tls.setDefaultCACertificates([])` for a `SecureContext`: its `ca: []` parses as no list, which would mean the default store. +pub(crate) fn apply_empty_default_ca(vm: &VirtualMachine, cfg: &mut SSLConfig) { + if cfg.ca.is_none() + && cfg.ca_file_name.is_null() + && vm.tls_default_ca().is_some_and(<[_]>::is_empty) + { + cfg.set_default_ca(&[]); + } +} + /// Whether a new TLS socket must enforce `rejectUnauthorized`: close the /// connection when the peer certificate fails verification. pub(crate) fn resolve_reject_unauthorized( diff --git a/src/runtime/socket/UpgradedDuplex.rs b/src/runtime/socket/UpgradedDuplex.rs index d39d67ca333c..4f40056b1cae 100644 --- a/src/runtime/socket/UpgradedDuplex.rs +++ b/src/runtime/socket/UpgradedDuplex.rs @@ -29,7 +29,7 @@ bun_output::declare_scope!(UpgradedDuplex, visible); pub(crate) struct UpgradedDuplex { pub wrapper: JsCell>, /// The owning `JSTLSSocket` wrapper. Its `values:` slots root `origin` and - /// the four listener thunks; the `JSValue` fields below are read-side + /// the three listener thunks; the `JSValue` fields below are read-side /// shadows of those slots. pub js_wrapper: JSValue, pub origin: Cell, // any duplex @@ -43,7 +43,6 @@ pub(crate) struct UpgradedDuplex { pub handlers: Handlers, pub on_data_callback: Cell, pub on_end_callback: Cell, - pub on_writable_callback: Cell, pub on_close_callback: Cell, pub event_loop_timer: JsCell, pub current_timeout: Cell, @@ -109,13 +108,15 @@ pub(crate) struct Handlers { pub(crate) on_keylog: fn(*mut (), &[u8]), pub(crate) server_identity: fn(*mut (), &mut bun_boringssl_sys::SSL) -> bun_boringssl::ServerIdentity, + /// Server wraps only: see `ssl_wrapper::Handlers::on_server_name`. + pub(crate) on_server_name: Option, } use crate::jsc_hooks::timer_all_mut as timer_all; /// Lazily create-and-cache a JS host-function callback in `shadow`, mirrored /// into the owning `JSTLSSocket` wrapper's visited `values:` slot (the GC -/// root). All four `get_js_handlers` slots follow the identical +/// root). All three `get_js_handlers` slots follow the identical /// `NewFunctionWithData(global, null, 0, fn, self)` → `ensureStillAlive` → /// `setFunctionData(self)` → store pattern. #[inline] @@ -235,6 +236,26 @@ impl UpgradedDuplex { unsafe { &*this }.finish_close(); } + fn on_server_name(this: *mut Self, name: &CStr) { + bun_output::scoped_log!(UpgradedDuplex, "onServerName"); + // SAFETY: see handler note above. + let this = unsafe { &*this }; + if let Some(on_server_name) = this.handlers.on_server_name { + on_server_name(this.handlers.ctx, name); + } + } + + /// Answers `Handlers::on_server_name`. A closed engine drops the answer. + pub(crate) fn resolve_server_name( + &self, + answer: Result, ()>, + ) { + let _keep_alive = self.keep_alive(); + if let Some(w) = self.wrapper_ref() { + w.resolve_server_name(answer); + } + } + pub(super) fn finish_close(&self) { let _keep_alive = self.keep_alive(); // Keep the wrapper (and so its visited `duplex*` slots) reachable @@ -485,7 +506,6 @@ impl UpgradedDuplex { ssl_error: JsCell::new(CertError::default()), on_data_callback: Cell::new(JSValue::ZERO), on_end_callback: Cell::new(JSValue::ZERO), - on_writable_callback: Cell::new(JSValue::ZERO), on_close_callback: Cell::new(JSValue::ZERO), event_loop_timer: JsCell::new(EventLoopTimer::init_paused( EventLoopTimerTag::UpgradedDuplex, @@ -499,7 +519,7 @@ impl UpgradedDuplex { } pub(crate) fn get_js_handlers(&self, global: &JSGlobalObject) -> JsResult { - let array = JSValue::create_empty_array(global, 4)?; + let array = JSValue::create_empty_array(global, 3)?; array.ensure_still_alive(); let this_ptr = std::ptr::from_ref(self).cast_mut().cast::(); @@ -531,18 +551,6 @@ impl UpgradedDuplex { array.put_index( global, 2, - lazy_js_handler( - &self.on_writable_callback, - js_wrapper, - js_TLSSocket::duplex_on_writable_set_cached, - global, - __jsc_host_on_writable, - this_ptr, - ), - )?; - array.put_index( - global, - 3, lazy_js_handler( &self.on_close_callback, js_wrapper, @@ -567,6 +575,10 @@ impl UpgradedDuplex { on_session: Some(Self::on_session), on_keylog: Some(Self::on_keylog), server_identity: Some(Self::server_identity), + on_server_name: self + .handlers + .on_server_name + .map(|_| Self::on_server_name as fn(*mut UpgradedDuplex, &CStr)), } } @@ -765,7 +777,6 @@ impl UpgradedDuplex { for cb in [ &self.on_data_callback, &self.on_end_callback, - &self.on_writable_callback, &self.on_close_callback, ] { let value = cb.get(); @@ -787,7 +798,7 @@ impl Drop for UpgradedDuplex { } } -// SAFETY (all four host fns): the function data is the `*mut UpgradedDuplex` +// SAFETY (all three host fns): the function data is the `*mut UpgradedDuplex` // installed by `get_js_handlers`; `teardown` clears it before the storage is // freed, so a non-null data pointer is live for the call. @@ -844,27 +855,6 @@ fn on_end(_global: &JSGlobalObject, frame: &CallFrame) -> JsResult { Ok(JSValue::UNDEFINED) } -#[bun_jsc::host_fn] -fn on_writable(_global: &JSGlobalObject, frame: &CallFrame) -> JsResult { - bun_output::scoped_log!(UpgradedDuplex, "onWritable"); - - let function = frame.callee(); - - if let Some(self_ptr) = host_fn::get_function_data(function) { - // SAFETY: see host-fn note above. - let this = unsafe { &*self_ptr.cast::() }; - let _keep_alive = this.keep_alive(); - // flush pending data - if let Some(w) = this.wrapper_ref() { - let _ = w.flush(); - } - // call onWritable (will flush on demand) - (this.handlers.on_writable)(this.handlers.ctx); - } - - Ok(JSValue::UNDEFINED) -} - #[bun_jsc::host_fn] fn on_close_js(_global: &JSGlobalObject, frame: &CallFrame) -> JsResult { bun_output::scoped_log!(UpgradedDuplex, "onCloseJS"); @@ -903,6 +893,23 @@ extern "C" fn UpgradedDuplex__set_inline_reject(this: *const c_void) { } } +/// `handle.resumeSNI(ctx, isError)`: takes the reference `ctx` carries. +#[unsafe(no_mangle)] +extern "C" fn UpgradedDuplex__sni_resolve( + this: *const c_void, + ctx: *mut bun_boringssl_sys::SSL_CTX, + error: bool, +) { + // SAFETY: `ctx` is null or carries a reference the caller gives up. + let ctx = unsafe { bun_boringssl_sys::OwnedSslCtx::from_raw(ctx) }; + // SAFETY: `this` is a live `*const UpgradedDuplex` from the uws_sys opaque handle. + unsafe { &*this.cast::() }.resolve_server_name(if error { + Err(()) + } else { + Ok(ctx) + }); +} + #[unsafe(no_mangle)] extern "C" fn UpgradedDuplex__latest_session( this: *const c_void, diff --git a/src/runtime/socket/WindowsNamedPipe.rs b/src/runtime/socket/WindowsNamedPipe.rs index 842b7d75f4d7..737d467fc971 100644 --- a/src/runtime/socket/WindowsNamedPipe.rs +++ b/src/runtime/socket/WindowsNamedPipe.rs @@ -77,7 +77,7 @@ bun_event_loop::impl_timer_owner!(WindowsNamedPipe; from_timer_ptr => event_loop bitflags::bitflags! { #[repr(transparent)] #[derive(Clone, Copy, Default)] - pub struct Flags: u8 { + pub struct Flags: u16 { const DISCONNECTED = 1 << 0; const IS_CLOSED = 1 << 1; const IS_CLIENT = 1 << 2; @@ -89,7 +89,10 @@ bitflags::bitflags! { const PIPE_ADOPTED = 1 << 4; const WRAPPER_BUSY = 1 << 5; const WRITER_BUSY = 1 << 6; - // _: u2 padding + /// `shutdown()` ran on a plain pipe. The write side ends once queued data drains, and reads continue. + const SHUTDOWN_REQUESTED = 1 << 7; + /// libuv has the `uv_shutdown` request. + const SHUTDOWN_SENT = 1 << 8; } } @@ -262,6 +265,7 @@ impl WindowsNamedPipe { source.unref(); } }); + self.send_shutdown_if_drained(); } WriteStatus::EndOfFile => { // we send FIN so we close after this @@ -386,6 +390,7 @@ impl WindowsNamedPipe { on_session: Some(Self::ssl_on_session), on_keylog: Some(Self::ssl_on_keylog), server_identity: Some(Self::ssl_server_identity), + on_server_name: None, } } @@ -600,6 +605,8 @@ impl WindowsNamedPipe { } } self.flush(); + // With nothing queued, no `on_write(Drained)` follows to send a shutdown requested before the connect. + self.send_shutdown_if_drained(); self.deref(); } @@ -892,15 +899,50 @@ impl WindowsNamedPipe { let _ = w.shutdown(false); }); if handled.is_none() { - // Plain (non-TLS) named pipe: half-close the write side so the peer - // observes EOF. Without this, Socket.prototype.end() over a Windows - // named pipe (endNT → shutdown()) never signals the peer, and an - // allowHalfOpen peer waiting on 'end' hangs. `writer.end()` is - // idempotent and mirrors `close`'s unconditional writer teardown. + let flags = self.flags.get(); + if flags.contains(Flags::SHUTDOWN_REQUESTED) || flags.is_closed() { + return; + } + self.update_flags(|f| f.insert(Flags::SHUTDOWN_REQUESTED)); + self.send_shutdown_if_drained(); + } + } + + /// Half-closes with `uv_shutdown` once libuv has every queued byte. `writer.end()` would close the read side too and drop the peer's reply. + fn send_shutdown_if_drained(&self) { + let flags = self.flags.get(); + if !flags.contains(Flags::SHUTDOWN_REQUESTED) + || flags.contains(Flags::SHUTDOWN_SENT) + || flags.is_closed() + || flags.disconnected() + || self.writer.get().has_pending_data() + { + return; + } + let Some(stream) = self.writer.with_mut(|w| w.get_stream()) else { + self.with_writer(|w| w.end()); + return; + }; + self.update_flags(|f| f.insert(Flags::SHUTDOWN_SENT)); + let req = bun_core::heap::alloc(bun_core::ffi::zeroed::()); + // SAFETY: `stream` is the live pipe stream owned by `self.writer.source`. libuv + // owns `req` until it calls `uv_on_shutdown`, with `UV_ECANCELED` if the handle + // closes first. + let rc = unsafe { uv::uv_shutdown(req, stream, Some(Self::uv_on_shutdown)) }; + if rc.is_err() { + // SAFETY: libuv didn't take `req`, so the callback never runs. + unsafe { bun_core::heap::destroy(req) }; + // Close the whole handle so the peer still sees EOF. self.with_writer(|w| w.end()); } } + /// Only frees the request. A canceled shutdown can run after its `WindowsNamedPipe` is gone. + extern "C" fn uv_on_shutdown(req: *mut uv::uv_shutdown_t, _status: core::ffi::c_int) { + // SAFETY: `req` comes from `heap::alloc` in `send_shutdown_if_drained`, and libuv calls this once. + unsafe { bun_core::heap::destroy(req) }; + } + #[bun_uws::uws_callback(export = "WindowsNamedPipe__shutdown_read")] pub(crate) fn shutdown_read(&self) { if let Some(wrapper) = self.wrapper_ref() { @@ -920,7 +962,10 @@ impl WindowsNamedPipe { return wrapper.is_shutdown(); } - self.flags.get().disconnected() || self.writer.get().is_done + let flags = self.flags.get(); + flags.disconnected() + || flags.contains(Flags::SHUTDOWN_REQUESTED) + || self.writer.get().is_done } #[bun_uws::uws_callback(export = "WindowsNamedPipe__is_closed", no_catch)] diff --git a/src/runtime/socket/mod.rs b/src/runtime/socket/mod.rs index a8f6036e4984..c60804fa3c33 100644 --- a/src/runtime/socket/mod.rs +++ b/src/runtime/socket/mod.rs @@ -21,6 +21,8 @@ pub(crate) mod js_socket_handlers; #[path = "Listener.rs"] pub(crate) mod listener; +pub(crate) mod pending_writes; + #[path = "UpgradedDuplex.rs"] pub(crate) mod upgraded_duplex; @@ -75,8 +77,8 @@ pub(crate) mod uws_jsc; #[path = "SSLConfig.rs"] pub(crate) mod ssl_config; pub(crate) use ssl_config::{ - SSLConfig, SSLConfigFromJs, http_client_defaults, resolve_reject_unauthorized, - tls_true_defaults, + SSLConfig, SSLConfigFromJs, apply_empty_default_ca, http_client_defaults, + resolve_reject_unauthorized, tls_true_defaults, }; // ─── canonical type surface ────────────────────────────────────────────────── diff --git a/src/runtime/socket/pending_writes.rs b/src/runtime/socket/pending_writes.rs new file mode 100644 index 000000000000..d0c68f68dc68 --- /dev/null +++ b/src/runtime/socket/pending_writes.rs @@ -0,0 +1,46 @@ +use bun_io::StreamBuffer; + +/// Bytes a write accepted and the socket hasn't taken yet. A partial send advances a cursor instead of moving the rest. +#[derive(Default)] +pub(crate) struct PendingWrites(StreamBuffer); + +impl PendingWrites { + /// The number of unsent bytes. + #[inline] + pub(crate) fn len(&self) -> usize { + self.0.size() + } + + /// The unsent bytes. + #[inline] + pub(crate) fn slice(&self) -> &[u8] { + self.0.slice() + } + + /// The size of the allocation, which includes the bytes already sent. + #[inline] + pub(crate) fn capacity(&self) -> usize { + self.0.memory_cost() + } + + #[inline] + pub(crate) fn append_slice(&mut self, bytes: &[u8]) { + bun_core::handle_oom(self.0.write(bytes)); + } + + #[inline] + pub(crate) fn clear_and_free(&mut self) { + self.0 = StreamBuffer::default(); + } + + /// Drops the first `n` bytes, which the socket took. An emptied queue frees its allocation. + #[inline] + pub(crate) fn consume(&mut self, n: usize) { + // One writev sends the queue and part of the chunk behind it, so `n` can exceed `len()`. + if n >= self.len() { + self.clear_and_free(); + } else { + self.0.wrote(n); + } + } +} diff --git a/src/runtime/socket/socket_body.bind.ts b/src/runtime/socket/socket_body.bind.ts index 0a4c855be8e7..975718e6c24f 100644 --- a/src/runtime/socket/socket_body.bind.ts +++ b/src/runtime/socket/socket_body.bind.ts @@ -20,3 +20,4 @@ export const checkServerIdentityInHandshake = b.fn({ args: { socket: SocketHandl export const firstFlightBeforeFin = b.fn({ args: { socket: SocketHandle } }); export const isNamedPipeSocket = b.fn({ args: { socket: SocketHandle }, returns: b.bool }); export const getBufferedAmount = b.fn({ args: { socket: SocketHandle }, returns: b.usize }); +export const getBytesRead = b.fn({ args: { socket: SocketHandle }, returns: b.f64 }); diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index c56871d2a525..7373d29a97b9 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -16,7 +16,6 @@ use bun_ptr::RefPtr; // `const SSL: bool` generic param in `NewSocket` below, making rustc // resolve `` as a type arg (E0747). Use the qualified path instead. use bun_boringssl_sys::SSL_CTX; -use bun_collections::VecExt; use bun_core::{self, fmt as bun_fmt}; use bun_jsc::SysErrorJsc; use bun_jsc::{self as jsc, CallFrame, JSGlobalObject, JSValue, JsRef, JsResult, SystemError}; @@ -94,6 +93,7 @@ pub(crate) extern "C" fn Bun__socketReadErrorFromCloseCode( // ────────────────────────────────────────────────────────────────────────── pub(super) use super::handlers::Handlers; +use super::pending_writes::PendingWrites; use std::rc::Rc; mod tls_socket_functions; @@ -115,20 +115,13 @@ macro_rules! log { /// its `arg` is shared across every accepted connection — using it for a /// per-connection `*TLSSocket` is a UAF when handshakes overlap. Read the /// socket back from the per-SSL ex_data slot set in `onOpen` instead. -extern "C" fn select_alpn_callback( - ssl: *mut bun_boringssl_sys::SSL, - out: *mut *const u8, - outlen: *mut u8, - in_: *const u8, - inlen: c_uint, - _arg: *mut c_void, -) -> c_int { +fn alpn_socket(ssl: *mut bun_boringssl_sys::SSL) -> Option> { // BoringSSL never invokes the ALPN callback with a null `SSL*`; route // through the const-asserted opaque-ZST accessor so the call is safe. let this_ptr = tls_socket_functions::ffi::SSL_get_ex_data(boringssl_sys::SSL::opaque_ref(ssl), 0); if this_ptr.is_null() { - return boringssl_sys::SSL_TLSEXT_ERR_NOACK; + return None; } // SAFETY: ex_data slot 0 holds a `*mut TLSSocket` (set in on_open), kept // live for this handshake callback by the JS wrapper's ref. @@ -136,10 +129,24 @@ extern "C" fn select_alpn_callback( // Same handlers-presence guard as every other dispatch entry point: // an idle socket has dropped its Handlers, and the ALPN selection // callback can still fire for a connection JS already detached - - // get_handlers() would panic. NOACK falls through to the static list. - if !this.has_handlers() { - return boringssl_sys::SSL_TLSEXT_ERR_NOACK; - } + // get_handlers() would panic. + this.has_handlers().then_some(this) +} + +/// Runs `ALPNCallback` before BoringSSL picks the certificate and the session, so its `setKeyCert()` still applies. Returns 0 if it refused. +#[unsafe(no_mangle)] +pub(crate) extern "C" fn us_dispatch_alpn_offer( + ssl: *mut bun_boringssl_sys::SSL, + in_: *const u8, + inlen: c_uint, +) -> c_int { + let Some(this) = alpn_socket(ssl) else { + return 1; + }; + let refuse = || { + this.update_flags(|f| f.insert(Flags::ALPN_REFUSED)); + 0 + }; // Dynamic per-connection ALPN: when the listener's config carries an // `alpnCallback` handler, consult it with the client's protocol list (and // the SNI name) before the static ALPNProtocols list. The JS handler @@ -184,13 +191,13 @@ extern "C" fn select_alpn_callback( let global = handlers.global_object; let this_value = this.get_this_value(&global); let wire_len = inlen as usize; - // BoringSSL's ALPN callback is these sockets' landing frame for this + // BoringSSL's select-certificate callback is these sockets' landing frame for this // event: whatever it leaves pending is folded before returning to C. let buffer = match JSValue::create_buffer_from_length(&global, wire_len) { Ok(b) => b, Err(err) => { crate::dispatch::fold(Err(err)); - return boringssl_sys::SSL_TLSEXT_ERR_ALERT_FATAL; + return refuse(); } }; if let Some(ab) = buffer.as_array_buffer(&global) { @@ -198,8 +205,8 @@ extern "C" fn select_alpn_callback( // and `in_` is valid for `inlen` per the callback contract. unsafe { core::ptr::copy_nonoverlapping(in_, ab.ptr, wire_len) }; } - // SAFETY: `ssl` is the live SSL handle passed into this ALPN - // callback; SSL_get_servername reads the negotiated SNI name and + // SAFETY: `ssl` is the live SSL handle passed into the select-certificate + // callback; SSL_get_servername reads the ClientHello's SNI name and // returns NULL or a NUL-terminated string owned by the SSL. let servername_ptr = unsafe { boringssl_sys::SSL_get_servername(ssl.cast_const(), 0) }; let servername_js = if servername_ptr.is_null() { @@ -218,7 +225,7 @@ extern "C" fn select_alpn_callback( crate::dispatch::fold( handlers.call_error_handler(this_value, &[this_value, err_value]), ); - return boringssl_sys::SSL_TLSEXT_ERR_ALERT_FATAL; + return refuse(); } if !result.is_boolean() || result.to_boolean() { // The server has an ALPNCallback and it answered: a string @@ -234,23 +241,40 @@ extern "C" fn select_alpn_callback( crate::dispatch::fold( handlers.call_error_handler(this_value, &[this_value, err_value]), ); - return boringssl_sys::SSL_TLSEXT_ERR_ALERT_FATAL; + return refuse(); } }; let chosen_bytes = chosen.slice(); if !result.is_string() || chosen_bytes.is_empty() || chosen_bytes.len() > 255 { - return boringssl_sys::SSL_TLSEXT_ERR_ALERT_FATAL; + return refuse(); } let mut wire = Vec::with_capacity(chosen_bytes.len() + 1); wire.push(chosen_bytes.len() as u8); wire.extend_from_slice(chosen_bytes); this.protos.set(Some(wire.into_boxed_slice())); - // Fall through to the standard selection below, which now + // `select_alpn_callback` runs the standard selection, which now // negotiates against the single chosen protocol (and sends the // fatal alert if the client did not actually offer it). } } } + 1 +} + +extern "C" fn select_alpn_callback( + ssl: *mut bun_boringssl_sys::SSL, + out: *mut *const u8, + outlen: *mut u8, + in_: *const u8, + inlen: c_uint, + _arg: *mut c_void, +) -> c_int { + let Some(this) = alpn_socket(ssl) else { + return boringssl_sys::SSL_TLSEXT_ERR_NOACK; + }; + if this.flags.get().contains(Flags::ALPN_REFUSED) { + return boringssl_sys::SSL_TLSEXT_ERR_ALERT_FATAL; + } if let Some(protos) = this.protos.get() { if protos.is_empty() { return boringssl_sys::SSL_TLSEXT_ERR_NOACK; @@ -346,8 +370,10 @@ pub(crate) struct NewSocket { pub(crate) local_binding: JsCell, u16)>>, pub(crate) protos: JsCell>>, pub(crate) server_name: JsCell>>, - pub(crate) buffered_data_for_node_net: JsCell>, + pub(crate) buffered_data_for_node_net: JsCell, pub(crate) bytes_written: Cell, + /// Bytes `on_data` received. A TLS socket counts plaintext. + pub(crate) bytes_read: Cell, pub(crate) native_callback: JsCell, /// `upgradeTLS` produces two `TLSSocket` wrappers over one @@ -633,11 +659,8 @@ impl NewSocket { } else { uws::SocketKind::BunSocketTcp }; - let flags: i32 = if self.flags.get().contains(Flags::ALLOW_HALF_OPEN) { - uws::LIBUS_SOCKET_ALLOW_HALF_OPEN - } else { - 0 - }; + // The loop never closes on a FIN. `on_end` applies `Flags::ENDS_ON_PEER_FIN`. + let flags: i32 = uws::LIBUS_SOCKET_ALLOW_HALF_OPEN; let ssl_ctx: Option<*mut uws::SslCtx> = if SSL { self.owned_ssl_ctx .get() @@ -941,6 +964,30 @@ impl NewSocket { called } + /// Reports `internal_flush`'s fatal errno as a failed write, then closes. The caller holds the handlers scope. + #[cfg(not(windows))] + fn fail_fatal_flush( + &self, + handlers: &Handlers, + this_value: JSValue, + fatal_send_errno: i32, + ) -> JsResult<()> { + // Without an `error` handler this would be an uncaught exception, which a peer could cause by disconnecting early. + if !handlers.on_error().is_empty() { + let global = handlers.global_object; + let err_value = ::to_js( + &sys::Error::from_code_int(fatal_send_errno, sys::Tag::write), + &global, + ); + handlers.call_error_handler(this_value, &[this_value, err_value])?; + } + // The error handler can destroy the socket. Close without detaching so on_close reports 'close' to JS. + if !self.socket.get().is_detached() { + self.socket.get().close(uws::CloseCode::Normal); + } + Ok(()) + } + /// Takes `ThisPtr`, not `&mut self`: `callback.call(...)` re-enters /// JS which can call `socket.write()`/`end()`/`reload()` on this same /// wrapper via the JS object's `m_ptr`, re-deriving a borrow and mutating @@ -968,7 +1015,8 @@ impl NewSocket { } let handlers = this.get_handlers(); let callback = handlers.on_writable(); - if callback.is_empty() { + // Without a `drain` handler, `end(data)` still has its queued tail to send and the socket to close. + if callback.is_empty() && !this.flags.get().contains(Flags::END_AFTER_FLUSH) { return Ok(()); } @@ -995,31 +1043,22 @@ impl NewSocket { // failWrite) and close the socket so 'error' is followed by 'close'. #[cfg(not(windows))] if !SSL && fatal_send_errno != 0 { - let global = handlers.global_object; let _scope = ScopeExit { socket: this, scope: Some(handlers.enter()), }; - let this_value = this.get_this_value(&global); - let err_value = ::to_js( - &sys::Error::from_code_int(fatal_send_errno, sys::Tag::write), - &global, - ); - handlers.call_error_handler(this_value, &[this_value, err_value])?; - // The error handler can destroy the socket itself; only close a - // still-attached socket. Close without detaching so on_close runs - // and JS observes 'close' (mirrors h2's dead-transport close). - if !this.socket.get().is_detached() { - this.socket.get().close(uws::CloseCode::Normal); - } - return Ok(()); + let this_value = this.get_this_value(&handlers.global_object); + return this.fail_fatal_flush(&handlers, this_value, fatal_send_errno); } log!( "onWritable buffered_data_for_node_net {}", this.buffered_data_for_node_net.get().len() ); // is not writable if we have buffered data or if we are already detached - if this.buffered_data_for_node_net.get().len() > 0 || this.socket.get().is_detached() { + if callback.is_empty() + || this.buffered_data_for_node_net.get().len() > 0 + || this.socket.get().is_detached() + { return Ok(()); } @@ -1242,41 +1281,28 @@ impl NewSocket { // ENOENT, an inexpressible path is EINVAL), and a connect that was // closed rather than failed is ECANCELED; everything else stays // ECONNREFUSED. - let errno_: c_int = if errno == sys::SystemErrno::ENOENT as c_int - || errno == sys::SystemErrno::ENOTSOCK as c_int - || errno == sys::SystemErrno::EACCES as c_int - || errno == sys::SystemErrno::EINVAL as c_int - || errno == sys::SystemErrno::ECONNRESET as c_int - || errno == sys::SystemErrno::EADDRINUSE as c_int - || errno == sys::SystemErrno::EADDRNOTAVAIL as c_int - || errno == sys::SystemErrno::ECONNABORTED as c_int - || errno == sys::SystemErrno::ECANCELED as c_int - { - errno - } else { - sys::SystemErrno::ECONNREFUSED as c_int - }; - let code_ = if errno == sys::SystemErrno::ENOENT as c_int { - BunString::static_("ENOENT") - } else if errno == sys::SystemErrno::ENOTSOCK as c_int { - BunString::static_("ENOTSOCK") - } else if errno == sys::SystemErrno::EACCES as c_int { - BunString::static_("EACCES") - } else if errno == sys::SystemErrno::EINVAL as c_int { - BunString::static_("EINVAL") - } else if errno == sys::SystemErrno::ECONNRESET as c_int { - BunString::static_("ECONNRESET") - } else if errno == sys::SystemErrno::EADDRINUSE as c_int { - BunString::static_("EADDRINUSE") - } else if errno == sys::SystemErrno::EADDRNOTAVAIL as c_int { - BunString::static_("EADDRNOTAVAIL") - } else if errno == sys::SystemErrno::ECONNABORTED as c_int { - BunString::static_("ECONNABORTED") - } else if errno == sys::SystemErrno::ECANCELED as c_int { - BunString::static_("ECANCELED") - } else { - BunString::static_("ECONNREFUSED") - }; + const KEPT: &[(sys::SystemErrno, &str)] = &[ + (sys::SystemErrno::ENOENT, "ENOENT"), + (sys::SystemErrno::ENOTSOCK, "ENOTSOCK"), + (sys::SystemErrno::EACCES, "EACCES"), + (sys::SystemErrno::EINVAL, "EINVAL"), + (sys::SystemErrno::ECONNRESET, "ECONNRESET"), + (sys::SystemErrno::EADDRINUSE, "EADDRINUSE"), + (sys::SystemErrno::EADDRNOTAVAIL, "EADDRNOTAVAIL"), + (sys::SystemErrno::ECONNABORTED, "ECONNABORTED"), + (sys::SystemErrno::ECANCELED, "ECANCELED"), + (sys::SystemErrno::ETIMEDOUT, "ETIMEDOUT"), + (sys::SystemErrno::EHOSTUNREACH, "EHOSTUNREACH"), + (sys::SystemErrno::ENETUNREACH, "ENETUNREACH"), + ]; + let (errno_, code_) = KEPT + .iter() + .find(|(kept, _)| errno == *kept as c_int) + .map_or( + (sys::SystemErrno::ECONNREFUSED as c_int, "ECONNREFUSED"), + |(kept, code)| (*kept as c_int, *code), + ); + let code_ = BunString::static_(code_); #[cfg(windows)] let errno_ = -sys::windows::libuv::e_discriminant_to_uv(errno_ as u16) .unwrap_or(sys::windows::libuv::UV_ECONNREFUSED); @@ -1378,6 +1404,25 @@ impl NewSocket { } } + /// Runs `send` over the queued bytes. It can run JS that closes the socket (a TLS callback, a Duplex transport), and `on_close` must not free them under it. + fn send_queued(&self, send: impl FnOnce(&[u8]) -> R) -> R { + let nested = self.flags.get().contains(Flags::SENDING_QUEUE); + self.update_flags(|f| f.insert(Flags::SENDING_QUEUE)); + let result = send(self.buffered_data_for_node_net.get().slice()); + self.update_flags(|f| f.set(Flags::SENDING_QUEUE, nested)); + result + } + + /// Frees the bytes the socket will never take. `bytesWritten` keeps counting them, as in Node. + fn discard_queued_writes(&self) { + if self.flags.get().contains(Flags::SENDING_QUEUE) { + return; + } + let dropped = self.buffered_data_for_node_net.with_mut(core::mem::take); + self.bytes_written + .set(self.bytes_written.get() + dropped.len() as u64); + } + pub(crate) fn close_and_detach(&self, code: uws::CloseCode) { let socket = self.socket.get(); self.buffered_data_for_node_net @@ -1411,6 +1456,7 @@ impl NewSocket { self.socket.set(SocketHandler::::DETACHED); self.buffered_data_for_node_net .with_mut(|b| b.clear_and_free()); + self.bytes_read.set(0); self.detach_native_callback(); old.close(uws::CloseCode::Failure); self.poll_ref.with_mut(|p| p.unref(js_loop_ctx())); @@ -1519,18 +1565,10 @@ impl NewSocket { ssl_ptr, )) == 0 { - if let Some(server_name) = this.server_name.get() { - let host: &[u8] = server_name.as_ref(); - if !host.is_empty() { - let host_z = bun_core::ZBox::from_bytes(host); - // SAFETY: `host_z` is NUL-terminated; FFI reads until NUL. - unsafe { - boringssl_sys::SSL_set_tlsext_host_name(ssl_ptr, host_z.as_ptr()) - }; - } - } else if let Some(connection) = this.connection.get() { - if let super::listener::UnixOrHost::Host { host, .. } = connection { - let host = bun_core::ip_address::strip_ipv6_brackets(host.as_ref()); + // Only a client sends a server name. A server's `SSL` must keep the ClientHello's. + if !this.acts_as_tls_server() { + if let Some(server_name) = this.server_name.get() { + let host: &[u8] = server_name.as_ref(); if !host.is_empty() { let host_z = bun_core::ZBox::from_bytes(host); // SAFETY: `host_z` is NUL-terminated; FFI reads until NUL. @@ -1541,6 +1579,20 @@ impl NewSocket { ) }; } + } else if let Some(connection) = this.connection.get() { + if let super::listener::UnixOrHost::Host { host, .. } = connection { + let host = bun_core::ip_address::strip_ipv6_brackets(host.as_ref()); + if !host.is_empty() { + let host_z = bun_core::ZBox::from_bytes(host); + // SAFETY: `host_z` is NUL-terminated; FFI reads until NUL. + unsafe { + boringssl_sys::SSL_set_tlsext_host_name( + ssl_ptr, + host_z.as_ptr(), + ) + }; + } + } } } // A server needs the per-connection ALPN selector when it @@ -1551,9 +1603,9 @@ impl NewSocket { // ffi-safe-fn: opaque-ZST `&SSL`/`&SSL_CTX` redecls; // `ssl_ptr` non-null in this branch and `SSL_get_SSL_CTX` // never returns null for a live SSL. + let has_alpn_callback = !this.get_handlers().on_alpn_callback().is_empty(); if this.acts_as_tls_server() - && (this.protos.get().is_some() - || !this.get_handlers().on_alpn_callback().is_empty()) + && (this.protos.get().is_some() || has_alpn_callback) { let ssl_ref = boringssl_sys::SSL::opaque_ref(ssl_ptr); tls_socket_functions::ffi::SSL_set_ex_data( @@ -1561,6 +1613,9 @@ impl NewSocket { 0, this_ptr.cast::(), ); + if has_alpn_callback { + tls_socket_functions::ffi::us_ssl_on_alpn_offer(ssl_ref); + } tls_socket_functions::ffi::SSL_CTX_set_alpn_select_cb( SSL_CTX::opaque_ref(tls_socket_functions::ffi::SSL_get_SSL_CTX( ssl_ref, @@ -1681,8 +1736,16 @@ impl NewSocket { // pending JS write the same way on_writable's tail does, otherwise // the do_socket_write backpressure arms the normal writable // subscription. - let _ = this.internal_flush(); - if this.buffered_data_for_node_net.get().len() == 0 { + let fatal_send_errno = this.internal_flush(); + // A fatal flush empties the buffer too, so the drain below would report the write as sent. + #[cfg(not(windows))] + if fatal_send_errno != 0 { + return this.fail_fatal_flush(&handlers, this_value, fatal_send_errno); + } + #[cfg(windows)] + let _ = fatal_send_errno; + if this.buffered_data_for_node_net.get().len() == 0 && !this.socket.get().is_detached() + { let drain_callback = handlers.on_writable(); if !drain_callback.is_empty() { if let Err(err) = drain_callback.call(&global, this_value, &[this_value]) { @@ -1771,6 +1834,10 @@ impl NewSocket { let callback = handlers.on_end(); if callback.is_empty() { + // `on_writable` sends `end(data)`'s queued tail, then closes. + if this.buffered_data_for_node_net.get().len() > 0 { + return Ok(()); + } this.poll_ref.with_mut(|p| p.unref(js_loop_ctx())); // If you don't handle TCP fin, we assume you're done. @@ -1780,17 +1847,37 @@ impl NewSocket { // the handlers must be kept alive for the duration of the function call // that way if we need to call the error handler, we can - let _scope = ScopeExit { + let scope = ScopeExit { socket: this, scope: Some(handlers.enter()), }; let global = handlers.global_object; let this_value = this.get_this_value(&global); - if let Err(err) = callback.call(&global, this_value, &[this_value]) { - handlers.call_error_handler(this_value, &[this_value, global.take_error(err)])?; + let handled = match callback.call(&global, this_value, &[this_value]) { + Ok(_) => Ok(()), + Err(err) => { + handlers.call_error_handler(this_value, &[this_value, global.take_error(err)]) + } + }; + // Runs the handler's microtasks first, because one of them may call `end(data)`. + drop(scope); + this.end_on_peer_fin(); + handled + } + + /// Closes an `allowHalfOpen: false` socket after the peer's FIN, once `end(data)`'s queued tail is sent. + fn end_on_peer_fin(&self) { + if !self.flags.get().contains(Flags::ENDS_ON_PEER_FIN) + || !matches!(self.socket.get().socket, uws::InternalSocket::Connected(_)) + || self.socket.get().is_closed() + { + return; + } + self.update_flags(|f| f.insert(Flags::END_AFTER_FLUSH)); + if self.can_end_after_flush() { + self.mark_inactive(); } - Ok(()) } /// Takes `ThisPtr` for the same re-entrancy reason as `on_writable`. @@ -2240,6 +2327,7 @@ impl NewSocket { }; this.detach_native_callback(); this.socket.set(SocketHandler::::DETACHED); + this.discard_queued_writes(); // Declared first so it drops last: ticks drain ahead of the teardown. let _cleanup = CloseTeardown { socket: this, @@ -2276,7 +2364,7 @@ impl NewSocket { } // Reached by a nested dispatch (a close from on_open's error branch, - // on_writable's fatal close, a rejected handshake) while the handler + // fail_fatal_flush's close, a rejected handshake) while the handler // above is unwinding with a termination pending: it belongs to that // frame, so this dispatch neither enters JS over it nor claims it. if handlers.global_object.has_exception() { @@ -2331,6 +2419,8 @@ impl NewSocket { if this.socket.get().is_detached() { return Ok(()); } + this.bytes_read + .set(this.bytes_read.get() + data.len() as u64); if this.native_callback.get().on_data(data)? { return Ok(()); } @@ -2450,7 +2540,7 @@ impl NewSocket { this.socket .get() - .set_timeout(c_uint::try_from(t).expect("int cast")); + .set_timeout_without_long_timeout(c_uint::try_from(t).expect("int cast")); Ok(JSValue::UNDEFINED) } @@ -2739,7 +2829,9 @@ impl NewSocket { // node:net fails the write like Node's onWriteComplete; // -1 stays the legacy closed/shutdown sentinel. JSValue::js_number(f64::from(wrote)) - } else if usize::try_from(wrote.max(0)).expect("int cast") == total { + } else if usize::try_from(wrote.max(0)).expect("int cast") == total + && !this.holds_unsent_tls_records() + { JSValue::TRUE } else { JSValue::FALSE @@ -2749,6 +2841,12 @@ impl NewSocket { ) } + /// node:net parks the write callback until the drain that follows. The raw twin of an `upgradeTLS` pair gets no drain. + fn holds_unsent_tls_records(&self) -> bool { + SSL && !self.flags.get().contains(Flags::BYPASS_TLS) + && self.socket.get().ssl_spill_pending() + } + #[bun_jsc::host_fn(method)] pub(crate) fn end_buffered( this: &Self, @@ -2884,7 +2982,7 @@ impl NewSocket { let remaining_in_input_data = &buffer.slice()[input_written..]; self.buffered_data_for_node_net - .with_mut(|b| b.drain_front(written)); + .with_mut(|b| b.consume(written)); if !remaining_in_input_data.is_empty() { // Result intentionally discarded @@ -2903,33 +3001,19 @@ impl NewSocket { let _ = self .buffered_data_for_node_net .with_mut(|b| b.append_slice(buffer.slice())); - // R-2: `write_maybe_corked` takes `&self` and does not touch - // `buffered_data_for_node_net`, so a `JsCell::get()` projection - // is valid for the duration of the call. - let rc = self.write_maybe_corked(self.buffered_data_for_node_net.get().slice()); + let rc = self.send_queued(|bytes| self.write_maybe_corked(bytes)); if rc < 0 { // Fatal write error (or the socket is already shut down/closed): // the buffered bytes can never be delivered - drop them now that // the borrow of their slice has ended. - self.buffered_data_for_node_net - .with_mut(|b| b.clear_and_free()); + self.discard_queued_writes(); } else if rc > 0 { let wrote_u: usize = usize::try_from(rc.max(0)).expect("int cast"); - self.buffered_data_for_node_net.with_mut(|b| { - // did we write everything? - // we can free this temporary buffer. - if wrote_u == b.len() as usize { - b.clear_and_free(); - } else { - // Otherwise, let's move the temporary buffer back. - let len = b.len() as usize - wrote_u; - debug_assert!(len <= b.len() as usize); - debug_assert!(len <= b.capacity() as usize); - // `wrote_u < b.len()` (else branch) — safe overlapping memmove. - b.copy_within(wrote_u.., 0); - b.truncate(len); - } - }); + self.buffered_data_for_node_net + .with_mut(|b| b.consume(wrote_u)); + } + if self.socket.get().is_detached() { + self.discard_queued_writes(); } rc @@ -2955,7 +3039,13 @@ impl NewSocket { return WriteResult::Success { wrote: 0, total: 0 }; } - debug_assert!(self.buffered_data_for_node_net.get().len() == 0); + // `end(data)` left a tail queued, and a send now would jump ahead of it. + if self.buffered_data_for_node_net.get().len() > 0 { + return WriteResult::Success { + wrote: -1, + total: 0, + }; + } let mut encoding_value: JSValue = args[3]; if args[2].is_string() { encoding_value = args[2]; @@ -3036,7 +3126,7 @@ impl NewSocket { let byte_length: usize = 'brk: { if length_value.is_undefined() { - break 'brk buffer.slice().len(); + break 'brk buffer.slice().len().saturating_sub(byte_offset); } if !length_value.is_any_int() { let _ = global.throw_invalid_argument_type(label, "byteLength", "integer"); @@ -3126,7 +3216,8 @@ impl NewSocket { log!("writeOrEnd {}", bytes.len()); let wrote = self.write_maybe_corked(bytes); let uwrote: usize = usize::try_from(wrote.max(0)).expect("int cast"); - if buffer_unwritten_data { + // JS fails a write whose send was fatal (`wrote < -1`), so a later flush must not send its bytes. + if buffer_unwritten_data && wrote >= -1 { let remaining = &bytes[uwrote..]; if !remaining.is_empty() { let _ = self @@ -3185,8 +3276,7 @@ impl NewSocket { // `bytes_written`/`flags`/`buffered_data_for_node_net` across the // re-entrant `do_socket_write`) is no longer needed. if self.buffered_data_for_node_net.get().len() > 0 { - // Neither write call touches `buffered_data_for_node_net`, so a - // `JsCell::get()` projection is valid for the duration of the call. + // A Duplex's `write()` runs JS inside these calls. The slices stay valid unless that JS changes the queue. // The drain-driven retry must detect a fatal send error the same way // the initial write does: once the peer is gone the kernel rejects // every retry (EPIPE/ECONNRESET), and treating that as would-block @@ -3195,17 +3285,14 @@ impl NewSocket { let res: i32 = if self.flags.get().contains(Flags::BYPASS_TLS) { self.do_socket_write(self.buffered_data_for_node_net.get().slice()) } else { - let (res, fatal_errno) = self - .socket - .get() - .write_check_error(self.buffered_data_for_node_net.get().slice()); + let (res, fatal_errno) = + self.send_queued(|bytes| self.socket.get().write_check_error(bytes)); if fatal_errno != 0 { // Same rule as write_maybe_corked: drop the undeliverable // buffer, stop re-arming the writable retry, and report the // errno so the event-loop caller surfaces it (the data was // already acknowledged to JS, so only an 'error' can). - self.buffered_data_for_node_net - .with_mut(|b| b.clear_and_free()); + self.discard_queued_writes(); return fatal_errno; } res @@ -3213,17 +3300,10 @@ impl NewSocket { let written: usize = usize::try_from(res.max(0)).unwrap(); self.bytes_written .set(self.bytes_written.get() + written as u64); - if written > 0 { - self.buffered_data_for_node_net.with_mut(|b| { - if b.len() as usize > written { - let remaining_len = b.len() as usize - written; - // `written < b.len()` — safe overlapping memmove. - b.copy_within(written.., 0); - b.truncate(remaining_len); - } else { - b.clear_and_free(); - } - }); + self.buffered_data_for_node_net + .with_mut(|b| b.consume(written)); + if self.socket.get().is_detached() { + self.discard_queued_writes(); } } @@ -3330,13 +3410,19 @@ impl NewSocket { // `write_or_end` reaches `internal_flush`, which re-enters JS. let _guard = this.ref_guard(); - let result = match this.write_or_end::(global, args.mut_(), false) { + // No writable event reaches the raw half of an `upgradeTLS` pair, so nothing would send its tail. + let queues_tail = !this.flags.get().contains(Flags::BYPASS_TLS); + let result = match this.write_or_end::(global, args.mut_(), queues_tail) { WriteResult::Fail => JSValue::ZERO, WriteResult::Success { wrote, total } => { if wrote >= 0 && usize::try_from(wrote).expect("int cast") == total { let _ = this.internal_flush(); } - JSValue::js_number_from_int32(wrote.max(-1)) + if queues_tail && wrote >= 0 { + JSValue::js_number(total as f64) + } else { + JSValue::js_number_from_int32(wrote.max(-1)) + } } }; Ok(result) @@ -3640,7 +3726,8 @@ impl NewSocket { server_ctx_rejects_unauthorized(ctx_ptr), ), }; - let mut initial_flags = Flags::initial(reject_unauthorized); + let mut initial_flags = + Flags::initial(reject_unauthorized) | (this.flags.get() & Flags::ENDS_ON_PEER_FIN); initial_flags.set(Flags::DEFERS_SERVER_IDENTITY, defers_server_identity); initial_flags.set(Flags::TLS_SERVER_ROLE, is_server); let tls: bun_ptr::ThisPtr = TLSSocket::new(TLSSocket { @@ -3658,8 +3745,9 @@ impl NewSocket { this_value: JsCell::new(JsRef::empty()), poll_ref: JsCell::new(KeepAlive::init()), ref_pollref_on_connect: Cell::new(true), - buffered_data_for_node_net: JsCell::new(Vec::new()), + buffered_data_for_node_net: Default::default(), bytes_written: Cell::new(0), + bytes_read: Cell::new(0), native_callback: JsCell::new(NativeCallbacks::None), twin: JsCell::new(None), verify_error: JsCell::new(None), @@ -3783,8 +3871,10 @@ impl NewSocket { this_value: JsCell::new(JsRef::empty()), poll_ref: JsCell::new(KeepAlive::init()), ref_pollref_on_connect: Cell::new(true), - buffered_data_for_node_net: JsCell::new(Vec::new()), - bytes_written: Cell::new(0), + buffered_data_for_node_net: Default::default(), + // Same fd as the TCP wrapper it replaces, so the count carries over. + bytes_written: Cell::new(this.bytes_written.get()), + bytes_read: Cell::new(this.bytes_read.get()), native_callback: JsCell::new(NativeCallbacks::None), twin: JsCell::new(None), verify_error: JsCell::new(None), @@ -4210,7 +4300,7 @@ pub(crate) struct StoredVerifyError { bitflags::bitflags! { #[derive(Clone, Copy, PartialEq, Eq)] - pub struct Flags: u16 { + pub struct Flags: u32 { const IS_ACTIVE = 1 << 0; /// Prevent onClose from calling into JavaScript while we are finalizing const FINALIZING = 1 << 1; @@ -4220,7 +4310,8 @@ bitflags::bitflags! { const END_AFTER_FLUSH = 1 << 5; const OWNED_PROTOS = 1 << 6; const IS_PAUSED = 1 << 7; - const ALLOW_HALF_OPEN = 1 << 8; + /// Set for `allowHalfOpen: false` on `Bun.listen` / `Bun.connect`. `on_end` closes the socket. + const ENDS_ON_PEER_FIN = 1 << 8; /// Set on the `raw` half of an `upgradeTLS` pair. Writes route through /// `us_socket_raw_write` (bypassing the SSL layer) so node:net can pipe /// pre-handshake bytes / read the underlying TCP stream. @@ -4240,6 +4331,10 @@ bitflags::bitflags! { const TLS_SERVER_ROLE = 1 << 14; /// node:net's `pauseOnConnect`: paused in `on_open` (plain; free for a socket that was registered with `LIBUS_SOCKET_OPEN_PAUSED`) or after the handshake (TLS). const PAUSE_ON_CONNECT = 1 << 15; + /// `ALPNCallback` refused the client's protocols, so `select_alpn_callback` sends the fatal alert. + const ALPN_REFUSED = 1 << 16; + /// A send is reading `buffered_data_for_node_net`, so its caller frees the queue if the socket closes. + const SENDING_QUEUE = 1 << 17; } } @@ -4412,6 +4507,28 @@ pub(super) enum EventState { Close, } +impl TLSSocket { + /// The stream engine asks outside BoringSSL, so this takes the handler scope. + fn on_server_name( + this: bun_ptr::ThisPtr, + name: &core::ffi::CStr, + ) -> super::listener::ServerNameAnswer { + use super::listener::ServerNameAnswer; + if this.socket.get().is_detached() || !this.has_handlers() { + return ServerNameAnswer::Default; + } + let handlers = this.get_handlers(); + if handlers.vm.script_execution_status() != jsc::ScriptExecutionStatus::Running { + return ServerNameAnswer::Default; + } + let _scope = ScopeExit { + socket: this, + scope: Some(handlers.enter()), + }; + super::listener::ask_server_name(this, name) + } +} + impl DuplexUpgradeContext { #[inline(always)] // The `on_*` callbacks take `ThisPtr`, not `&mut self`: they are @@ -4525,6 +4642,23 @@ impl DuplexUpgradeContext { }) } + /// Asks the socket's `serverName` handler. `handle.resumeSNI(...)` answers a `Pending` one. + fn on_server_name(this: bun_ptr::ThisPtr, name: &core::ffi::CStr) { + use super::listener::ServerNameAnswer; + this.debug_assert_kept_alive(); + let answer = this + .tls_this_ptr() + .map_or(ServerNameAnswer::Default, |tls| { + TLSSocket::on_server_name(tls, name) + }); + match answer { + ServerNameAnswer::Pending => {} + ServerNameAnswer::Default => this.upgrade.resolve_server_name(Ok(None)), + ServerNameAnswer::Context(ctx) => this.upgrade.resolve_server_name(Ok(Some(ctx))), + ServerNameAnswer::Refused => this.upgrade.resolve_server_name(Err(())), + } + } + fn on_handshake( this: bun_ptr::ThisPtr, success: bool, @@ -4858,6 +4992,7 @@ pub(crate) fn js_upgrade_duplex_to_tls( )?; // Nothing holds the callback cell until the TLS wrapper below does. let _cell_root = handlers.root_cell(global); + let resolves_server_name = is_server && !handlers.on_server_name().is_empty(); // Resolve the `SSL_CTX*`. Prefer a passed `SecureContext` (the memoised // `tls.createSecureContext` path — what `[buntls]` now returns) so the @@ -4954,8 +5089,9 @@ pub(crate) fn js_upgrade_duplex_to_tls( this_value: JsCell::new(JsRef::empty()), poll_ref: JsCell::new(KeepAlive::init()), ref_pollref_on_connect: Cell::new(true), - buffered_data_for_node_net: JsCell::new(Vec::new()), + buffered_data_for_node_net: Default::default(), bytes_written: Cell::new(0), + bytes_read: Cell::new(0), native_callback: JsCell::new(NativeCallbacks::None), twin: JsCell::new(None), verify_error: JsCell::new(None), @@ -5057,6 +5193,14 @@ pub(crate) fn js_upgrade_duplex_to_tls( server_identity: |c: *mut (), ssl| { DuplexUpgradeContext::server_identity(bun_ptr::ThisPtr::new(c.cast()), ssl) }, + on_server_name: if resolves_server_name { + // SAFETY: `c` is `ctx` below — the live `DuplexUpgradeContext` heap allocation. + Some(|c: *mut (), name: &core::ffi::CStr| { + DuplexUpgradeContext::on_server_name(bun_ptr::ThisPtr::new(c.cast()), name) + }) + } else { + None + }, ctx: duplex_context.cast::<()>(), }, )); @@ -5086,7 +5230,7 @@ pub(crate) fn js_upgrade_duplex_to_tls( let array = JSValue::create_empty_array(global, 2)?; array.put_index(global, 0, tls_js_value)?; - // data, end, drain and close events must be reported + // data, end and close events must be reported array.put_index( global, 1, @@ -5112,6 +5256,14 @@ pub(crate) fn get_buffered_amount(socket: SocketHandle<'_>) -> usize { } } +pub(crate) fn get_bytes_read(socket: SocketHandle<'_>) -> f64 { + match socket { + SocketHandle::Tcp(this) => this.bytes_read.get() as f64, + SocketHandle::Tls(this) => this.bytes_read.get() as f64, + SocketHandle::Ignored(_) => 0.0, + } +} + #[bun_jsc::host_fn] pub(crate) fn js_create_socket_pair( global: &JSGlobalObject, @@ -5237,6 +5389,32 @@ pub(crate) mod testing_apis { Ok(JSValue::js_number(count as f64)) } + /// `sweepSocketTimers` of `bun:internal-for-testing`. + #[bun_jsc::host_fn] + pub(crate) fn js_sweep_socket_timers( + global: &JSGlobalObject, + frame: &CallFrame, + ) -> JsResult { + let [count] = frame.arguments_as_array::<1>(); + let count = count.coerce::(global)?; + let loop_ = uws::Loop::get(); + // SAFETY: `loop_` is this thread's live loop; plain field reads. + let sweeping = unsafe { + !(*loop_).internal_loop_data.iterator.is_null() + || !(*loop_).internal_loop_data.low_prio_iterator.is_null() + }; + if sweeping { + return Err(global.throw(format_args!( + "sweepSocketTimers() cannot be called from a socket timeout handler" + ))); + } + for _ in 0..count { + // SAFETY: `loop_` is this thread's live loop and no sweep is running on it. + unsafe { bun_uws_sys::loop_::us_internal_timer_sweep(loop_) }; + } + Ok(JSValue::UNDEFINED) + } + #[bun_jsc::host_fn] pub(crate) fn js_clear_socket_faults( global: &JSGlobalObject, diff --git a/src/runtime/socket/sockets.classes.ts b/src/runtime/socket/sockets.classes.ts index 854e00ffebed..350a67400115 100644 --- a/src/runtime/socket/sockets.classes.ts +++ b/src/runtime/socket/sockets.classes.ts @@ -9,12 +9,10 @@ function generate(ssl) { memoryCost: true, // Visited slot holding the shared JSSocketHandlers cell, so the callbacks // stay alive as long as any socket that can still fire them. The duplex* - // slots carry the origin stream and the four native listener thunks for a + // slots carry the origin stream and the three native listener thunks for a // TLSSocket driven by an upgraded Duplex (UpgradedDuplex); plain TCP // sockets never populate them. - values: ssl - ? ["handlers", "duplexOrigin", "duplexOnData", "duplexOnEnd", "duplexOnWritable", "duplexOnClose"] - : ["handlers"], + values: ssl ? ["handlers", "duplexOrigin", "duplexOnData", "duplexOnEnd", "duplexOnClose"] : ["handlers"], proto: { getAuthorizationError: { fn: "getAuthorizationError", diff --git a/src/runtime/socket/tls_socket_functions.rs b/src/runtime/socket/tls_socket_functions.rs index 77e19e8f0263..a46f9ab8984b 100644 --- a/src/runtime/socket/tls_socket_functions.rs +++ b/src/runtime/socket/tls_socket_functions.rs @@ -52,6 +52,8 @@ pub(super) mod ffi { pub(crate) const NID_secp384r1: c_int = 715; pub(crate) const NID_secp521r1: c_int = 716; pub(crate) const NID_X25519: c_int = 948; + pub(crate) const NID_X25519MLKEM768: c_int = 965; + pub(crate) const NID_ML_KEM_1024: c_int = 966; pub(crate) const NID_kx_ecdhe: c_int = 952; // ffi-safe-fn: every handle type below (`SSL`, `X509`, `SSL_CIPHER`, @@ -203,27 +205,15 @@ pub(super) mod ffi { // Swaps the cert/key/chain (and session-related state) this connection // serves to those of `ctx`; takes its own reference to `ctx`. pub(crate) fn SSL_set_SSL_CTX(ssl: *mut SSL, ctx: *mut SSL_CTX) -> *mut SSL_CTX; - // Apply `ctx`'s leaf certificate / private key / extra chain directly - // to the connection - SSL_set_SSL_CTX alone does not retarget the - // certificate once ClientHello processing has reached ALPN selection. - pub(crate) fn SSL_CTX_get0_certificate(ctx: *const SSL_CTX) -> *mut core::ffi::c_void; - pub(crate) fn SSL_CTX_get0_privatekey(ctx: *const SSL_CTX) -> *mut core::ffi::c_void; - pub(crate) fn SSL_use_certificate( + /// openssl.c: `setKeyCert()` on a server. Returns 0 if `ctx` has no certificate. + pub(crate) fn us_ssl_set_key_cert( ssl: *mut SSL, - x509: *mut core::ffi::c_void, - ) -> core::ffi::c_int; - pub(crate) fn SSL_use_PrivateKey( - ssl: *mut SSL, - pkey: *mut core::ffi::c_void, - ) -> core::ffi::c_int; - pub(crate) fn SSL_CTX_get0_chain_certs( - ctx: *const SSL_CTX, - out_chain: *mut *mut core::ffi::c_void, - ) -> core::ffi::c_int; - pub(crate) fn SSL_set1_chain( - ssl: *mut SSL, - chain: *mut core::ffi::c_void, + ctx: *mut SSL_CTX, + sid_ctx: *const u8, + sid_ctx_length: usize, ) -> core::ffi::c_int; + /// openssl.c: hands the ALPN protocols of this SSL's ClientHello to `us_dispatch_alpn_offer`. + pub(crate) safe fn us_ssl_on_alpn_offer(ssl: &SSL); // Stores `cb`/`arg` opaquely on the CTX (BoringSSL never derefs `arg` // outside the callback). Opaque-ZST `&SSL_CTX` + by-value fn-ptr + // opaque `*mut c_void` ⇒ no caller-side precondition. @@ -280,6 +270,86 @@ use crate::node::StringOrBuffer; // `socket_body` instance. type This = super::TLSSocket; +/// Gives the getters that only read the `SSL*` their handle. `ssl()` returns `None` once the connection has closed. +pub(crate) trait TlsHandle { + fn ssl(&self) -> Option<*mut boringssl::SSL>; +} + +impl TlsHandle for This { + fn ssl(&self) -> Option<*mut boringssl::SSL> { + self.socket.get().ssl() + } +} + +/// `this` of a JSNodeHTTPServerSocket method, the handle of a node:https `req.socket`. +struct NodeHTTPServerSocket(JSValue); + +impl TlsHandle for NodeHTTPServerSocket { + fn ssl(&self) -> Option<*mut boringssl::SSL> { + let ssl = Bun__NodeHTTPServerSocket__ssl(self.0); + (!ssl.is_null()).then_some(ssl) + } +} + +unsafe extern "C" { + // Null unless `value` is the JSNodeHTTPServerSocket of an open TLS connection. + safe fn Bun__NodeHTTPServerSocket__ssl(value: JSValue) -> *mut boringssl::SSL; +} + +// The methods of JSNodeHTTPServerSocketPrototype.cpp that `tls.TLSSocket` calls on its handle. +#[bun_jsc::host_fn(export = "Bun__NodeHTTPServerSocket__getPeerCertificate")] +fn node_http_get_peer_certificate(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { + get_peer_certificate(&NodeHTTPServerSocket(frame.this()), global, frame) +} +#[bun_jsc::host_fn(export = "Bun__NodeHTTPServerSocket__getCertificate")] +fn node_http_get_certificate(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { + get_certificate(&NodeHTTPServerSocket(frame.this()), global, frame) +} +#[bun_jsc::host_fn(export = "Bun__NodeHTTPServerSocket__getX509Certificate")] +fn node_http_get_x509_certificate(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { + get_x509_certificate(&NodeHTTPServerSocket(frame.this()), global, frame) +} +#[bun_jsc::host_fn(export = "Bun__NodeHTTPServerSocket__getCipher")] +fn node_http_get_cipher(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { + get_cipher(&NodeHTTPServerSocket(frame.this()), global, frame) +} +#[bun_jsc::host_fn(export = "Bun__NodeHTTPServerSocket__getTLSVersion")] +fn node_http_get_tls_version(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { + get_tls_version(&NodeHTTPServerSocket(frame.this()), global, frame) +} +#[bun_jsc::host_fn(export = "Bun__NodeHTTPServerSocket__getEphemeralKeyInfo")] +fn node_http_get_ephemeral_key_info( + global: &JSGlobalObject, + frame: &CallFrame, +) -> JsResult { + get_ephemeral_key_info(&NodeHTTPServerSocket(frame.this()), global, frame) +} +#[bun_jsc::host_fn(export = "Bun__NodeHTTPServerSocket__getTLSFinishedMessage")] +fn node_http_get_tls_finished_message( + global: &JSGlobalObject, + frame: &CallFrame, +) -> JsResult { + get_tls_finished_message(&NodeHTTPServerSocket(frame.this()), global, frame) +} +#[bun_jsc::host_fn(export = "Bun__NodeHTTPServerSocket__getTLSPeerFinishedMessage")] +fn node_http_get_tls_peer_finished_message( + global: &JSGlobalObject, + frame: &CallFrame, +) -> JsResult { + get_tls_peer_finished_message(&NodeHTTPServerSocket(frame.this()), global, frame) +} +#[bun_jsc::host_fn(export = "Bun__NodeHTTPServerSocket__isSessionReused")] +fn node_http_is_session_reused(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { + is_session_reused(&NodeHTTPServerSocket(frame.this()), global, frame) +} +#[bun_jsc::host_fn(export = "Bun__NodeHTTPServerSocket__exportKeyingMaterial")] +fn node_http_export_keying_material( + global: &JSGlobalObject, + frame: &CallFrame, +) -> JsResult { + export_keying_material(&NodeHTTPServerSocket(frame.this()), global, frame) +} + pub(super) fn get_servername( this: &This, global: &JSGlobalObject, @@ -362,11 +432,11 @@ pub(super) fn get_peer_x509_certificate( } pub(super) fn get_x509_certificate( - this: &This, + this: &impl TlsHandle, global: &JSGlobalObject, _frame: &CallFrame, ) -> JsResult { - let Some(ssl_ptr) = this.socket.get().ssl() else { + let Some(ssl_ptr) = this.ssl() else { return Ok(JSValue::UNDEFINED); }; let cert = ffi::SSL_get_certificate(boringssl::SSL::opaque_ref(ssl_ptr)); @@ -379,13 +449,13 @@ pub(super) fn get_x509_certificate( } pub(super) fn get_tls_version( - this: &This, + this: &impl TlsHandle, global: &JSGlobalObject, _frame: &CallFrame, ) -> JsResult { jsc::mark_binding(); - let Some(ssl_ptr) = this.socket.get().ssl() else { + let Some(ssl_ptr) = this.ssl() else { return Ok(JSValue::NULL); }; let version = ffi::SSL_get_version(boringssl::SSL::opaque_ref(ssl_ptr)); @@ -433,7 +503,7 @@ pub(super) fn set_max_send_fragment( } pub(super) fn get_peer_certificate( - this: &This, + this: &impl TlsHandle, global: &JSGlobalObject, frame: &CallFrame, ) -> JsResult { @@ -448,7 +518,7 @@ pub(super) fn get_peer_certificate( abbreviated = arg.to_boolean(); } - let Some(ssl_ptr) = this.socket.get().ssl() else { + let Some(ssl_ptr) = this.ssl() else { return Ok(JSValue::UNDEFINED); }; let is_server_ssl = ffi::SSL_is_server(boringssl::SSL::opaque_ref(ssl_ptr)) != 0; @@ -616,11 +686,11 @@ pub(super) fn get_peer_certificate( } pub(super) fn get_certificate( - this: &This, + this: &impl TlsHandle, global: &JSGlobalObject, _frame: &CallFrame, ) -> JsResult { - let Some(ssl_ptr) = this.socket.get().ssl() else { + let Some(ssl_ptr) = this.ssl() else { return Ok(JSValue::UNDEFINED); }; let cert = ffi::SSL_get_certificate(boringssl::SSL::opaque_ref(ssl_ptr)); @@ -632,11 +702,11 @@ pub(super) fn get_certificate( } pub(super) fn get_tls_finished_message( - this: &This, + this: &impl TlsHandle, global: &JSGlobalObject, _frame: &CallFrame, ) -> JsResult { - let Some(ssl_ptr) = this.socket.get().ssl() else { + let Some(ssl_ptr) = this.ssl() else { return Ok(JSValue::UNDEFINED); }; // We cannot just pass nullptr to SSL_get_finished() @@ -771,11 +841,11 @@ pub(super) fn get_shared_sigalgs( } pub(super) fn get_cipher( - this: &This, + this: &impl TlsHandle, global: &JSGlobalObject, _frame: &CallFrame, ) -> JsResult { - let Some(ssl_ptr) = this.socket.get().ssl() else { + let Some(ssl_ptr) = this.ssl() else { return Ok(JSValue::UNDEFINED); }; let cipher = ffi::SSL_get_current_cipher(boringssl::SSL::opaque_ref(ssl_ptr)); @@ -832,11 +902,11 @@ pub(super) fn get_cipher( } pub(super) fn get_tls_peer_finished_message( - this: &This, + this: &impl TlsHandle, global: &JSGlobalObject, _frame: &CallFrame, ) -> JsResult { - let Some(ssl_ptr) = this.socket.get().ssl() else { + let Some(ssl_ptr) = this.ssl() else { return Ok(JSValue::UNDEFINED); }; // We cannot just pass nullptr to SSL_get_peer_finished() @@ -870,6 +940,7 @@ pub(super) fn get_tls_peer_finished_message( /// `tlsSocket.setKeyCert(secureContext)` - serve this connection's identity /// from the given context: SSL_set_SSL_CTX swaps the cert/key/chain used for /// the rest of the handshake (Node calls it from ALPNCallback / SNICallback). +/// A server keeps its SSL_CTX and only takes the identity and the CAs (`us_ssl_set_key_cert`). pub(crate) fn set_key_cert( this: &This, global: &JSGlobalObject, @@ -891,28 +962,21 @@ pub(crate) fn set_key_cert( // SAFETY: `sc` is a live SecureContext; SSL_set_SSL_CTX takes its own reference. unsafe { let ctx = &(*sc).ctx; - ffi::SSL_set_SSL_CTX(ssl_ptr.cast(), ctx.as_ptr().cast()); - // A client keeps no session id context (see `us_internal_ssl_attach`). if ffi::SSL_is_server(boringssl::SSL::opaque_ref(ssl_ptr)) == 0 { + ffi::SSL_set_SSL_CTX(ssl_ptr.cast(), ctx.as_ptr().cast()); + // A client keeps no session id context (see `us_internal_ssl_attach`). boringssl::SSL_set_session_id_context(ssl_ptr, core::ptr::null(), 0); - } - // SSL_set_SSL_CTX stops retargeting the certificate once ClientHello - // processing has reached ALPN selection, and Node supports calling - // setKeyCert from ALPNCallback - apply the identity directly. - let leaf = ffi::SSL_CTX_get0_certificate(ctx.as_ptr().cast()); - let pkey = ffi::SSL_CTX_get0_privatekey(ctx.as_ptr().cast()); - if !leaf.is_null() && !pkey.is_null() { - let ok_cert = ffi::SSL_use_certificate(ssl_ptr.cast(), leaf); - let ok_key = ffi::SSL_use_PrivateKey(ssl_ptr.cast(), pkey); - let mut ok_chain = 1; - let mut chain: *mut core::ffi::c_void = core::ptr::null_mut(); - if ffi::SSL_CTX_get0_chain_certs(ctx.as_ptr().cast(), &raw mut chain) == 1 - && !chain.is_null() + } else { + let sid_ctx = (*sc).digest.get(); + if ffi::us_ssl_set_key_cert( + ssl_ptr.cast(), + ctx.as_ptr().cast(), + sid_ctx.as_ptr(), + sid_ctx.len(), + ) == 0 { - ok_chain = ffi::SSL_set1_chain(ssl_ptr.cast(), chain); - } - if ok_cert != 1 || ok_key != 1 || ok_chain != 1 { - return Err(global.throw(format_args!("setKeyCert failed to apply the context"))); + // Node's message (`TLSWrap::SetKeyCert`). + return Err(global.throw(format_args!("SetKeyCert"))); } } } @@ -920,11 +984,11 @@ pub(crate) fn set_key_cert( } pub(crate) fn export_keying_material( - this: &This, + this: &impl TlsHandle, global: &JSGlobalObject, frame: &CallFrame, ) -> JsResult { - if this.socket.get().is_detached() { + if this.ssl().is_none() { return Ok(JSValue::UNDEFINED); } @@ -971,7 +1035,7 @@ pub(crate) fn export_keying_material( let buffer = JSValue::create_buffer_from_length(global, buffer_size)?; let buffer_ptr = buffer.as_array_buffer(global).unwrap().ptr; - let Some(ssl_ptr) = this.socket.get().ssl() else { + let Some(ssl_ptr) = this.ssl() else { return Ok(JSValue::UNDEFINED); }; @@ -998,11 +1062,11 @@ pub(crate) fn export_keying_material( } pub(super) fn get_ephemeral_key_info( - this: &This, + this: &impl TlsHandle, global: &JSGlobalObject, _frame: &CallFrame, ) -> JsResult { - let Some(ssl_ptr) = this.socket.get().ssl() else { + let Some(ssl_ptr) = this.ssl() else { return Ok(JSValue::NULL); }; let ssl = boringssl::SSL::opaque_ref(ssl_ptr); @@ -1016,16 +1080,20 @@ pub(super) fn get_ephemeral_key_info( { return Ok(result); } - let (name, size) = match ffi::SSL_get_negotiated_group(ssl) { - ffi::NID_X25519 => ("X25519", 253), - ffi::NID_X9_62_prime256v1 => ("prime256v1", 256), - ffi::NID_secp384r1 => ("secp384r1", 384), - ffi::NID_secp521r1 => ("secp521r1", 521), + let (kind, name, size) = match ffi::SSL_get_negotiated_group(ssl) { + ffi::NID_X25519 => ("ECDH", "X25519", Some(253)), + ffi::NID_X9_62_prime256v1 => ("ECDH", "prime256v1", Some(256)), + ffi::NID_secp384r1 => ("ECDH", "secp384r1", Some(384)), + ffi::NID_secp521r1 => ("ECDH", "secp521r1", Some(521)), + ffi::NID_X25519MLKEM768 => ("TLSGroup", "X25519MLKEM768", None), + ffi::NID_ML_KEM_1024 => ("TLSGroup", "MLKEM1024", None), _ => return Ok(result), }; - result.put(global, b"type", BunString::static_("ECDH").to_js(global)?); + result.put(global, b"type", BunString::static_(kind).to_js(global)?); result.put(global, b"name", BunString::static_(name).to_js(global)?); - result.put(global, b"size", JSValue::js_number(f64::from(size))); + if let Some(size) = size { + result.put(global, b"size", JSValue::js_number(f64::from(size))); + } Ok(result) } @@ -1220,11 +1288,11 @@ pub(super) fn disable_renegotiation( } pub(super) fn is_session_reused( - this: &This, + this: &impl TlsHandle, _global: &JSGlobalObject, _frame: &CallFrame, ) -> JsResult { - let Some(ssl_ptr) = this.socket.get().ssl() else { + let Some(ssl_ptr) = this.ssl() else { return Ok(JSValue::FALSE); }; Ok(JSValue::from( diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index f31aedd4a5eb..e717bea73aed 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -286,9 +286,9 @@ fn bun_fetch_preconnect( // `preconnect` is a free fn in `bun_http::async_http`. Ownership // of `href_raw` transfers here (`is_url_owned: true`). - // A request to an origin the environment proxies never dials it. One after `tls.DEFAULT_CIPHERS` was assigned does not use the default context. + // A request to an origin the environment proxies never dials it. One after `tls.DEFAULT_CIPHERS` or `tls.setDefaultCACertificates` does not use the default context. let vm = VirtualMachine::get(); - if vm.env_loader().get_http_proxy_for(&url).is_some() || vm.tls_default_ciphers().is_some() { + if vm.env_loader().get_http_proxy_for(&url).is_some() || vm.tls_client_defaults_set() { reclaim_href!(); return Ok(JSValue::UNDEFINED); } diff --git a/src/runtime/webcore/fetch/FetchTasklet.rs b/src/runtime/webcore/fetch/FetchTasklet.rs index 2f64e8ccc8cc..4cfcfaad2fe0 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.rs +++ b/src/runtime/webcore/fetch/FetchTasklet.rs @@ -1658,6 +1658,15 @@ impl FetchTasklet { ..Default::default() }; + if matches!(fail, http::Error::Cert(_)) { + // Node's fetch reports the TLS error as `.cause`. + let global = &self.global_this; + let cause = fetch_error.clone().to_error_instance(global); + let err_js = fetch_error.to_type_error_instance(global); + err_js.put(global, b"cause", cause); + return BodyValueError::JSValue(StrongOptional::create(err_js, global)); + } + BodyValueError::SystemTypeError(fetch_error) } diff --git a/src/spawn/process.rs b/src/spawn/process.rs index 8235db3b16e4..69068b64ace3 100644 --- a/src/spawn/process.rs +++ b/src/spawn/process.rs @@ -66,19 +66,7 @@ pub use bun_spawn_sys::{ /// tracked process exits and stays readable until the fd is closed, so a /// plain level-triggered watch is sufficient: when the event fires we /// `wait4(WNOHANG)`, and on success we close the pidfd (which removes it -/// from epoll). -/// -/// `EPOLLONESHOT` is actively harmful here: the kernel disarms the fd the -/// instant `epoll_wait` returns it — before user-space has dispatched it. -/// If a poll callback then re-enters `us_loop_run_bun_tick` (e.g. -/// `expect(p).resolves` → `waitForPromise` → `autoTick`, or any other -/// `waitForPromise` path), the inner tick overwrites the shared -/// `loop->ready_polls`/`num_ready_polls`/`current_ready_poll` and the outer -/// dispatch silently skips its remaining events. A dropped one-shot pidfd -/// event is unrecoverable: the fd is disarmed with no re-arm path, so the -/// process's `'exit'` arrives only when the next unrelated timer wakes the -/// loop. Level-triggered makes a dropped slot harmless — the next -/// `epoll_wait` just returns it again. `rewatch_posix` still re-registers +/// from epoll). `rewatch_posix` still re-registers /// defensively if `wait4` returns 0, which is a harmless `CTL_MOD`. /// /// macOS/FreeBSD watch the pid via `EVFILT_PROC` + `NOTE_EXIT`, which is diff --git a/src/uws/lib.rs b/src/uws/lib.rs index b8e778bbf194..c93b609952ae 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -172,15 +172,15 @@ pub mod ssl_wrapper { BIO, BIO_CTRL_FLUSH, BIO_METHOD, BIO_TYPE_SOURCE_SINK, BIO_clear_retry_flags, BIO_get_data, BIO_get_new_index, BIO_meth_new, BIO_meth_set_ctrl, BIO_meth_set_read, BIO_meth_set_write, BIO_new, BIO_set_data, BIO_set_init, BIO_set_retry_read, - ERR_clear_error, OwnedSslCtx, SSL, SSL_CTX, SSL_ERROR_SSL, SSL_ERROR_SYSCALL, - SSL_ERROR_WANT_READ, SSL_ERROR_WANT_RENEGOTIATE, SSL_ERROR_WANT_WRITE, - SSL_ERROR_ZERO_RETURN, SSL_RECEIVED_SHUTDOWN, SSL_SESSION, SSL_SESSION_free, - SSL_VERIFY_FAIL_IF_NO_PEER_CERT, SSL_VERIFY_NONE, SSL_VERIFY_PEER, SSL_do_handshake, - SSL_free, SSL_get_error, SSL_get_shutdown, SSL_get_verify_result, SSL_is_init_finished, - SSL_new, SSL_pending, SSL_read, SSL_renegotiate, SSL_set_accept_state, SSL_set_bio, - SSL_set_connect_state, SSL_set_renegotiate_mode, SSL_set_session_id_context, - SSL_set_verify, SSL_shutdown, SSL_write, X509_STORE_CTX, ssl_renegotiate_explicit, - ssl_renegotiate_never, + ERR_clear_error, OwnedSslCtx, SSL, SSL_CTX, SSL_ERROR_PENDING_CERTIFICATE, + SSL_ERROR_SSL, SSL_ERROR_SYSCALL, SSL_ERROR_WANT_READ, SSL_ERROR_WANT_RENEGOTIATE, + SSL_ERROR_WANT_WRITE, SSL_ERROR_ZERO_RETURN, SSL_RECEIVED_SHUTDOWN, SSL_SESSION, + SSL_SESSION_free, SSL_VERIFY_FAIL_IF_NO_PEER_CERT, SSL_VERIFY_NONE, SSL_VERIFY_PEER, + SSL_do_handshake, SSL_free, SSL_get_error, SSL_get_servername, SSL_get_shutdown, + SSL_get_verify_result, SSL_is_init_finished, SSL_new, SSL_pending, SSL_read, + SSL_renegotiate, SSL_set_accept_state, SSL_set_bio, SSL_set_connect_state, + SSL_set_renegotiate_mode, SSL_set_session_id_context, SSL_set_verify, SSL_shutdown, + SSL_write, X509_STORE_CTX, ssl_renegotiate_explicit, ssl_renegotiate_never, }; } @@ -218,6 +218,8 @@ pub mod ssl_wrapper { const MAX_RECORD_PLAINTEXT: usize = 16384; /// `SSL3_RT_HEADER_LENGTH + SSL3_RT_SEND_MAX_ENCRYPTED_OVERHEAD` const MAX_RECORD_OVERHEAD: usize = 5 + 88; + /// `kMaxEarlyDataSkipped` in BoringSSL's tls_record.cc: the rejected early data a server skips. + const MAX_EARLY_DATA_SKIPPED: usize = 16384; /// What the one BIO of the `SSL` reads and writes. No borrow spans an `SSL_*` call or a handler. #[derive(Default)] @@ -317,6 +319,7 @@ pub mod ssl_wrapper { inline_reject: Cell, verify_failed: Cell, identity_checked: Cell, + server_name: Cell, /// `SSLWrapper::::server_identity`, which finds its wrapper from this field's address. server_identity: Option< unsafe fn(&CallbackState, &mut boring_sys::SSL) -> bun_boringssl::ServerIdentity, @@ -328,6 +331,19 @@ pub mod ssl_wrapper { keylog: RefCell>>, } + /// State of a server wrapper's `Handlers::on_server_name` request. + #[derive(Clone, Copy, PartialEq, Eq)] + enum ServerName { + /// No resolver, or its answer was applied. + Settled, + /// The owner has a resolver and no ClientHello has arrived. + Armed, + /// The handshake is suspended. The owner is asked once `SSL_do_handshake` has returned. + Parked, + /// The owner was asked. `resolve_server_name` resumes the handshake. + Asked, + } + impl Drop for CallbackState { fn drop(&mut self) { if let Some(session) = self.latest_session.take() { @@ -514,6 +530,8 @@ pub mod ssl_wrapper { pub on_keylog: Option, /// The name check of the verify step. `None`: the owner checks after the handshake. pub server_identity: Option bun_boringssl::ServerIdentity>, + /// Server only: asks for the context of a server name. `resolve_server_name` answers. + pub on_server_name: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, strum::IntoStaticStr)] @@ -596,6 +614,7 @@ pub mod ssl_wrapper { inline_reject: Cell::new(false), verify_failed: Cell::new(false), identity_checked: Cell::new(false), + server_name: Cell::new(ServerName::Settled), server_identity: handlers .server_identity .is_some() @@ -627,6 +646,11 @@ pub mod ssl_wrapper { core::ptr::from_ref(&this.callbacks).cast_mut().cast(), ) }; + if !is_client && handlers.on_server_name.is_some() { + this.callbacks.server_name.set(ServerName::Armed); + // SAFETY: `ssl` is live. + unsafe { us_ssl_wrapper_on_server_name(ssl.as_ptr()) }; + } Ok(this) } @@ -1121,6 +1145,21 @@ pub mod ssl_wrapper { self.trigger_close_callback(); return false; } + if err == boring_sys::SSL_ERROR_PENDING_CERTIFICATE + && self.callbacks.server_name.get() != ServerName::Settled + { + if self.callbacks.server_name.get() == ServerName::Parked { + self.ask_server_name(ssl); + } + // BoringSSL fails a handshake that has to skip more early data than this. + if self.callbacks.server_name.get() == ServerName::Asked + && self.ciphertext.incoming.borrow().len() > MAX_EARLY_DATA_SKIPPED + { + self.drop_suspended_handshake(); + } + // Suspended: a read would drive the handshake again. + return false; + } // as far as I know these are the only errors we want to handle if err != boring_sys::SSL_ERROR_WANT_READ && err != boring_sys::SSL_ERROR_WANT_WRITE { @@ -1151,6 +1190,49 @@ pub mod ssl_wrapper { true } + fn ask_server_name(&self, ssl: NonNull) { + self.callbacks.server_name.set(ServerName::Asked); + let handlers = self.handlers.get(); + // SAFETY: ssl is live and owns the name. A `deinit` in this pass frees it afterwards. + let name = unsafe { boring_sys::SSL_get_servername(ssl.as_ptr(), 0) }; + match handlers.on_server_name { + Some(on_server_name) if !name.is_null() => { + // SAFETY: BoringSSL keeps the server name NUL-terminated. + let name = unsafe { core::ffi::CStr::from_ptr(name) }; + on_server_name(handlers.ctx, name) + } + _ => self.resolve_server_name(Ok(None)), + } + } + + /// Closes without a TLS alert and reports ECONNRESET, like the fd engine. + fn drop_suspended_handshake(&self) { + self.callbacks.server_name.set(ServerName::Settled); + self.flags.set_fatal_error(true); + self.flags + .set_handshake_state(HandshakeState::HandshakeCompleted); + self.trigger_handshake_callback(HandshakeOutcome::PeerClosed); + self.trigger_close_callback(); + } + + /// Answers `on_server_name`: a context, the default one (`None`), or a refusal (`Err`). + pub fn resolve_server_name(&self, answer: Result, ()>) { + let Some(ssl) = self.ssl.get() else { return }; + if self.flags.closed_notified() || self.callbacks.server_name.get() != ServerName::Asked + { + return; + } + let Ok(selected) = answer else { + return self.drop_suspended_handshake(); + }; + self.callbacks.server_name.set(ServerName::Settled); + if let Some(ctx) = selected { + // SAFETY: `ssl` and `ctx` are live, and `ssl` is at its select-certificate step. + unsafe { us_ssl_apply_selected_ctx(ssl.as_ptr(), ctx.as_ptr()) }; + } + self.handle_traffic(); + } + /// Handle the end of a renegotiation if it was pending. This function /// is called when we receive a SSL_ERROR_ZERO_RETURN or successfully /// read data. @@ -1381,7 +1463,10 @@ pub mod ssl_wrapper { self.flush_pending_events(); } else { debug_assert!( - self.flags.closed_notified() || self.ssl.get().is_none(), + self.flags.closed_notified() + || self.ssl.get().is_none() + || self.callbacks.server_name.get() == ServerName::Asked + || self.traffic.get() == Traffic::RerunRequested, "update_handshake_state stopped the pass and left the wrapper open" ); } @@ -1416,6 +1501,29 @@ pub mod ssl_wrapper { } } + /// For `us_select_cert_cb` (openssl.c). 0 suspends the handshake until the owner answers. + #[unsafe(no_mangle)] + extern "C" fn us_ssl_wrapper_select_cert( + state: *const CallbackState, + ssl: *mut boring_sys::SSL, + ) -> c_int { + // SAFETY: openssl.c passes the pointer that `us_ssl_set_wrapper` stored on this live `ssl`. + let state = unsafe { &*state }; + match state.server_name.get() { + ServerName::Settled => 1, + ServerName::Armed => { + // SAFETY: `ssl` is live for this callback. + if unsafe { boring_sys::SSL_get_servername(ssl, 0) }.is_null() { + state.server_name.set(ServerName::Settled); + return 1; + } + state.server_name.set(ServerName::Parked); + 0 + } + ServerName::Parked | ServerName::Asked => 0, + } + } + /// `us_verify_callback` equivalent — let the handshake complete regardless of /// verify result so JS reads `authorizationError` and `rejectUnauthorized` /// decides, instead of BoringSSL aborting mid-flight. @@ -1547,6 +1655,10 @@ pub mod ssl_wrapper { fn us_ssl_take_error_reason(reason: *mut c_char, length: usize) -> c_int; /// openssl.c: makes the callbacks of `ssl` go to `wrapper`. `wrapper` must outlive `ssl`. fn us_ssl_set_wrapper(ssl: *mut boring_sys::SSL, wrapper: *mut c_void); + /// openssl.c: switches `ssl` to the context its server name selected. + fn us_ssl_apply_selected_ctx(ssl: *mut boring_sys::SSL, ctx: *mut boring_sys::SSL_CTX); + /// openssl.c: makes the select-certificate step of `ssl` call `us_ssl_wrapper_select_cert`. + fn us_ssl_wrapper_on_server_name(ssl: *mut boring_sys::SSL); /// openssl.c: the pointer `us_ssl_set_wrapper` stored on the `SSL` that `ctx` verifies, or null. fn us_ssl_wrapper_from_verify(ctx: *mut boring_sys::X509_STORE_CTX) -> *mut c_void; fn i2d_SSL_SESSION(session: *mut boring_sys::SSL_SESSION, out: *mut *mut u8) -> c_int; diff --git a/src/uws_sys/App.rs b/src/uws_sys/App.rs index 5a130245d53f..03cd9f99b789 100644 --- a/src/uws_sys/App.rs +++ b/src/uws_sys/App.rs @@ -1,6 +1,7 @@ use core::ffi::{c_char, c_int, c_uint, c_void}; use core::marker::{PhantomData, PhantomPinned}; +use bun_boringssl_sys::OwnedSslCtx; use bun_core::ZStr; use bun_http_types::Method::Method; @@ -8,7 +9,7 @@ use crate::response::Response; use crate::socket_context::BunSocketContextOptions; use crate::web_socket::c::{uws_app_ws_package_context, uws_ws}; use crate::{ - AnyRequest, AnyResponse, ListenSocket as UwsListenSocket, Opcode, Request, SendStatus, + AnyRequest, AnyResponse, ListenSocket as UwsListenSocket, Opcode, Request, SendStatus, SslCtx, WebSocketBehavior, WebSocketUpgradeContext, thunk, us_socket_t, uws_res, }; use bun_ptr::ThisPtr; @@ -117,6 +118,16 @@ impl App { } } + /// A TLS app that serves `ctx`, which already carries its verify mode. `None` for an app without TLS. + pub fn create_with_ssl_ctx(ctx: &OwnedSslCtx, reject_unauthorized: bool) -> Option<*mut Self> { + if !SSL { + return None; + } + let app = + c::uws_create_app_with_ssl_ctx(SslCtx::opaque_ref(ctx.as_ptr()), reject_unauthorized); + Some(app.cast::()) + } + /// # Safety /// `this` must be a live app handle from [`App::create`]. Caller must not use it after. /// (FFI destroy on an opaque C-owned handle — intentionally not `Drop`.) @@ -523,6 +534,11 @@ pub mod c { user_data: *mut c_void, ); pub(crate) fn uws_create_app(ssl: i32, options: BunSocketContextOptions) -> *mut uws_app_t; + // safe: C++ up-refs `ssl_ctx` before it stores the pointer. + pub(crate) safe fn uws_create_app_with_ssl_ctx( + ssl_ctx: &SslCtx, + reject_unauthorized: bool, + ) -> *mut uws_app_t; pub(crate) fn uws_app_destroy(ssl: i32, app: *mut uws_app_t); pub(crate) safe fn uws_app_set_flags( ssl: i32, diff --git a/src/uws_sys/ListenSocket.rs b/src/uws_sys/ListenSocket.rs index 03a940d7416e..5274addbac88 100644 --- a/src/uws_sys/ListenSocket.rs +++ b/src/uws_sys/ListenSocket.rs @@ -36,6 +36,11 @@ impl ListenSocket { )) } + /// Call before another process gets a duplicate of this socket (see `bsd_accept_socket`). + pub fn set_shared(&mut self) { + us_listen_socket_set_shared(self) + } + /// `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. /// @@ -71,6 +76,7 @@ impl ListenSocket { // `safe fn`. Shims with nullable raw / ctx ptr stay unsafe. unsafe extern "C" { safe fn us_listen_socket_close(ls: &mut ListenSocket); + safe fn us_listen_socket_set_shared(ls: &mut ListenSocket); fn us_listen_socket_add_server_name( ls: *mut ListenSocket, hostname: *const c_char, diff --git a/src/uws_sys/Loop.rs b/src/uws_sys/Loop.rs index 90ab5336fe39..7142e40acd2b 100644 --- a/src/uws_sys/Loop.rs +++ b/src/uws_sys/Loop.rs @@ -526,6 +526,8 @@ mod c { now_ns: u64, ); pub(super) fn us_internal_free_closed_sockets(loop_: *mut Loop); + /// One pass of the socket timeout sweep. Must not run recursively. + pub fn us_internal_timer_sweep(loop_: *mut Loop); pub(super) fn us_loop_close_all_groups(loop_: *mut Loop) -> c_int; #[cfg(not(windows))] pub(super) safe fn uws_get_loop() -> *mut Loop; @@ -544,6 +546,9 @@ mod c { // receiver would create two live `&mut Loop` to the same singleton (UB). pub use c::{us_loop_run, us_wakeup_loop}; +/// For `bun:internal-for-testing` only. +pub use c::us_internal_timer_sweep; + unsafe extern "C" { // safe: no args; frees this thread's lazily-created uws loop if it exists. safe fn bun_free_loop_at_thread_exit(); diff --git a/src/uws_sys/lib.rs b/src/uws_sys/lib.rs index d2ec9aaefafc..324e409cf90a 100644 --- a/src/uws_sys/lib.rs +++ b/src/uws_sys/lib.rs @@ -224,6 +224,11 @@ unsafe extern "C" { safe fn UpgradedDuplex__is_shutdown(this: &UpgradedDuplex) -> bool; safe fn UpgradedDuplex__ssl(this: &UpgradedDuplex) -> *mut bun_boringssl_sys::SSL; safe fn UpgradedDuplex__set_inline_reject(this: &UpgradedDuplex); + fn UpgradedDuplex__sni_resolve( + this: *const UpgradedDuplex, + ctx: *mut bun_boringssl_sys::SSL_CTX, + error: bool, + ); safe fn UpgradedDuplex__latest_session( this: &UpgradedDuplex, ) -> *mut bun_boringssl_sys::SSL_SESSION; @@ -268,6 +273,12 @@ impl UpgradedDuplex { pub(crate) fn set_inline_reject(&self) { UpgradedDuplex__set_inline_reject(self) } + /// Consumes the reference `ctx` carries (null: the default context). + #[inline] + pub(crate) fn sni_resolve(&self, ctx: *mut bun_boringssl_sys::SSL_CTX, error: bool) { + // SAFETY: `self` is a live handle; the callee takes the reference `ctx` carries. + unsafe { UpgradedDuplex__sni_resolve(self, ctx, error) } + } #[inline] pub(crate) fn latest_session(&self) -> *mut bun_boringssl_sys::SSL_SESSION { UpgradedDuplex__latest_session(self) diff --git a/src/uws_sys/libuwsockets.cpp b/src/uws_sys/libuwsockets.cpp index 8d1faf19ebaa..87cce4bdb9af 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -131,6 +131,11 @@ extern "C" return (uws_app_t *)uWS::App::create(socket_context_options); } + uws_app_t *uws_create_app_with_ssl_ctx(struct ssl_ctx_st *ssl_ctx, bool reject_unauthorized) + { + return (uws_app_t *)uWS::SSLApp::create(ssl_ctx, reject_unauthorized); + } + void uws_app_clear_routes(int ssl, uws_app_t *app) { if (ssl) diff --git a/src/uws_sys/socket.rs b/src/uws_sys/socket.rs index 2694ff304583..a20273ad8573 100644 --- a/src/uws_sys/socket.rs +++ b/src/uws_sys/socket.rs @@ -291,6 +291,16 @@ impl NewSocketHandler { self.is_closed() || self.is_shutdown() || self.get_error() != 0 } + /// Returns true if TLS records that `write` already counted as written still wait in user space for the kernel. + pub fn ssl_spill_pending(&self) -> bool { + on_socket!(self.socket; + connected s => s.ssl_spill_pending(), + duplex _d => false, + pipe _p => false, + else => false, + ) + } + /// TLS sockets only. The kernel rejected a send of TLS records, so the error /// code of the close stands for the lost records, not for a failed read. pub fn ssl_send_rejected(&self) -> bool { @@ -500,6 +510,18 @@ impl NewSocketHandler { ) } + /// Fires `on_timeout` after any number of seconds, for owners that don't handle `on_long_timeout`. + pub fn set_timeout_without_long_timeout(&self, seconds: c_uint) { + match self.socket { + InternalSocket::Connected(s) => { + let s = sock(s); + s.set_timeout(seconds); + s.set_long_timeout(0); + } + _ => self.set_timeout(seconds), + } + } + // ── flow control / sockopts ───────────────────────────────────────────── /// A connect that has not completed yet is left alone (like the @@ -561,18 +583,20 @@ impl NewSocketHandler { } /// Resume a handshake suspended by an asynchronous SNICallback. The ctx - /// reference is consumed (freed here when the socket is no longer a real - /// connected socket). + /// reference is consumed (freed here when no TLS engine is left to resume). pub fn sni_resolve(&self, ctx: *mut crate::SslCtx, error: bool) { match self.socket { - InternalSocket::Connected(s) => sock(s).sni_resolve(ctx, error), - _ => { - // The socket is gone; release the reference the caller handed us. - if !ctx.is_null() { - // SAFETY: the caller passed an owned SSL_CTX reference. - unsafe { bun_boringssl_sys::SSL_CTX_free(ctx) }; - } - } + InternalSocket::Connected(s) => return sock(s).sni_resolve(ctx, error), + InternalSocket::UpgradedDuplex(d) => return duplex(d).sni_resolve(ctx, error), + // A named pipe's engine has no resolver (`WindowsNamedPipe::wrapper_handlers`). + #[cfg(windows)] + InternalSocket::Pipe(_) => {} + InternalSocket::Connecting(_) | InternalSocket::Detached => {} + } + // No handshake is waiting for the answer, so release the caller's reference. + if !ctx.is_null() { + // SAFETY: the caller passed an owned SSL_CTX reference. + unsafe { bun_boringssl_sys::SSL_CTX_free(ctx) }; } } diff --git a/src/uws_sys/us_socket_t.rs b/src/uws_sys/us_socket_t.rs index aba2f4d51dca..555d94f1ecec 100644 --- a/src/uws_sys/us_socket_t.rs +++ b/src/uws_sys/us_socket_t.rs @@ -486,6 +486,10 @@ impl us_socket_t { c::us_socket_ssl_send_rejected(self) > 0 } + pub(crate) fn ssl_spill_pending(&self) -> bool { + c::us_socket_ssl_spill_pending(self) > 0 + } + pub(crate) fn queued_input(&self) -> QueuedInput { match c::us_socket_queued_input(self) { LIBUS_QUEUED_INPUT_DATA => QueuedInput::Data, @@ -602,6 +606,7 @@ mod c { pub(super) safe fn us_socket_is_established(s: &us_socket_t) -> i32; pub(super) safe fn us_socket_ssl_send_rejected(s: &us_socket_t) -> c_int; pub(super) safe fn us_socket_queued_input(s: &us_socket_t) -> c_int; + pub(super) safe fn us_socket_ssl_spill_pending(s: &us_socket_t) -> c_uint; /// ssl_ctx is required (the whole point); sni may be null. pub(super) fn us_socket_adopt_tls( diff --git a/test/cli/hot/hot.test.ts b/test/cli/hot/hot.test.ts index f8c6887f5d9c..41a81ed80d67 100644 --- a/test/cli/hot/hot.test.ts +++ b/test/cli/hot/hot.test.ts @@ -12,7 +12,18 @@ import { unlinkSync, writeFileSync, } from "fs"; -import { bunEnv, bunExe, isASAN, isDebug, isLinux, isWindows, tempDir, tmpdirSync, waitForFileToExist } from "harness"; +import { + bunEnv, + bunExe, + isASAN, + isDebug, + isLinux, + isWindows, + tempDir, + tls, + tmpdirSync, + waitForFileToExist, +} from "harness"; import { join } from "path"; const timeout = isDebug ? Infinity : 10_000; @@ -1332,3 +1343,90 @@ it("--hot does not emit beforeExit to the listeners of a failed run", async () = expect(await stdout("still running")).toBe(true); expect(await runner.exited).toBe(0); }); + +// https://github.com/oven-sh/bun/issues/26036 +it.each(isWindows ? ["tcp", "tls"] : ["tcp", "unix", "tls"])( + "should let Bun.listen() take over the previous run's %s address", + async transport => { + const { key, cert } = tls; + const source = (comment: string) => ` + const generation = (globalThis.generation = (globalThis.generation ?? 0) + 1); + if (!globalThis.address) { + if (${JSON.stringify(transport)} === "unix") { + globalThis.address = { unix: "s.sock" }; + } else { + const probe = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); + globalThis.address = { hostname: "127.0.0.1", port: probe.port }; + probe.stop(true); + } + } + Bun.listen({ + ...globalThis.address, + tls: ${transport === "tls" ? JSON.stringify({ key, cert }) : "undefined"}, + socket: { + open: socket => void socket.write("open " + generation + "\\n"), + data: socket => void socket.write("data " + generation + "\\n"), + }, + }); + console.log(JSON.stringify({ generation, ...globalThis.address })); + // ${comment} + `; + using dir = tempDir("hot-listen", { "index.ts": source("first") }); + await using runner = spawn({ + cmd: [bunExe(), "--hot", "run", "index.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", + }); + + // Yields complete lines, whatever the chunk boundaries are. + async function* lines(stream: AsyncIterable) { + let buffered = ""; + for await (const chunk of stream) { + const parts = (buffered + Buffer.from(chunk).toString()).split("\n"); + buffered = parts.pop()!; + yield* parts; + } + } + const runs = lines(runner.stdout); + const nextRun = async () => JSON.parse((await runs.next()).value!); + + // Resolves once the server has greeted the connection. + async function connect(address: { unix: string } | { hostname: string; port: number }) { + while (true) { + const stream = new TransformStream(); + const writer = stream.writable.getWriter(); + const socket = await Bun.connect({ + ...("unix" in address ? { unix: join(String(dir), address.unix) } : address), + tls: transport === "tls" ? { ca: cert } : undefined, + socket: { data: (_socket, chunk) => void writer.write(chunk), close: () => void writer.close() }, + }); + const replies = lines(stream.readable); + const nextReply = async () => (await replies.next()).value; + const greeting = await nextReply(); + // A TLS listener binds again on every run, which drops a connection it hasn't accepted yet. + if (greeting !== undefined) return { socket, greeting, nextReply }; + } + } + + const { generation, ...address } = await nextRun(); + expect(generation).toBe(1); + const first = await connect(address); + expect(first.greeting).toBe("open 1"); + + writeFileSync(join(String(dir), "index.ts"), source("second")); + expect((await nextRun()).generation).toBe(2); + + // A watcher can report one write twice, so a later run may already have taken over. + const second = await connect(address); + expect(second.greeting).toMatch(/^open [2-9]$/); + first.socket.write("x"); + expect(await first.nextReply()).toMatch(/^data [2-9]$/); + + first.socket.end(); + second.socket.end(); + }, + timeout, +); diff --git a/test/cli/run/as-node.test.ts b/test/cli/run/as-node.test.ts index 4d291b398033..cebb00e93872 100644 --- a/test/cli/run/as-node.test.ts +++ b/test/cli/run/as-node.test.ts @@ -132,3 +132,28 @@ describe("fake node cli", () => { expect(result.success).toBe(false); }); }); + +test.concurrent.each(["--tls-keylog", "--network-family-autoselection-attempt-timeout"])( + "%s takes the next argument as its value", + async flag => { + using dir = tempDir("node-net-flags", { + "value.mjs": `throw new Error("ran the flag's value as the script");`, + "app.mjs": `console.log(JSON.stringify({ argv: process.argv.slice(2), execArgv: process.execArgv }));`, + }); + for (const launcher of [[], ["run"], ["--bun", "node"]]) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...launcher, flag, "./value.mjs", "app.mjs", "scriptarg"], + 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({ argv: ["scriptarg"], execArgv: [flag, "./value.mjs"] }), + stderr: "", + exitCode: 0, + }); + } + }, +); diff --git a/test/expectations.txt b/test/expectations.txt index db27e61ffe5e..646c14fe144c 100644 --- a/test/expectations.txt +++ b/test/expectations.txt @@ -23,12 +23,6 @@ # ships ClangCL. Runs everywhere else. (Verified still failing, build 87834.) [ WINDOWS ] test/bundler/native-plugin.test.ts [ SKIP ] # node-gyp needs a ClangCL toolset the Windows agents do not have -# Windows-only gap in named-pipe half-close: WindowsNamedPipe::shutdown() / -# on_read_error(EOF) issue a full uv_close instead of uv_shutdown, so the -# final write after the peer half-closes is lost. Passes on Linux and macOS. -# (Verified still failing on both Windows lanes, build 87834.) -[ WINDOWS ] test/js/node/test/parallel/test-net-pingpong.js [ FAIL ] # named-pipe half-close: count 1000 !== 1001 at close - # Cluster workers sharing a listen port behave differently on Linux, where # SO_REUSEPORT load-balances across the workers' own listeners instead of the # primary distributing accepted connections; the upstream test's expectations diff --git a/test/integration/bun-types/fixture/tcp.ts b/test/integration/bun-types/fixture/tcp.ts index f3d0094c43aa..fe5d58994511 100644 --- a/test/integration/bun-types/fixture/tcp.ts +++ b/test/integration/bun-types/fixture/tcp.ts @@ -68,6 +68,17 @@ Bun.listen({ port: 324, }); +Bun.listen({ + socket: { + data() {}, + }, + hostname: "::", + port: 0, + reusePort: true, + ipv6Only: true, + allowHalfOpen: true, +}); + Bun.listen({ data: { arg: "asdf" }, socket: { diff --git a/test/js/bun/dns/dns-interleave.test.ts b/test/js/bun/dns/dns-interleave.test.ts index da288b466c56..0664e69dd088 100644 --- a/test/js/bun/dns/dns-interleave.test.ts +++ b/test/js/bun/dns/dns-interleave.test.ts @@ -19,7 +19,7 @@ import { bunEnv, bunExe, isLinux, isMusl } from "harness"; // each scenario runs in its own process. Every test also does a real fetch() // through the seeded entry, which is consumed by usockets via // Bun__addrinfo_getRequestResult and start_connections(). -async function run(body: string, timeoutMs = 10_000) { +async function run(body: string, timeoutMs = 10_000, execArgs: string[] = []) { const fixture = /* js */ ` const { dnsCacheSeed } = require("bun:internal-for-testing"); if (typeof dnsCacheSeed !== "function") { @@ -30,7 +30,7 @@ async function run(body: string, timeoutMs = 10_000) { ${body} `; await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", fixture], + cmd: [bunExe(), ...execArgs, "-e", fixture], env: { ...bunEnv, HTTP_PROXY: undefined, @@ -53,6 +53,45 @@ async function run(body: string, timeoutMs = 10_000) { return { out, stderr, exitCode, signal: proc.signalCode }; } +const blackholePrelude = /* js */ ` +const { dlopen } = require("bun:ffi"); +const libc = dlopen("libc.so.6", { + socket: { args: ["int","int","int"], returns: "int" }, + bind: { args: ["int","ptr","int"], returns: "int" }, + listen: { args: ["int","int"], returns: "int" }, + connect: { args: ["int","ptr","int"], returns: "int" }, + close: { args: ["int"], returns: "int" }, + setsockopt:{args: ["int","int","int","ptr","int"],returns:"int" }, +}); +const AF_INET=2, SOCK_STREAM=1, SOCK_NONBLOCK=0o4000, SOL_SOCKET=1, SO_REUSEADDR=2; +function sockaddr_in(ip, port) { + const b = new Uint8Array(16); + new DataView(b.buffer).setUint16(0, AF_INET, true); + b[2] = (port>>8)&0xff; b[3] = port&0xff; + const o = ip.split(".").map(Number); b[4]=o[0]; b[5]=o[1]; b[6]=o[2]; b[7]=o[3]; + return b; +} +const fds = []; +// listen(fd, 0) + fill the one-slot accept queue so further SYNs to +// ip:port are silently dropped (same EINPROGRESS a filtered network +// produces). +function blackhole(ip, port) { + const fd = libc.symbols.socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) throw new Error("socket() failed"); + fds.push(fd); + const one = new Int32Array([1]); + libc.symbols.setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, one, 4); + if (libc.symbols.bind(fd, sockaddr_in(ip, port), 16) !== 0) + throw new Error("bind(" + ip + ":" + port + ") failed"); + if (libc.symbols.listen(fd, 0) !== 0) throw new Error("listen() failed"); + for (let i = 0; i < 8; i++) { + const c = libc.symbols.socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0); + fds.push(c); + libc.symbols.connect(c, sockaddr_in(ip, port), 16); + } +} +`; + describe.concurrent("getaddrinfo interleave (RFC 8305)", () => { test("fetch() through a seeded 4xAAAA + 4xA entry connects via the interleaved order", async () => { const { out, stderr, exitCode, signal } = await run(/* js */ ` @@ -123,42 +162,7 @@ describe.concurrent("getaddrinfo interleave (RFC 8305)", () => { async () => { const { out, stderr, exitCode, signal } = await run( /* js */ ` - const { dlopen } = require("bun:ffi"); - const libc = dlopen("libc.so.6", { - socket: { args: ["int","int","int"], returns: "int" }, - bind: { args: ["int","ptr","int"], returns: "int" }, - listen: { args: ["int","int"], returns: "int" }, - connect: { args: ["int","ptr","int"], returns: "int" }, - close: { args: ["int"], returns: "int" }, - setsockopt:{args: ["int","int","int","ptr","int"],returns:"int" }, - }); - const AF_INET=2, SOCK_STREAM=1, SOCK_NONBLOCK=0o4000, SOL_SOCKET=1, SO_REUSEADDR=2; - function sockaddr_in(ip, port) { - const b = new Uint8Array(16); - new DataView(b.buffer).setUint16(0, AF_INET, true); - b[2] = (port>>8)&0xff; b[3] = port&0xff; - const o = ip.split(".").map(Number); b[4]=o[0]; b[5]=o[1]; b[6]=o[2]; b[7]=o[3]; - return b; - } - const fds = []; - // listen(fd, 0) + fill the one-slot accept queue so further SYNs to - // ip:port are silently dropped (same EINPROGRESS a filtered network - // produces). - function blackhole(ip, port) { - const fd = libc.symbols.socket(AF_INET, SOCK_STREAM, 0); - if (fd < 0) throw new Error("socket() failed"); - fds.push(fd); - const one = new Int32Array([1]); - libc.symbols.setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, one, 4); - if (libc.symbols.bind(fd, sockaddr_in(ip, port), 16) !== 0) - throw new Error("bind(" + ip + ":" + port + ") failed"); - if (libc.symbols.listen(fd, 0) !== 0) throw new Error("listen() failed"); - for (let i = 0; i < 8; i++) { - const c = libc.symbols.socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0); - fds.push(c); - libc.symbols.connect(c, sockaddr_in(ip, port), 16); - } - } + ${blackholePrelude} using server = Bun.serve({ port: 0, hostname: "::1", fetch: () => new Response("ok via ::1") }); const port = server.port; @@ -199,6 +203,44 @@ describe.concurrent("getaddrinfo interleave (RFC 8305)", () => { }, 20_000, ); + + test.skipIf(!isLinux || isMusl)( + "fetch() reaches the fifth address when two of the first four hang and two are refused", + async () => { + const { out, stderr, exitCode, signal } = await run( + /* js */ ` + ${blackholePrelude} + + using server = Bun.serve({ port: 0, hostname: "127.0.0.6", fetch: () => new Response("ok via 127.0.0.6") }); + const port = server.port; + blackhole("127.0.0.2", port); + blackhole("127.0.0.3", port); + // Nothing listens on 127.0.0.4 or 127.0.0.5, so those connects are refused at once. + const host = "he-refill-" + port + ".test"; + const order = dnsCacheSeed(host, ["127.0.0.2", "127.0.0.3", "127.0.0.4", "127.0.0.5", "127.0.0.6"]); + + let result; + try { + const res = await fetch("http://" + host + ":" + port + "/", { signal: AbortSignal.timeout(4000) }); + result = { ok: true, order, body: await res.text() }; + } catch (e) { + result = { ok: false, order, err: e?.name ?? String(e) }; + } + console.log(JSON.stringify(result)); + for (const fd of fds) libc.symbols.close(fd); + process.exit(0); + `, + 20_000, + ); + expect({ out, stderr, exitCode, signal }).toEqual({ + out: { ok: true, order: [4, 4, 4, 4, 4], body: "ok via 127.0.0.6" }, + stderr: expect.any(String), + exitCode: 0, + signal: null, + }); + }, + 20_000, + ); }); // Loopback: the connect-path resolver asks getaddrinfo with AI_ADDRCONFIG, and @@ -401,3 +443,36 @@ describe.concurrent( } }, ); + +// https://github.com/oven-sh/bun/issues/40178 +describe.concurrent("dns result order applies to fetch() and Bun.connect()", () => { + const seed = (addresses: string[]) => /* js */ ` + console.log(JSON.stringify({ order: dnsCacheSeed("order.test", ${JSON.stringify(addresses)}) })); + `; + + test.each([ + ["ipv4first", ["::1", "::1", "127.0.0.1", "127.0.0.1"], [4, 6, 4, 6]], + ["ipv6first", ["127.0.0.1", "127.0.0.1", "::1", "::1"], [6, 4, 6, 4]], + ["verbatim", ["::1", "127.0.0.1"], [6, 4]], + ["ipv4first", ["::1", "::1", "::1"], [6, 6, 6]], + ])("--dns-result-order=%s packs %j as %j", async (flag, addresses, order) => { + const { out, stderr, exitCode } = await run(seed(addresses), 10_000, [`--dns-result-order=${flag}`]); + expect({ out, stderr, exitCode }).toEqual({ out: { order }, stderr: "", exitCode: 0 }); + }); + + test("dns.setDefaultResultOrder() applies to later lookups and drops cached ones", async () => { + const { out, stderr, exitCode } = await run(/* js */ ` + dnsCacheSeed("stale.test", ["::1", "127.0.0.1"]); + const before = Bun.dns.getCacheStats().size; + require("node:dns").setDefaultResultOrder("ipv4first"); + const after = Bun.dns.getCacheStats().size; + const order = dnsCacheSeed("order.test", ["::1", "::1", "127.0.0.1", "127.0.0.1"]); + console.log(JSON.stringify({ before, after, order })); + `); + expect({ out, stderr, exitCode }).toEqual({ + out: { before: 1, after: 0, order: [4, 6, 4, 6] }, + stderr: "", + exitCode: 0, + }); + }); +}); diff --git a/test/js/bun/dns/dns-prefetch.test.ts b/test/js/bun/dns/dns-prefetch.test.ts index 4ae35531278f..f7bf621f1379 100644 --- a/test/js/bun/dns/dns-prefetch.test.ts +++ b/test/js/bun/dns/dns-prefetch.test.ts @@ -40,6 +40,124 @@ test("a failed connect evicts the host's DNS cache entry", async () => { }); }); +// The test above always misses the cache. A completed cache hit with a single address connects +// directly, without a us_connecting_socket_t, and has to evict too. Each scenario seeds the +// process-global cache and runs in its own process. `cacheMisses` staying 0 proves every connect +// was served by the seeded entry. +describe.concurrent("eviction through a completed cache hit", () => { + async function run(body: string) { + const script = ` + const { dnsCacheSeed } = require("bun:internal-for-testing"); + const stats = () => { + const { cacheHitsCompleted, cacheMisses, size, errors } = Bun.dns.getCacheStats(); + return { cacheHitsCompleted, cacheMisses, size, errors }; + }; + const connect = (hostname, port, tls = false) => + Bun.connect({ hostname, port, tls, socket: { data() {} } }).then( + socket => { + socket.end(); + return "connected"; + }, + error => error.code, + ); + // Nothing listens on this port anymore, so connects to it are refused. + const closed = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); + const refusedPort = closed.port; + closed.stop(); + console.log(JSON.stringify(await (async () => { ${body} })())); + `; + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], env: bunEnv, stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { result: JSON.parse(stdout.trim() || "null"), stderr, exitCode }; + } + + // This path attaches TLS before the connect finishes, so check a refused TLS connect as well. + test("a refused connect to a single-address entry evicts it", async () => { + expect( + await run(` + dnsCacheSeed("single.test", ["127.0.0.1"]); + dnsCacheSeed("single-tls.test", ["127.0.0.1"]); + const seeded = stats(); + const plain = await connect("single.test", refusedPort); + const afterPlain = stats(); + const tls = await connect("single-tls.test", refusedPort, true); + return { seeded, plain, afterPlain, tls, afterTls: stats() }; + `), + ).toEqual({ + result: { + seeded: { cacheHitsCompleted: 0, cacheMisses: 0, size: 2, errors: 0 }, + plain: "ECONNREFUSED", + afterPlain: { cacheHitsCompleted: 1, cacheMisses: 0, size: 1, errors: 1 }, + tls: "ECONNREFUSED", + afterTls: { cacheHitsCompleted: 2, cacheMisses: 0, size: 0, errors: 2 }, + }, + stderr: "", + exitCode: 0, + }); + }); + + // A leaked reference would keep the entry in the cache after the second connect. + test("a successful connect keeps the entry and releases its reference", async () => { + expect( + await run(` + const server = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); + dnsCacheSeed("flaky.test", ["127.0.0.1"]); + const first = await connect("flaky.test", server.port); + const afterSuccess = stats(); + server.stop(true); + const second = await connect("flaky.test", server.port); + return { first, afterSuccess, second, afterRefused: stats() }; + `), + ).toEqual({ + result: { + first: "connected", + afterSuccess: { cacheHitsCompleted: 1, cacheMisses: 0, size: 1, errors: 0 }, + second: "ECONNREFUSED", + afterRefused: { cacheHitsCompleted: 2, cacheMisses: 0, size: 0, errors: 1 }, + }, + stderr: "", + exitCode: 0, + }); + }); + + // The worker starts the connect and never returns to its event loop, so only terminate() closes + // the still-connecting socket. That says nothing about the host, so the entry stays. + // Starting a worker alone takes ~3s in a debug build. + test("a connect closed before it completes keeps the entry and releases its reference", async () => { + expect( + await run(` + const { Worker } = require("node:worker_threads"); + dnsCacheSeed("abandoned.test", ["127.0.0.1"]); + const worker = new Worker( + 'const { parentPort, workerData: port } = require("node:worker_threads");' + + 'Bun.connect({ hostname: "abandoned.test", port, socket: { data() {} } }).catch(() => {});' + + 'parentPort.postMessage("connecting");' + + "for (;;) {}", + { eval: true, workerData: refusedPort }, + ); + await new Promise((resolve, reject) => { + worker.once("message", resolve); + worker.once("error", reject); + }); + const whileConnecting = stats(); + await worker.terminate(); + const afterAbandon = stats(); + const code = await connect("abandoned.test", refusedPort); + return { whileConnecting, afterAbandon, code, afterRefused: stats() }; + `), + ).toEqual({ + result: { + whileConnecting: { cacheHitsCompleted: 1, cacheMisses: 0, size: 1, errors: 0 }, + afterAbandon: { cacheHitsCompleted: 1, cacheMisses: 0, size: 1, errors: 0 }, + code: "ECONNREFUSED", + afterRefused: { cacheHitsCompleted: 2, cacheMisses: 0, size: 0, errors: 1 }, + }, + stderr: "", + exitCode: 0, + }); + }, 30_000); +}); + test("dns.getCacheStats returns a new plain object of integers", () => { const stats = dns.getCacheStats(); expect(Object.getPrototypeOf(stats)).toBe(Object.prototype); diff --git a/test/js/bun/http/bun-serve-ssl.test.ts b/test/js/bun/http/bun-serve-ssl.test.ts index 9402ed2f0f97..d238f1c2207d 100644 --- a/test/js/bun/http/bun-serve-ssl.test.ts +++ b/test/js/bun/http/bun-serve-ssl.test.ts @@ -725,3 +725,56 @@ describe.concurrent("client certificate policy", () => { }); }); }); + +// node:https passes the context it built in createServer() this way. No other server reads the key. +test("Bun.serve() ignores tls.secureContext", async () => { + const pem = (name: string) => readFileSync(join(import.meta.dir, "../../node/test/fixtures/keys", name), "utf8"); + const secureContext = tls.createSecureContext({ key: pem("agent1-key.pem"), cert: pem("agent1-cert.pem") }).context; + using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + tls: { ...tlsCert, secureContext } as Bun.TLSOptions, + fetch: () => new Response("ok"), + }); + const client = tls.connect({ port: server.port, host: "127.0.0.1", ca: tlsCert.cert }); + await once(client, "secureConnect"); + const { CN } = client.getPeerCertificate().subject; + client.destroy(); + expect(CN).toBe("server-bun"); +}); + +describe("tls.minVersion and tls.maxVersion take node:tls's version names", () => { + const serve = (bounds: Bun.TLSOptions) => + Bun.serve({ port: 0, hostname: "127.0.0.1", tls: { ...tlsCert, ...bounds }, fetch: () => new Response("ok") }); + + test.each(["TLSv1.2", "TLSv1.3"] as const)("Bun.serve negotiates %s when it's both bounds", async version => { + using server = serve({ minVersion: version, maxVersion: version }); + const client = tls.connect({ port: server.port, host: "127.0.0.1", ca: tlsCert.cert, servername: "localhost" }); + await once(client, "secureConnect"); + const negotiated = client.getProtocol(); + client.destroy(); + expect(negotiated).toBe(version); + }); + + test("fetch applies them", async () => { + using server = serve({ minVersion: "TLSv1.3" }); + const request = (bounds: Bun.TLSOptions) => + fetch(`https://localhost:${server.port}/`, { tls: { ca: tlsCert.cert, ...bounds } }).then( + response => response.text(), + () => "refused", + ); + expect([await request({ minVersion: "TLSv1.3" }), await request({ maxVersion: "TLSv1.2" })]).toEqual([ + "ok", + "refused", + ]); + }); + + test("another string throws ERR_TLS_INVALID_PROTOCOL_VERSION", () => { + expect(() => serve({ minVersion: "TLSv9" as any })).toThrow( + expect.objectContaining({ + code: "ERR_TLS_INVALID_PROTOCOL_VERSION", + message: '"TLSv9" is not a valid minimum TLS protocol version', + }), + ); + }); +}); diff --git a/test/js/bun/http/serve-listen.test.ts b/test/js/bun/http/serve-listen.test.ts index ee635d88f979..d30f1b5af95f 100644 --- a/test/js/bun/http/serve-listen.test.ts +++ b/test/js/bun/http/serve-listen.test.ts @@ -1,6 +1,7 @@ import { file, serve } from "bun"; import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isLinux, isWindows, tmpdirSync } from "harness"; +import { existsSync } from "node:fs"; import type { NetworkInterfaceInfo } from "node:os"; import { networkInterfaces } from "node:os"; import { join } from "node:path"; @@ -161,6 +162,44 @@ describe.each([ }); }); +// "localhost" usually resolves to both ::1 and 127.0.0.1. +test("throws EADDRINUSE when another server holds the port on localhost", () => { + using occupant = serve({ hostname: "localhost", port: 0, fetch: () => new Response() }); + const { port } = occupant; + for (const listen of [ + () => serve({ hostname: "localhost", port, fetch: () => new Response() }), + () => Bun.listen({ hostname: "localhost", port, socket: { data() {} } }), + ]) { + expect(() => listen().stop(true)).toThrow(expect.objectContaining({ code: "EADDRINUSE" })); + } +}); + +// Linux refuses [::]:port while a listener holds [::1]:port. +test.skipIf(!isLinux || !hasIPv6)( + "without a hostname, falls back to 0.0.0.0 when the port is only taken on IPv6", + () => { + using occupant = Bun.listen({ hostname: "::1", port: 0, socket: { data() {} } }); + using server = serve({ port: occupant.port, fetch: () => new Response() }); + expect(server.address).toEqual({ address: "0.0.0.0", family: "IPv4", port: occupant.port }); + }, +); + +test.skipIf(isWindows)("rejects a unix socket path with a null byte instead of truncating it", async () => { + const dir = tmpdirSync(); + const unix = join(dir, "nul\0byte.sock"); + for (const listen of [ + () => serve({ unix, fetch: () => new Response() }), + () => Bun.listen({ unix, socket: { data() {} } }), + ]) { + expect(() => listen().stop(true)).toThrow(expect.objectContaining({ code: "EINVAL" })); + } + expect(existsSync(join(dir, "nul"))).toBe(false); + + // A listener at the truncated path must not be reachable through the longer one. + using truncated = Bun.listen({ unix: join(dir, "nul"), socket: { data() {} } }); + await expect(Bun.connect({ unix, socket: { data() {} } })).rejects.toMatchObject({ code: "EINVAL" }); +}); + // A name that cannot be a host name (a space, a colon, an empty label, // brackets around anything but an IPv6 literal) is rejected in-process with the // resolver's ENOTFOUND, the error Bun.connect reports for the same name. The diff --git a/test/js/bun/http/serve-tls-prehandshake-reap-fixture.ts b/test/js/bun/http/serve-tls-prehandshake-reap-fixture.ts index 9857865e2f8d..60c02099c5ee 100644 --- a/test/js/bun/http/serve-tls-prehandshake-reap-fixture.ts +++ b/test/js/bun/http/serve-tls-prehandshake-reap-fixture.ts @@ -1,8 +1,8 @@ // A burst of TCP connections that never send a byte to a TLS Bun.serve listener // must all be reaped by the server's idle timeout. On Linux, TCP_DEFER_ACCEPT -// releases the whole burst at once and the synthetic readable dispatch parks -// the overflow in the SSL low-priority queue, which the timer sweep must still -// be able to see. +// releases the whole burst at once, more than the 5 handshake reads one loop +// iteration allows. The overflow has no input, so it must not be parked in the +// SSL low-priority queue, where timeouts are held back. // // Prints one JSON line: { opened, held } where held is the count the server // had not closed by the deadline. diff --git a/test/js/bun/net/fin-with-final-data-fixture.ts b/test/js/bun/net/fin-with-final-data-fixture.ts new file mode 100644 index 000000000000..6e0ee202e00c --- /dev/null +++ b/test/js/bun/net/fin-with-final-data-fixture.ts @@ -0,0 +1,71 @@ +// kqueue sets EV_EOF on the readable event that carries the last of the peer's data. The read loop +// stops after one ~512 KiB recv() when 25 or more polls are ready, so acting on that flag there +// closes the socket with the rest of the body still in the kernel buffer. +// +// Each response is one ~600 KiB chunked body written in a single burst followed by FIN, and 32 +// requests run at once so a batch routinely carries 25 ready polls. Every request must receive the +// full body. +import net from "node:net"; + +const payload = Buffer.alloc(600 * 1024, "x"); +const reply = Buffer.concat([ + Buffer.from( + "HTTP/1.1 200 OK\r\n" + + "Connection: close\r\n" + + "Transfer-Encoding: chunked\r\n" + + "\r\n" + + payload.length.toString(16) + + "\r\n", + ), + payload, + Buffer.from("\r\n0\r\n\r\n"), +]); + +const server = net.createServer(socket => { + socket.on("error", () => {}); + socket.once("data", () => { + socket.end(reply); + }); +}); + +await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); +}); +const { port } = server.address() as net.AddressInfo; + +const ITERATIONS = 128; +const CONCURRENCY = 32; +let failures = 0; +let firstError = ""; + +async function once() { + try { + const res = await fetch(`http://127.0.0.1:${port}/`, { keepalive: false }); + const buf = await res.arrayBuffer(); + if (buf.byteLength !== payload.length) { + failures++; + firstError ||= `body mismatch: got ${buf.byteLength} bytes, want ${payload.length}`; + } + } catch (e: any) { + failures++; + firstError ||= `${e?.code ?? e?.name}: ${e?.message}`; + } +} + +let i = 0; +async function worker() { + while (i < ITERATIONS) { + i++; + await once(); + } +} +await Promise.all(Array.from({ length: CONCURRENCY }, worker)); + +server.close(); + +if (failures > 0) { + console.error(`FAIL ${failures}/${ITERATIONS}: ${firstError}`); + process.exit(1); +} +console.log(`OK ${ITERATIONS}`); diff --git a/test/js/bun/net/keep-event-loop-alive.js b/test/js/bun/net/keep-event-loop-alive.js index adccc488e523..2289b740daa2 100644 --- a/test/js/bun/net/keep-event-loop-alive.js +++ b/test/js/bun/net/keep-event-loop-alive.js @@ -1,28 +1,34 @@ (async () => { const port = process.argv[2] ? parseInt(process.argv[2]) : null; const hostname = process.argv[3] ? process.argv[3] : "localhost"; + // The test passes a port that refuses connections. + const refusedPort = process.argv[4] ? parseInt(process.argv[4]) : 9999; await Bun.sleep(10); // failed connection - console.log("test 1: failed connection"); try { const socket = await Bun.connect({ hostname: hostname, - port: 9999, + port: refusedPort, socket: { data() {} }, }); socket.end(); - } catch (error) {} + console.log("test 1: failed connection did not fail"); + } catch (error) { + console.log("test 1: failed connection", error.code); + } // failed connection tls - console.log("test 2: failed connection [tls]"); try { const socket = await Bun.connect({ hostname: hostname, - port: 9999, + port: refusedPort, socket: { data() {} }, tls: true, }); socket.end(); - } catch (error) {} + console.log("test 2: failed connection [tls] did not fail"); + } catch (error) { + console.log("test 2: failed connection [tls]", error.code); + } if (port) { // successful connection console.log("test 3: successful connection"); diff --git a/test/js/bun/net/socket-huge-fixture.js b/test/js/bun/net/socket-huge-fixture.js index 590816558c7f..c622e2444f5d 100644 --- a/test/js/bun/net/socket-huge-fixture.js +++ b/test/js/bun/net/socket-huge-fixture.js @@ -1,7 +1,12 @@ import { connect, listen } from "bun"; import { fillRepeating } from "harness"; -const huge = Buffer.alloc(1024 * 1024 * 1024); +// The test passes a smaller transfer size on debug and ASAN builds. +const size = process.argv[2] ? Number.parseInt(process.argv[2], 10) : 1024 * 1024 * 1024; +if (!Number.isInteger(size) || size <= 0) { + throw new Error(`transfer size must be a positive integer, got ${process.argv[2]}`); +} +const huge = Buffer.alloc(size); for (let i = 0; i < 1024; i++) { huge[i] = (Math.random() * 255) | 0; } @@ -17,22 +22,18 @@ var server = listen({ data: { sent: 0 }, socket: { open(socket) { - console.time("send 1 GB (server)"); socket.data.sent = socket.write(huge); if (socket.data.sent === huge.length) { - console.timeEnd("send 1 GB (server)"); socket.shutdown(); - serverResolve(); + serverResolve(socket.data.sent); } }, async drain(socket) { socket.data.sent += socket.write(huge.subarray(socket.data.sent)); - // console.error("Sent", socket.data.sent, "bytes"); if (socket.data.sent === huge.length) { - console.timeEnd("send 1 GB (server)"); socket.shutdown(); - serverResolve(); + serverResolve(socket.data.sent); } }, }, @@ -44,25 +45,22 @@ const socket = await connect({ data: { received: 0 }, socket: { open(socket) { - console.time("recv 1 GB (client)"); socket.data.received = 0; }, data(socket, data) { socket.data.received += data.length; - // console.error("Received", data.length, "bytes"); received.update(data); if (socket.data.received === huge.length) { - console.timeEnd("recv 1 GB (client)"); socket.end(); - clientResolve(); + clientResolve(socket.data.received); } }, }, }); -await Promise.all([clientPromise, serverPromise]); +const [receivedBytes, sentBytes] = await Promise.all([clientPromise, serverPromise]); server.stop(true); socket.end(); @@ -70,4 +68,5 @@ if (received.digest("hex") !== Bun.SHA256.hash(huge, "hex")) { throw new Error("Received data doesn't match sent data"); } +console.log(`sent ${sentBytes} bytes, received ${receivedBytes} bytes, digest matches`); process.exit(0); diff --git a/test/js/bun/net/socket-syscall-fault.test.ts b/test/js/bun/net/socket-syscall-fault.test.ts index fc00d86d9281..821484f2abf1 100644 --- a/test/js/bun/net/socket-syscall-fault.test.ts +++ b/test/js/bun/net/socket-syscall-fault.test.ts @@ -74,10 +74,10 @@ test.concurrent.skipIf(skip)( // 2 rounds x (1 primer + 2 waves x 32). opened: 130, closed: 130, - // Every wave socket is closed mid-handshake by the unread-ciphertext - // guard, which reports the handshake as failed; the primers are - // closed by stop(true) and report nothing. - handshakeFailed: 128, + // Every socket is closed mid-handshake, which reports the handshake as + // failed: the wave sockets by the unread-ciphertext guard, the primers + // by stop(true) with their flight still unsent after the peer's FIN. + handshakeFailed: 130, handshakeOk: 0, data: 0, errors: 0, @@ -163,6 +163,57 @@ test.concurrent( 60_000, ); +// The loop turned a parked socket's reads off, so its peer isn't idle and its timeouts must wait. The fixture parks 15 +// of 20 Bun.listen sockets, runs the timeout sweep by hand and counts `timeout` handlers as [while parked, after release]. +// Firing on a parked socket gives oneTick "20,0", losing the timeout in the queue gives "5,0". +test.concurrent( + "TLS low-prio queue: a parked socket's timeout fires after the queue releases it", + async () => { + expect(await runFixture("tls-parked-handshake-timeout-fixture.ts")).toEqual({ + summary: { opened: 20, oneTick: ["5,15"], unarmed: [0, 0], parkedTooLong: [5, 15], errors: 0 }, + signalCode: null, + exitCode: 0, + stderrTail: "", + }); + }, + 60_000, +); + +// A parked socket's input can't wake the loop, so the loop must keep iterating until the queue is empty. The fixture +// parks 59 of 64 sockets and pauses all but the one released last, so no readable event drives the next iteration. +// Reaching that socket takes ceil(58 / 5) = 12 iterations, plus its handshake's round trips. +test.concurrent( + "TLS low-prio queue: the loop releases parked sockets without an unrelated event", + async () => { + const result = await runFixture("tls-parked-handshake-turns-fixture.ts"); + expect(result).toEqual({ + summary: { opened: 64, clientHandshakes: 6, iterationsToHandshake: expect.any(Number), errors: 0 }, + signalCode: null, + exitCode: 0, + stderrTail: "", + }); + const { iterationsToHandshake } = result.summary as { iterationsToHandshake: number }; + expect(iterationsToHandshake).toBeGreaterThanOrEqual(12); + expect(iterationsToHandshake).toBeLessThanOrEqual(24); + }, + 60_000, +); + +// With a deferred accept (Linux) the accept path dispatches a new socket's first readable event itself. Of 12 +// connections that arrive with input, the server times out the 5 it read, and the 7 it parked only after their release. +test.concurrent.skipIf(!isLinux)( + "TLS low-prio queue: Bun.serve parks an accepted connection that has input once the handshake budget is spent", + async () => { + expect(await runFixture("tls-serve-deferred-accept-fixture.ts")).toEqual({ + summary: { clients: 12, closedWhileParked: 5, closedAfterRelease: 12 }, + signalCode: null, + exitCode: 0, + stderrTail: "", + }); + }, + 60_000, +); + // us_poll_start_rc wraps uv_poll_init_socket on Windows and EPOLL_CTL_ADD / // kevent on posix. On Windows the return value was ignored, so an ioctlsocket // FIONBIO failure left a never-initialized uv_poll_t that uv_unref/uv_poll_start @@ -251,6 +302,33 @@ describe.skipIf(!fault.available())("poll_start failure is reported, not a crash ); }); +test.concurrent.skipIf(skip).each(["ETIMEDOUT", "EHOSTUNREACH", "ENETUNREACH"])( + "Bun.connect to a hostname reports %s when that's why its last address failed", + async errno => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { socketFaultInjection: fault, dnsCacheSeed } = require("bun:internal-for-testing"); + dnsCacheSeed("two-addresses.test", ["127.0.0.1", "127.0.0.1"]); + fault.set({ syscall: "connect", action: "errno", errno: ${JSON.stringify(errno)}, repeat: -1 }); + const result = await Bun.connect({ hostname: "two-addresses.test", port: 1, socket: { data() {} } }).then( + () => "connected", + e => e.code, + ); + fault.clear(); + console.log(result, Bun.dns.getCacheStats().size);`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // The failed connect also evicts the name from the DNS cache. + expect({ stdout, stderr, exitCode }).toEqual({ stdout: errno + " 0\n", stderr: "", exitCode: 0 }); + }, +); + // A paused socket whose peer hung up is taken out of epoll by the dispatcher // (EPOLLHUP is level-triggered and cannot be masked) and registered again by // resume(), which is a fresh EPOLL_CTL_ADD and can fail the way the first one diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index 841df8b0f2c6..19ccb46d0c88 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -1,15 +1,18 @@ import type { Socket } from "bun"; import { connect, fileURLToPath, SocketHandler, spawn } from "bun"; import { createSocketPair, setSocketOptions, socketFaultInjection } from "bun:internal-for-testing"; +import { estimateShallowMemoryUsageOf } from "bun:jsc"; import { afterAll, afterEach, describe, expect, it, jest } from "bun:test"; -import { generateKeyPairSync } from "crypto"; -import { closeSync, fstatSync, readFileSync } from "fs"; +import { generateKeyPairSync, randomFillSync } from "crypto"; +import { closeSync, fstatSync, readFileSync, readSync, writeSync } from "fs"; import { bunEnv, bunExe, bunRun, expectMaxObjectTypeCount, getMaxFD, + isASAN, + isDebug, isLinux, isWindows, libcPathForDlopen, @@ -22,51 +25,78 @@ import net from "node:net"; import { join } from "node:path"; import { Duplex } from "node:stream"; import { createSecureContext, connect as tlsConnect, createServer as tlsCreateServer, type TlsOptions } from "node:tls"; + +type ErrorShape = { name: string; code: string | undefined; message: string }; +const errorShape = (error: unknown): ErrorShape => { + const e = error as Error & { code?: string }; + return { name: e.name, code: e.code, message: e.message }; +}; +const refusedShape: ErrorShape = { name: "Error", code: "ECONNREFUSED", message: "Failed to connect" }; + +// Returns a port that refuses connections. An established connection owns it as its local port, +// so a concurrent `port: 0` listener can't be assigned the same port. `localAddress` makes the +// holder bind() first. Linux can reuse a port that connect() picked as the local port of a later +// connect(), and a connect to its own port succeeds. +async function refusedPort() { + const sink = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); + const holder = await Bun.connect({ + hostname: "127.0.0.1", + port: sink.port, + localAddress: "127.0.0.1", + socket: { data() {} }, + } as any); + return { + port: holder.localPort, + [Symbol.dispose]() { + holder.terminate(); + sink.stop(true); + }, + }; +} + describe.concurrent("socket", () => { it("should throw when a socket from a file descriptor has a bad file descriptor", async () => { const open = jest.fn(); const close = jest.fn(); const data = jest.fn(); - const connectError = jest.fn(() => {}); - { - expect( - async () => - await Bun.connect({ - fd: getMaxFD() + 1024, - socket: { - open, - close, - data, - connectError, - }, - } as any), - ).toThrow(); - Bun.gc(true); - await Bun.sleep(10); - Bun.gc(true); - } - - await Bun.sleep(10); + const connectErrorCalled = Promise.withResolvers(); + const connectError = jest.fn((_socket: Socket, error: Error) => connectErrorCalled.resolve(errorShape(error))); + const rejection = await Bun.connect({ + fd: getMaxFD() + 1024, + socket: { + open, + close, + data, + connectError, + }, + } as any).then( + () => null, + error => errorShape(error), + ); + // Don't pin the code. Platforms map a bad descriptor to different errnos. + const failedToConnect = { name: "Error", message: "Failed to connect" }; + expect({ rejection, connectError: await connectErrorCalled.promise }).toMatchObject({ + rejection: failedToConnect, + connectError: failedToConnect, + }); + Bun.gc(true); expect(open).toHaveBeenCalledTimes(0); expect(close).toHaveBeenCalledTimes(0); expect(data).toHaveBeenCalledTimes(0); expect(connectError).toHaveBeenCalledTimes(1); - connectError.mockClear(); - open.mockClear(); - close.mockClear(); - data.mockClear(); }); it.skipIf(isWindows)( "should not crash when a socket from a file descriptor is already closed after opening", async () => { const [server, client] = createSocketPair(); + const closed = Promise.withResolvers(); const open = jest.fn(); - const close = jest.fn(); + const close = jest.fn(() => closed.resolve()); const data = jest.fn(); closeSync(client); { - const socket = await Bun.connect({ + await Bun.connect({ fd: server, socket: { open, @@ -75,17 +105,14 @@ describe.concurrent("socket", () => { }, } as any); Bun.gc(true); - await Bun.sleep(10); + // The peer end is already closed, so the adopted fd reads EOF and closes. + await closed.promise; Bun.gc(true); } - await Bun.sleep(10); expect(open).toHaveBeenCalledTimes(1); expect(close).toHaveBeenCalledTimes(1); expect(data).toHaveBeenCalledTimes(0); - open.mockClear(); - close.mockClear(); - data.mockClear(); }, ); @@ -119,7 +146,7 @@ describe.concurrent("socket", () => { }); it("should keep process alive only when active", async () => { - const { exited, stdout, stderr } = spawn({ + await using proc = spawn({ cmd: [bunExe(), "echo.js"], cwd: import.meta.dir, stdout: "pipe", @@ -128,23 +155,25 @@ describe.concurrent("socket", () => { env: bunEnv, }); - expect(await exited).toBe(0); - expect(await stderr.text()).toBe(""); - var lines = (await stdout.text()).split(/\r?\n/); - expect( - lines.filter(function (line) { - return line.startsWith("[Server]"); - }), - ).toEqual(["[Server] OPENED", "[Server] GOT request", "[Server] CLOSED"]); - expect( - lines.filter(function (line) { - return line.startsWith("[Client]"); - }), - ).toEqual(["[Client] OPENED", "[Client] GOT response", "[Client] CLOSED"]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // The server and client lines interleave, but each side's own order is fixed. + const lines = stdout.split(/\r?\n/).filter(Boolean); + expect({ + stderr, + server: lines.filter(line => line.startsWith("[Server]")), + client: lines.filter(line => line.startsWith("[Client]")), + other: lines.filter(line => !line.startsWith("[Server]") && !line.startsWith("[Client]")), + }).toEqual({ + stderr: "", + server: ["[Server] OPENED", "[Server] GOT request", "[Server] CLOSED"], + client: ["[Client] OPENED", "[Client] GOT response", "[Client] CLOSED"], + other: [], + }); + expect(exitCode).toBe(0); }); it("connect without top level await should keep process alive", async () => { - const server = Bun.listen({ + using server = Bun.listen({ socket: { open(socket) {}, data(socket, data) {}, @@ -152,22 +181,29 @@ describe.concurrent("socket", () => { hostname: "localhost", port: 0, }); - const proc = Bun.spawn({ - cmd: [bunExe(), "keep-event-loop-alive.js", String(server.port), server.hostname], + using refused = await refusedPort(); + await using proc = Bun.spawn({ + cmd: [bunExe(), "keep-event-loop-alive.js", String(server.port), server.hostname, String(refused.port)], cwd: import.meta.dir, env: bunEnv, + stdout: "pipe", + stderr: "pipe", }); - await proc.exited; - try { - expect(proc.exitCode).toBe(0); - expect(await proc.stdout.text()).toContain("event loop was not killed"); - } finally { - server.stop(); - } + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr }).toEqual({ + stdout: + "test 1: failed connection ECONNREFUSED\n" + + "test 2: failed connection [tls] ECONNREFUSED\n" + + "test 3: successful connection\n" + + "test 4: successful connection [tls]\n" + + "success: event loop was not killed\n", + stderr: "", + }); + expect(exitCode).toBe(0); }); it("connect() should return the socket object", async () => { - const { exited, stdout, stderr } = spawn({ + await using proc = spawn({ cmd: [bunExe(), "connect-returns-socket.js"], cwd: import.meta.dir, stdout: "pipe", @@ -176,10 +212,24 @@ describe.concurrent("socket", () => { env: bunEnv, }); - const [stderrText, stdoutText, exitCode] = await Promise.all([stderr.text(), stdout.text(), exited]); - // stderr first so an ASAN/LSAN report isn't swallowed behind a bare "exit 134". - expect(stderrText).toBe(""); - expect(stdoutText).toContain("CLIENT RECEIVED"); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // The two echoed writes can arrive in one or two data events, so compare their concatenation. + const lines = stdout.split(/\r?\n/).filter(Boolean); + const RECEIVED = "CLIENT RECEIVED "; + expect({ + stderr, + opened: lines.filter(line => line.endsWith(" OPENED")).toSorted(), + localPort: lines.filter(line => /^\d+$/.test(line)).length, + received: lines + .filter(line => line.startsWith(RECEIVED)) + .map(line => line.slice(RECEIVED.length)) + .join(""), + }).toEqual({ + stderr: "", + opened: ["CLIENT OPENED", "SERVER OPENED"], + localPort: 1, + received: "Hello, world!From returned socket", + }); expect(exitCode).toBe(0); }); @@ -201,54 +251,41 @@ describe.concurrent("socket", () => { }).toThrow(); }); - it("should reject on connection error, calling both connectError() and rejecting the promise", done => { - var data = {}; - connect({ + it("should reject on connection error, calling both connectError() and rejecting the promise", async () => { + using refused = await refusedPort(); + const data = {}; + const events: string[] = []; + const unexpected = (name: string) => () => void events.push(name); + let connectErrorArgs: { sameData: boolean; error: ErrorShape } | undefined; + const rejection = await connect({ data, hostname: "localhost", - port: 55555, + port: refused.port, socket: { connectError(socket, error) { - expect(socket).toBeDefined(); - expect(socket.data).toBe(data); - expect(error).toBeDefined(); - expect(error.name).toBe("Error"); - expect((error as any).code).toBe("ECONNREFUSED"); - expect(error.message).toBe("Failed to connect"); - }, - data() { - done(new Error("Unexpected data()")); - }, - drain() { - done(new Error("Unexpected drain()")); - }, - close() { - done(new Error("Unexpected close()")); - }, - end() { - done(new Error("Unexpected end()")); - }, - error() { - done(new Error("Unexpected error()")); - }, - open() { - done(new Error("Unexpected open()")); - }, + events.push("connectError"); + connectErrorArgs = { sameData: socket.data === data, error: errorShape(error) }; + }, + data: unexpected("data"), + drain: unexpected("drain"), + close: unexpected("close"), + end: unexpected("end"), + error: unexpected("error"), + open: unexpected("open"), }, }).then( - () => done(new Error("Promise should reject instead")), - err => { - expect(err).toBeDefined(); - expect(err.name).toBe("Error"); - expect(err.code).toBe("ECONNREFUSED"); - expect(err.message).toBe("Failed to connect"); - - done(); - }, + () => null, + error => errorShape(error), ); + expect({ events, connectErrorArgs, rejection }).toEqual({ + events: ["connectError"], + connectErrorArgs: { sameData: true, error: refusedShape }, + rejection: refusedShape, + }); }); it("should not leak memory when connect() fails", async () => { + using refused = await refusedPort(); await (async () => { // windows can take more than a second per connection const quantity = isWindows ? 10 : 50; @@ -256,7 +293,7 @@ describe.concurrent("socket", () => { for (let i = 0; i < quantity; i++) { promises[i] = connect({ hostname: "localhost", - port: 55555, + port: refused.port, socket: { connectError(socket, error) {}, data() {}, @@ -268,7 +305,8 @@ describe.concurrent("socket", () => { }, }); } - await Promise.allSettled(promises); + const settled = await Promise.allSettled(promises); + expect(settled.map(result => result.status)).toEqual(new Array(quantity).fill("rejected")); promises.length = 0; })(); @@ -276,96 +314,136 @@ describe.concurrent("socket", () => { }, 60_000); // this also tests we mark the promise as handled if connectError() is called - it("should handle connection error", done => { - var data = {}; + it("should handle connection error", async () => { + using refused = await refusedPort(); + const data = {}; + const events: string[] = []; + const unexpected = (name: string) => () => void events.push(name); + const connectErrorCalled = Promise.withResolvers<{ sameData: boolean; error: ErrorShape }>(); connect({ data, hostname: "localhost", - port: 55555, + port: refused.port, socket: { connectError(socket, error) { - expect(socket).toBeDefined(); - expect(socket.data).toBe(data); - expect(error).toBeDefined(); - expect(error.name).toBe("Error"); - expect(error.message).toBe("Failed to connect"); - expect((error as any).code).toBe("ECONNREFUSED"); - done(); - }, - data() { - done(new Error("Unexpected data()")); - }, - drain() { - done(new Error("Unexpected drain()")); - }, - close() { - done(new Error("Unexpected close()")); - }, - end() { - done(new Error("Unexpected end()")); - }, - error() { - done(new Error("Unexpected error()")); - }, - open() { - done(new Error("Unexpected open()")); - }, + events.push("connectError"); + connectErrorCalled.resolve({ sameData: socket.data === data, error: errorShape(error) }); + }, + data: unexpected("data"), + drain: unexpected("drain"), + close: unexpected("close"), + end: unexpected("end"), + error: unexpected("error"), + open: unexpected("open"), }, }); + expect(await connectErrorCalled.promise).toEqual({ sameData: true, error: refusedShape }); + expect(events).toEqual(["connectError"]); }); it("socket.timeout works", async () => { - try { - const { promise, resolve } = Promise.withResolvers(); - - var server = Bun.listen({ - socket: { - binaryType: "buffer", - open(socket) { - socket.write("hello"); - }, - data(socket, data) { - if (data.toString("utf-8") === "I have timed out!") { - client.end(); - resolve(undefined); - } - }, + const { promise, resolve } = Promise.withResolvers(); + let timeouts = 0; + let received = ""; + using server = Bun.listen({ + socket: { + binaryType: "buffer", + open(socket) { + socket.write("hello"); }, - hostname: "localhost", - port: 0, - }); - var client = await connect({ - hostname: server.hostname, - port: server.port, - socket: { - timeout(socket) { - socket.write("I have timed out!"); - }, - data() {}, - drain() {}, - close() {}, - end() {}, - error() {}, - open() {}, + data(socket, data) { + received += data.toString("utf-8"); + if (received === "I have timed out!") { + client.end(); + resolve(received); + } }, - }); - client.timeout(1); - await promise; - } finally { - server!.stop(true); - } + }, + hostname: "localhost", + port: 0, + }); + const client = await connect({ + hostname: server.hostname, + port: server.port, + socket: { + timeout(socket) { + timeouts++; + socket.write("I have timed out!"); + }, + data() {}, + drain() {}, + close() {}, + end() {}, + error() {}, + open() {}, + }, + }); + client.timeout(1); + // The timeout sweep runs every few seconds, so this waits for the next sweep. + expect(await promise).toBe("I have timed out!"); + expect(timeouts).toBe(1); }, 60_000); - it("should allow large amounts of data to be sent and received", async () => { - const { stderr, exitCode } = await bunRun(fileURLToPath(new URL("./socket-huge-fixture.js", import.meta.url))); - if (exitCode !== 0) console.error(stderr); + // Runs in a child because the sweeps also advance every other socket's timeout on the thread. + it("socket.timeout() above 240 seconds fires once, when it's due", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + import { sweepSocketTimers } from "bun:internal-for-testing"; + const accepted = Promise.withResolvers(); + let timeouts = 0; + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { open: accepted.resolve, timeout: () => void timeouts++, data() {} }, + }); + using client = await Bun.connect({ hostname: "127.0.0.1", port: server.port, socket: { data() {} } }); + const socket = await accepted.promise; + // One sweep is 4 seconds. + for (const seconds of [240, 244, 300, 960, 964, 86400]) { + timeouts = 0; + socket.timeout(seconds); + sweepSocketTimers(seconds / 4 - 1); + const early = timeouts; + sweepSocketTimers(1); + const due = timeouts; + sweepSocketTimers(500); + console.log(seconds, early, due, timeouts); + } + `, + ], + env: bunEnv, + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout.trim().split("\n")).toEqual([ + "240 0 1 1", + "244 0 1 1", + "300 0 1 1", + "960 0 1 1", + "964 0 1 1", + "86400 0 1 1", + ]); expect(exitCode).toBe(0); + }); + + it("should allow large amounts of data to be sent and received", async () => { + // Hashing and copying 1 GiB takes several seconds on debug and ASAN builds. + const size = isDebug || isASAN ? 256 * 1024 * 1024 : 1024 * 1024 * 1024; + const fixture = fileURLToPath(new URL("./socket-huge-fixture.js", import.meta.url)); + expect(await bunRun([fixture, String(size)])).toSpawn(`sent ${size} bytes, received ${size} bytes, digest matches`); }, 60_000); it.skipIf(isWindows)("kqueue should not dispatch spurious drain events on readable", async () => { expect(await bunRun(fileURLToPath(new URL("./kqueue-filter-coalesce-fixture.ts", import.meta.url)))).toSpawn(); }); + it("reads the whole body when the peer's FIN arrives with the final data on a busy loop", async () => { + expect(await bunRun(fileURLToPath(new URL("./fin-with-final-data-fixture.ts", import.meta.url)))).toSpawn(); + }); + // ssl_update_handshake set last_write_failed on SSL_ERROR_WANT_READ, so a // paused (writable-armed) socket with a stalled handshake re-fired writable // every tick at 100% CPU until the peer's handshake bytes arrived. @@ -381,8 +459,8 @@ describe.concurrent("socket", () => { stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ stdout, exitCode }).toEqual({ stdout: "OK\n", exitCode: 0 }); - void stderr; + expect({ stdout, stderr }).toEqual({ stdout: "OK\n", stderr: "" }); + expect(exitCode).toBe(0); }, 30_000); it("it should not crash when getting a ReferenceError on client socket open", async () => { @@ -394,13 +472,8 @@ describe.concurrent("socket", () => { }, }); { - const { resolve, reject, promise } = Promise.withResolvers(); - let client: Socket | null = null; - const timeout = setTimeout(() => { - client?.end(); - reject(new Error("Timeout")); - }, 1000); - client = await Bun.connect({ + const { resolve, promise } = Promise.withResolvers<{ handler: string; name?: string; message?: string }>(); + const client = await Bun.connect({ port: server.port!, hostname: server.hostname!, socket: { @@ -410,19 +483,18 @@ describe.concurrent("socket", () => { socket.write(bytes); }, error(socket, error) { - clearTimeout(timeout); - resolve(error); + resolve({ handler: "error", name: error.name, message: error.message }); }, close(socket) { // we need the close handler - resolve({ message: "Closed" }); + resolve({ handler: "close" }); }, data(socket, data) {}, }, }); - const result: any = await promise; - expect(result?.message).toBe("bytes is not defined"); + expect(await promise).toEqual({ handler: "error", name: "ReferenceError", message: "bytes is not defined" }); + client.end(); } }); @@ -435,13 +507,8 @@ describe.concurrent("socket", () => { }, }); { - const { resolve, reject, promise } = Promise.withResolvers(); - let client: Socket | null = null; - const timeout = setTimeout(() => { - client?.end(); - reject(new Error("Timeout")); - }, 1000); - client = await Bun.connect({ + const { resolve, promise } = Promise.withResolvers<{ handler: string; name?: string; message?: string }>(); + const client = await Bun.connect({ port: server.port, hostname: server.hostname, socket: { @@ -450,19 +517,18 @@ describe.concurrent("socket", () => { return new Error("CustomError"); }, error(socket, error) { - clearTimeout(timeout); - resolve(error); + resolve({ handler: "error", name: error.name, message: error.message }); }, close(socket) { // we need the close handler - resolve({ message: "Closed" }); + resolve({ handler: "close" }); }, data(socket, data) {}, }, }); - const result: any = await promise; - expect(result?.message).toBe("CustomError"); + expect(await promise).toEqual({ handler: "error", name: "Error", message: "CustomError" }); + client.end(); } }); @@ -478,30 +544,32 @@ describe.concurrent("socket", () => { }, }); - const { resolve, reject, promise } = Promise.withResolvers(); + const { resolve, promise } = Promise.withResolvers(); - let client: Socket | null = null; - let opened = false; - client = await Bun.connect({ + let opens = 0; + let connectErrors = 0; + let received = ""; + await Bun.connect({ port: server.port, hostname: "localhost", socket: { open(socket) { - expect(opened).toBe(false); - opened = true; + opens++; }, connectError(socket, error) { - expect().fail("connectError should not be called"); + connectErrors++; }, close(socket) { resolve(); }, - data(socket, data) {}, + data(socket, data) { + received += data.toString(); + }, }, }); await promise; - expect(opened).toBe(true); + expect({ opens, connectErrors, received }).toEqual({ opens: 1, connectErrors: 0, received: "Hello" }); }); it.skipIf(isWindows)("should not leak file descriptors when connecting", async () => { @@ -520,34 +588,32 @@ describe.concurrent("socket", () => { }, }); - const { resolve, reject, promise } = Promise.withResolvers(); - - let client: Socket | null = null; - let hadError = false; - try { - client = await Bun.connect({ - port: server.port, - hostname: "::1", - socket: { - open(socket) { - expect().fail("open should not be called, the connection should fail"); - }, - connectError(socket, error) { - expect(hadError).toBe(false); - hadError = true; - resolve(); - }, - close(socket) { - expect().fail("close should not be called, the connection should fail"); - }, - data(socket, data) {}, + const events: string[] = []; + const rejection = await Bun.connect({ + port: server.port, + hostname: "::1", + socket: { + open(socket) { + events.push("open"); }, - }); - } catch (e) {} + connectError(socket, error) { + events.push("connectError"); + }, + close(socket) { + events.push("close"); + }, + data(socket, data) {}, + }, + }).then( + () => null, + error => errorShape(error), + ); - await Bun.sleep(50); - await promise; - expect(hadError).toBe(true); + // Don't pin the code. A host without IPv6 fails this connect with a different errno. + expect({ events, rejection }).toMatchObject({ + events: ["connectError"], + rejection: { name: "Error", message: "Failed to connect" }, + }); }); it("should connect directly when using an ip address", async () => { @@ -562,50 +628,64 @@ describe.concurrent("socket", () => { }, }); - const { resolve, reject, promise } = Promise.withResolvers(); + const { resolve, promise } = Promise.withResolvers(); - let client: Socket | null = null; - let opened = false; - client = await Bun.connect({ + let opens = 0; + let connectErrors = 0; + let received = ""; + await Bun.connect({ port: server.port, hostname: "127.0.0.1", socket: { open(socket) { - expect(opened).toBe(false); - opened = true; + opens++; }, connectError(socket, error) { - expect().fail("connectError should not be called"); + connectErrors++; }, close(socket) { resolve(); }, - data(socket, data) {}, + data(socket, data) { + received += data.toString(); + }, }, }); await promise; - expect(opened).toBe(true); + expect({ opens, connectErrors, received }).toEqual({ opens: 1, connectErrors: 0, received: "Hello" }); }); it("should not call drain before handshake", async () => { - const { promise, resolve, reject } = Promise.withResolvers(); + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls, + socket: { + data() {}, + error() {}, + }, + }); + const { promise, resolve } = Promise.withResolvers<{ success: boolean; authorized: boolean }>(); + let handshook = false; + let drainsBeforeHandshake = 0; using socket = await Bun.connect({ - hostname: "www.example.com", - tls: true, - port: 443, + hostname: "127.0.0.1", + tls: { ca: tls.cert }, + port: server.port, socket: { drain() { - if (!socket.authorized) { - reject(new Error("Socket not authorized")); - } + if (!handshook) drainsBeforeHandshake++; }, - handshake() { - resolve(); + handshake(socket, success) { + handshook = true; + resolve({ success, authorized: socket.authorized }); }, + data() {}, }, }); - await promise; + expect(await promise).toEqual({ success: true, authorized: true }); + expect(drainsBeforeHandshake).toBe(0); expect(socket.authorized).toBe(true); }); // A server-side upgradeTLS handed only a SecureContext has no parsed tls @@ -620,7 +700,7 @@ describe.concurrent("socket", () => { requestCert: true, rejectUnauthorized: true, } as TlsOptions); - const { promise, resolve, reject } = Promise.withResolvers(); + const { promise, resolve, reject } = Promise.withResolvers(); const server = Bun.listen({ hostname: "127.0.0.1", port: 0, @@ -633,8 +713,8 @@ describe.concurrent("socket", () => { data: {}, socket: { handshake(tlsSocket) { - const peer = tlsSocket.getPeerCertificate(); - resolve(!!peer && Object.keys(peer).length > 0); + // An empty object means the client sent no certificate. + resolve(tlsSocket.getPeerCertificate()?.subject?.CN ?? null); }, data() {}, close() {}, @@ -659,7 +739,7 @@ describe.concurrent("socket", () => { key: tls.key, }); client.on("error", () => {}); - expect(await promise).toBe(true); + expect(await promise).toBe("server-bun"); client.destroy(); } finally { server.stop(true); @@ -834,9 +914,20 @@ describe.concurrent("socket", () => { expect(tls_socket).toBeDefined(); } const [tlsData, rawData] = await Promise.all([tlsSocketPromise, rawSocketPromise]); - expect(tlsData).toContain("HTTP/1.1 200 OK"); - expect(tlsData).toContain("Content-Length: 11"); - expect(tlsData).toContain("\r\nHello World"); + const [head, responseBody] = (tlsData as string).split("\r\n\r\n"); + const [statusLine, ...headerLines] = head.split("\r\n"); + const headers = Object.fromEntries( + headerLines.map(line => { + const colon = line.indexOf(":"); + return [line.slice(0, colon).toLowerCase(), line.slice(colon + 1).trim()]; + }), + ); + expect({ statusLine, contentLength: headers["content-length"], responseBody }).toEqual({ + statusLine: "HTTP/1.1 200 OK", + contentLength: "11", + responseBody: "Hello World", + }); + // The raw socket only sees ciphertext. expect((rawData as Buffer).byteLength).toBeGreaterThanOrEqual(1980); } }); @@ -980,40 +1071,44 @@ console.log("completed", done, "cycles without crashing"); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stdout.trim()).toBe("completed 64 cycles without crashing"); + expect({ stdout, stderr }).toEqual({ stdout: "completed 64 cycles without crashing\n", stderr: "" }); expect(exitCode).toBe(0); }, 30_000); // subprocess + debug/ASAN startup is slow }); -it.skipIf(isWindows)("should not crash when a socket from a file descriptor is closed after opening", async () => { - const [server, client] = createSocketPair(); - const open = jest.fn(); - const close = jest.fn(); - const data = jest.fn(); - { - const socket = await Bun.connect({ - fd: server, - socket: { - open, - close, - data, - }, - } as any); - Bun.gc(true); - await Bun.sleep(10); - closeSync(client); - Bun.gc(true); - } +it.concurrent.skipIf(isWindows)( + "should not crash when a socket from a file descriptor is closed after opening", + async () => { + const [server, client] = createSocketPair(); + const opened = Promise.withResolvers(); + const closed = Promise.withResolvers(); + const open = jest.fn(() => opened.resolve()); + const close = jest.fn(() => closed.resolve()); + const data = jest.fn(); + { + await Bun.connect({ + fd: server, + socket: { + open, + close, + data, + }, + } as any); + Bun.gc(true); + await opened.promise; + closeSync(client); + Bun.gc(true); + } - await Bun.sleep(10); - expect(open).toHaveBeenCalledTimes(1); - expect(close).toHaveBeenCalledTimes(1); - expect(data).toHaveBeenCalledTimes(0); - open.mockClear(); - close.mockClear(); - data.mockClear(); -}); + // Closing the peer end makes the adopted fd read EOF and close. + await closed.promise; + expect(open).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledTimes(1); + expect(data).toHaveBeenCalledTimes(0); + }, +); +// Keep the two object-count checks serial. They count live sockets, so no other test can run alongside them. it("should not leak memory", async () => { // assert we don't leak the sockets // we expect 1 or 2 because that's the prototype / structure. @@ -1030,7 +1125,7 @@ it("should not leak memory when connect() fails again", async () => { await expectMaxObjectTypeCount(expect, "TCPSocket", 5, 50); }); -it("should throw on empty hostname from truthy non-string value", () => { +it.concurrent("should throw on empty hostname from truthy non-string value", () => { const socket = { data() {}, open() {}, close() {} }; // A truthy value whose toString() returns "" should throw, not crash for (const hostname of [[], new String("")]) { @@ -1041,7 +1136,7 @@ it("should throw on empty hostname from truthy non-string value", () => { } }); -it("should throw on empty unix path from truthy non-string value", () => { +it.concurrent("should throw on empty unix path from truthy non-string value", () => { const socket = { data() {}, open() {}, close() {} }; // unix uses a strict string type in bindgen, so non-string values are rejected before // reaching the empty-string check — the error message differs from hostname @@ -1080,7 +1175,7 @@ describe.each([ }); }); -it("reading .listener on a closed client socket does not use-after-free handlers", async () => { +it.concurrent("reading .listener on a closed client socket does not use-after-free handlers", async () => { // Client-mode Handlers is heap-allocated per-connect and freed in // markInactive once the socket closes. `socket.listener` read // `handlers.mode` through the dangling pointer before the isDetached() @@ -1133,7 +1228,7 @@ it("reading .listener on a closed client socket does not use-after-free handlers expect(exitCode).toBe(0); }); -it("reading fd of a TLS listener should not crash", () => { +it.concurrent("reading fd of a TLS listener should not crash", () => { using listener = Bun.listen({ hostname: "localhost", port: 0, @@ -1144,7 +1239,7 @@ it("reading fd of a TLS listener should not crash", () => { expect((listener as any).fd).toBeGreaterThanOrEqual(0); }); -it("getServername on a closed TLS socket should not crash", async () => { +it.concurrent("getServername on a closed TLS socket should not crash", async () => { using listener = Bun.listen({ hostname: "127.0.0.1", port: 0, @@ -1185,12 +1280,14 @@ it("getServername on a closed TLS socket should not crash", async () => { expect(await promise).toBeUndefined(); expect(client.getServername()).toBeUndefined(); }); -it("exportKeyingMaterial with a context whose toPrimitive terminates the socket does not use-after-free", async () => { - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - ` +it.concurrent( + "exportKeyingMaterial with a context whose toPrimitive terminates the socket does not use-after-free", + async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` const tls = ${JSON.stringify(tls)}; const listener = Bun.listen({ hostname: "127.0.0.1", @@ -1225,26 +1322,28 @@ it("exportKeyingMaterial with a context whose toPrimitive terminates the socket console.log(await promise); 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("undefined\n"); - if (exitCode !== 0) expect(stderr).toBe(""); - expect(exitCode).toBe(0); -}); - -it("TLS client: flush() after end() does not double-teardown before deferred onClose", async () => { - // `end()` on a TLS client sends close_notify and defers the raw close until the - // peer replies, leaving `is_active` set so the eventual onClose can release the - // Handlers. A `flush()` in that window must not re-enter markInactive and free - // the Handlers early — when the peer's close_notify then arrives, onClose would - // deref freed memory (ASAN heap-use-after-free). Run in a subprocess so an ASAN - // abort surfaces as a clean test failure rather than taking down the runner. - using dir = tempDir("tls-flush-after-end", { - "run.ts": ` + ], + 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: "undefined\n", stderr: "" }); + expect(exitCode).toBe(0); + }, +); + +it.concurrent( + "TLS client: flush() after end() does not double-teardown before deferred onClose", + async () => { + // `end()` on a TLS client sends close_notify and defers the raw close until the + // peer replies, leaving `is_active` set so the eventual onClose can release the + // Handlers. A `flush()` in that window must not re-enter markInactive and free + // the Handlers early — when the peer's close_notify then arrives, onClose would + // deref freed memory (ASAN heap-use-after-free). Run in a subprocess so an ASAN + // abort surfaces as a clean test failure rather than taking down the runner. + using dir = tempDir("tls-flush-after-end", { + "run.ts": ` const tls = JSON.parse(process.env.TLS_JSON!); using server = Bun.listen({ @@ -1285,261 +1384,281 @@ it("TLS client: flush() after end() does not double-teardown before deferred onC await closed; console.log("OK"); `, - }); + }); - await using proc = Bun.spawn({ - cmd: [bunExe(), "run.ts"], - env: { ...bunEnv, TLS_JSON: JSON.stringify(tls) }, - cwd: String(dir), - stdout: "pipe", - stderr: "pipe", - }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "run.ts"], + env: { ...bunEnv, TLS_JSON: JSON.stringify(tls) }, + 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(exitCode).toBe(0); -}, 30_000); // debug subprocess startup + ASAN symbolication on failure is slow - -it("writing to an established TLS socket from another TLS client's open() does not divert either connection", async () => { - // Proxy-style flow: an inbound TLS connection is already established, then an - // outbound TLS client is opened and its open() callback synchronously writes a - // status frame back to the inbound socket. The outbound socket's handshake must - // still be sent to *its own* upstream server (so it completes and the proxy can - // report "upstream-ready"), and the inbound client's TLS stream must only ever - // carry the proxy's application data — never bytes belonging to the outbound - // connection. Previously the per-loop SSL output target was not re-pointed after - // the open() dispatch, so the outbound handshake bytes landed in the inbound - // client's stream, corrupting its session and stalling the outbound connection. - const { promise: clientResult, resolve: resolveClientResult } = Promise.withResolvers<{ - outcome: string; - received: string; - }>(); - - let clientReceived = ""; - - // Upstream TLS server the proxy connects out to. - const upstream = Bun.listen({ - hostname: "127.0.0.1", - port: 0, - tls, - socket: { - open() {}, - data() {}, - close() {}, - error() {}, - }, - }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout.trim()).toBe("OK"); + expect(exitCode).toBe(0); + }, + 30_000, +); // debug subprocess startup + ASAN symbolication on failure is slow - let outbound: Socket | undefined; - let inboundRequest = ""; +it.concurrent( + "writing to an established TLS socket from another TLS client's open() does not divert either connection", + async () => { + // Proxy-style flow: an inbound TLS connection is already established, then an + // outbound TLS client is opened and its open() callback synchronously writes a + // status frame back to the inbound socket. The outbound socket's handshake must + // still be sent to *its own* upstream server (so it completes and the proxy can + // report "upstream-ready"), and the inbound client's TLS stream must only ever + // carry the proxy's application data — never bytes belonging to the outbound + // connection. Previously the per-loop SSL output target was not re-pointed after + // the open() dispatch, so the outbound handshake bytes landed in the inbound + // client's stream, corrupting its session and stalling the outbound connection. + const { promise: clientResult, resolve: resolveClientResult } = Promise.withResolvers<{ + outcome: string; + received: string; + }>(); - // Proxy: TLS server that, when the inbound client asks, opens an outbound TLS - // connection and writes to the inbound socket from the outbound open() callback. - const proxy = Bun.listen({ - hostname: "127.0.0.1", - port: 0, - tls, - socket: { - open() {}, - async data(inbound, chunk) { - inboundRequest += chunk.toString(); - if (!inboundRequest.includes("CONNECT\n") || outbound) return; - outbound = await Bun.connect({ - hostname: "127.0.0.1", - port: upstream.port, - tls: { ...tls, rejectUnauthorized: false }, - socket: { - open() { - // Writes to the already-established inbound TLS socket while the - // outbound socket's own handshake has not been flushed to the wire yet. - inbound.write("hello-from-proxy\n"); - }, - handshake(_socket, success) { - inbound.write(success ? "upstream-ready\n" : "upstream-handshake-failed\n"); - }, - data() {}, - close() {}, - error() {}, - }, - }); - }, - close() {}, - error() {}, - }, - }); + let clientReceived = ""; - let client: Socket | undefined; - try { - // Inbound client talking TLS to the proxy. - client = await Bun.connect({ + // Upstream TLS server the proxy connects out to. + const upstream = Bun.listen({ hostname: "127.0.0.1", - port: proxy.port, - tls: { ...tls, rejectUnauthorized: false }, + port: 0, + tls, socket: { open() {}, - handshake(socket) { - socket.write("CONNECT\n"); - }, - data(_socket, chunk) { - clientReceived += chunk.toString(); - if (clientReceived.includes("upstream-ready\n") || clientReceived.includes("upstream-handshake-failed\n")) { - resolveClientResult({ outcome: "ok", received: clientReceived }); - } - }, - close() { - resolveClientResult({ outcome: "closed", received: clientReceived }); - }, - error(_socket, err) { - resolveClientResult({ outcome: `error: ${err}`, received: clientReceived }); - }, + data() {}, + close() {}, + error() {}, }, }); - // The inbound client's TLS session stays intact (no error/close) and sees - // exactly the proxy's two status frames, and the outbound handshake reached - // its own upstream server — otherwise "upstream-ready" is never produced. - expect(await clientResult).toEqual({ - outcome: "ok", - received: "hello-from-proxy\nupstream-ready\n", - }); - } finally { - client?.end(); - outbound?.end(); - proxy.stop(true); - upstream.stop(true); - } -}, 30_000); + let outbound: Socket | undefined; + let inboundRequest = ""; -it("TLS mid-read boundary dispatch: writing to another TLS socket from data() does not corrupt the rest of the stream", async () => { - // When SSL_read fills the per-loop 512KiB output buffer exactly, uSockets - // dispatches that chunk to the data() callback mid-read and then continues - // decrypting the rest of the same TCP read. If the callback does TLS work on - // another socket on the same loop (here: write() to a second TLS client), - // the per-loop ssl_read_input/offset/length and ssl_socket must be restored - // afterwards — otherwise the remaining ciphertext is dropped and the stream - // desyncs (bad record MAC / truncated payload). - const BOUNDARY_CHUNK = 512 * 1024; - const PAYLOAD_SIZE = 12 * 1024 * 1024; - const block = Buffer.alloc(64 * 1024); - for (let i = 0; i < block.length; i++) block[i] = i & 0xff; - const payload = Buffer.concat(Array(PAYLOAD_SIZE / block.length).fill(block)); - const expectedHash = new Bun.CryptoHasher("sha256").update(payload).digest("hex"); - - const downloadDone = Promise.withResolvers(); - const sideReceived = Promise.withResolvers(); - - // Server that streams the 12MiB payload once the client asks for it. - let sent = 0; - const pump = (socket: Socket) => { - while (sent < payload.length) { - const written = socket.write(payload.subarray(sent, Math.min(sent + 1024 * 1024, payload.length))); - if (written <= 0) break; - sent += written; - } - socket.flush(); - }; - const payloadServer = Bun.listen({ - hostname: "127.0.0.1", - port: 0, - tls, - socket: { - open() {}, - data(socket) { - pump(socket); - }, - drain(socket) { - pump(socket); + // Proxy: TLS server that, when the inbound client asks, opens an outbound TLS + // connection and writes to the inbound socket from the outbound open() callback. + const proxy = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls, + socket: { + open() {}, + async data(inbound, chunk) { + inboundRequest += chunk.toString(); + if (!inboundRequest.includes("CONNECT\n") || outbound) return; + outbound = await Bun.connect({ + hostname: "127.0.0.1", + port: upstream.port, + tls: { ...tls, rejectUnauthorized: false }, + socket: { + open() { + // Writes to the already-established inbound TLS socket while the + // outbound socket's own handshake has not been flushed to the wire yet. + inbound.write("hello-from-proxy\n"); + }, + handshake(_socket, success) { + inbound.write(success ? "upstream-ready\n" : "upstream-handshake-failed\n"); + }, + data() {}, + close() {}, + error() {}, + }, + }); + }, + close() {}, + error() {}, }, - close() {}, - error() {}, - }, - }); + }); - // Second TLS server + client on the same loop; the download's data() handler - // writes to this client from inside the boundary dispatch. - const sideServer = Bun.listen({ - hostname: "127.0.0.1", - port: 0, - tls, - socket: { - open() {}, - data() { - sideReceived.resolve(); - }, - close() {}, - error() {}, - }, - }); + let client: Socket | undefined; + try { + // Inbound client talking TLS to the proxy. + client = await Bun.connect({ + hostname: "127.0.0.1", + port: proxy.port, + tls: { ...tls, rejectUnauthorized: false }, + socket: { + open() {}, + handshake(socket) { + socket.write("CONNECT\n"); + }, + data(_socket, chunk) { + clientReceived += chunk.toString(); + if (clientReceived.includes("upstream-ready\n") || clientReceived.includes("upstream-handshake-failed\n")) { + resolveClientResult({ outcome: "ok", received: clientReceived }); + } + }, + close() { + resolveClientResult({ outcome: "closed", received: clientReceived }); + }, + error(_socket, err) { + resolveClientResult({ outcome: `error: ${err}`, received: clientReceived }); + }, + }, + }); - let sideSocket: Socket | undefined; - let downloadSocket: Socket | undefined; - const hasher = new Bun.CryptoHasher("sha256"); - let receivedBytes = 0; - let boundaryChunks = 0; - let sideWrites = 0; + // The inbound client's TLS session stays intact (no error/close) and sees + // exactly the proxy's two status frames, and the outbound handshake reached + // its own upstream server — otherwise "upstream-ready" is never produced. + expect(await clientResult).toEqual({ + outcome: "ok", + received: "hello-from-proxy\nupstream-ready\n", + }); + } finally { + client?.end(); + outbound?.end(); + proxy.stop(true); + upstream.stop(true); + } + }, + 30_000, +); - try { - sideSocket = await Bun.connect({ +it.concurrent( + "TLS mid-read boundary dispatch: writing to another TLS socket from data() does not corrupt the rest of the stream", + async () => { + // When SSL_read fills the per-loop 512KiB output buffer exactly, uSockets + // dispatches that chunk to the data() callback mid-read and then continues + // decrypting the rest of the same TCP read. If the callback does TLS work on + // another socket on the same loop (here: write() to a second TLS client), + // the per-loop ssl_read_input/offset/length and ssl_socket must be restored + // afterwards — otherwise the remaining ciphertext is dropped and the stream + // desyncs (bad record MAC / truncated payload). + const BOUNDARY_CHUNK = 512 * 1024; + const PAYLOAD_SIZE = 12 * 1024 * 1024; + const block = Buffer.alloc(64 * 1024); + for (let i = 0; i < block.length; i++) block[i] = i & 0xff; + const payload = Buffer.concat(Array(PAYLOAD_SIZE / block.length).fill(block)); + const expectedHash = new Bun.CryptoHasher("sha256").update(payload).digest("hex"); + + const downloadDone = Promise.withResolvers(); + const sideReceived = Promise.withResolvers(); + + // Server that streams the 12MiB payload once the client asks for it. + let sent = 0; + const pump = (socket: Socket) => { + while (sent < payload.length) { + const written = socket.write(payload.subarray(sent, Math.min(sent + 1024 * 1024, payload.length))); + if (written <= 0) break; + sent += written; + } + socket.flush(); + }; + const payloadServer = Bun.listen({ hostname: "127.0.0.1", - port: sideServer.port, - tls: { ...tls, rejectUnauthorized: false }, + port: 0, + tls, socket: { open() {}, - data() {}, + data(socket) { + pump(socket); + }, + drain(socket) { + pump(socket); + }, close() {}, error() {}, }, }); - downloadSocket = await Bun.connect({ + // Second TLS server + client on the same loop; the download's data() handler + // writes to this client from inside the boundary dispatch. + const sideServer = Bun.listen({ hostname: "127.0.0.1", - port: payloadServer.port, - tls: { ...tls, rejectUnauthorized: false }, + port: 0, + tls, socket: { open() {}, - handshake(socket) { - socket.write("GO\n"); + data() { + sideReceived.resolve(); }, - data(_socket, chunk) { - hasher.update(chunk); - receivedBytes += chunk.byteLength; - - const isBoundary = chunk.byteLength === BOUNDARY_CHUNK; - if (isBoundary) boundaryChunks += 1; - // Write to the second TLS socket from the boundary dispatch; if this - // run never produces an exact 512KiB chunk, fall back to writing on - // every data event so the re-entrancy is still exercised. - if (isBoundary || boundaryChunks === 0) { - sideWrites += 1; - sideSocket!.write("ping\n"); - } + close() {}, + error() {}, + }, + }); - if (receivedBytes >= PAYLOAD_SIZE) { - downloadDone.resolve("done"); - } - }, - close() { - downloadDone.resolve(`closed after ${receivedBytes} bytes`); + let sideSocket: Socket | undefined; + let downloadSocket: Socket | undefined; + const hasher = new Bun.CryptoHasher("sha256"); + let receivedBytes = 0; + let boundaryChunks = 0; + const sideWrites: number[] = []; + const sideHandshake = Promise.withResolvers(); + + try { + sideSocket = await Bun.connect({ + hostname: "127.0.0.1", + port: sideServer.port, + tls: { ...tls, rejectUnauthorized: false }, + socket: { + open() {}, + handshake(_socket, success, authorizationError) { + if (success) sideHandshake.resolve(); + else sideHandshake.reject(authorizationError ?? new Error("side handshake failed")); + }, + data() {}, + close() {}, + error(_socket, err) { + sideHandshake.reject(err); + }, }, - error(_socket, err) { - downloadDone.resolve(`error: ${err} after ${receivedBytes} bytes`); + }); + // connect() resolves when the TCP connection opens. write() returns 0 until the handshake completes. + await sideHandshake.promise; + + downloadSocket = await Bun.connect({ + hostname: "127.0.0.1", + port: payloadServer.port, + tls: { ...tls, rejectUnauthorized: false }, + socket: { + open() {}, + handshake(socket) { + socket.write("GO\n"); + }, + data(_socket, chunk) { + hasher.update(chunk); + receivedBytes += chunk.byteLength; + + const isBoundary = chunk.byteLength === BOUNDARY_CHUNK; + if (isBoundary) boundaryChunks += 1; + // Write to the second TLS socket from the boundary dispatch; if this + // run never produces an exact 512KiB chunk, fall back to writing on + // every data event so the re-entrancy is still exercised. + if (isBoundary || boundaryChunks === 0) { + sideWrites.push(sideSocket!.write("ping\n")); + } + + if (receivedBytes >= PAYLOAD_SIZE) { + downloadDone.resolve("done"); + } + }, + close() { + downloadDone.resolve(`closed after ${receivedBytes} bytes`); + }, + error(_socket, err) { + downloadDone.resolve(`error: ${err} after ${receivedBytes} bytes`); + }, }, - }, - }); + }); - expect(await downloadDone.promise).toBe("done"); - expect(receivedBytes).toBe(PAYLOAD_SIZE); - expect(hasher.digest("hex")).toBe(expectedHash); - expect(sideWrites).toBeGreaterThan(0); - await sideReceived.promise; - } finally { - downloadSocket?.end(); - sideSocket?.end(); - payloadServer.stop(true); - sideServer.stop(true); - } -}, 60_000); + expect(await downloadDone.promise).toBe("done"); + expect(receivedBytes).toBe(PAYLOAD_SIZE); + expect(hasher.digest("hex")).toBe(expectedHash); + // write() returns 0 when it buffers the bytes instead of sending them. + expect(sideWrites.length).toBeGreaterThan(0); + expect(sideWrites.filter(written => written !== "ping\n".length)).toEqual([]); + await sideReceived.promise; + } finally { + downloadSocket?.end(); + sideSocket?.end(); + payloadServer.stop(true); + sideServer.stop(true); + } + }, + 60_000, +); // write() reports how many bytes it took, and the caller may send anything next: the peer must get // exactly the reported bytes, then the next write. While one TLS socket holds the unsent rest of a @@ -1995,13 +2114,51 @@ describe.concurrent("TLS server: write() to the accepted socket from inside its } }); -it("alpnCallback: a selection whose ToString throws surfaces the thrown Error, not an engine-internal cell", async () => { - // The thrown value must reach the `error` handler as a plain Error, not the - // JSC::Exception wrapper cell: `Object.prototype.toString.call` on the cell - // aborts the process and `instanceof Error` on it is false. The crash fires - // on the first property load of the value, so probe it in a subprocess. - using dir = tempDir("alpn-tostring-throw", { - "fixture.cjs": ` +// serverName is the listener hook behind node:tls's SNICallback: `true` suspends the handshake until resumeSNI(). +it.concurrent("TLS server: closes a socket whose handshake fails right after resumeSNI()", async () => { + const events: string[] = []; + const asked = Promise.withResolvers(); + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls, + socket: { + serverName(_data: unknown, _servername: string, socket: Socket) { + asked.resolve(socket as never); + return true; + }, + handshake: (_, success, error) => void events.push(`handshake(${success}, ${(error as any)?.code})`), + data() {}, + close: () => void events.push("close"), + } as SocketHandler, + }); + // The listener's key is RSA, so BoringSSL finds no shared cipher once the server name is settled. + const client = tlsConnect({ + port: server.port, + host: "127.0.0.1", + servername: "pending.test", + ca: tls.cert, + maxVersion: "TLSv1.2", + ciphers: "ECDHE-ECDSA-AES128-GCM-SHA256", + }); + client.on("error", () => {}); + try { + (await asked.promise).resumeSNI(undefined, false); + expect(events).toEqual(["handshake(false, EPROTO)", "close"]); + } finally { + client.destroy(); + } +}); + +it.concurrent( + "alpnCallback: a selection whose ToString throws surfaces the thrown Error, not an engine-internal cell", + async () => { + // The thrown value must reach the `error` handler as a plain Error, not the + // JSC::Exception wrapper cell: `Object.prototype.toString.call` on the cell + // aborts the process and `instanceof Error` on it is false. The crash fires + // on the first property load of the value, so probe it in a subprocess. + using dir = tempDir("alpn-tostring-throw", { + "fixture.cjs": ` const tlsMod = require("node:tls"); const server = Bun.listen({ hostname: "127.0.0.1", @@ -2035,23 +2192,24 @@ it("alpnCallback: a selection whose ToString throws surfaces the thrown Error, n server.stop(true); }); `, - }); + }); - await using proc = Bun.spawn({ - cmd: [bunExe(), "fixture.cjs"], - env: { ...bunEnv, ASAN_OPTIONS: "symbolize=0:abort_on_error=1:allow_user_segv_handler=1" }, - cwd: String(dir), - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.cjs"], + env: { ...bunEnv, ASAN_OPTIONS: "symbolize=0:abort_on_error=1:allow_user_segv_handler=1" }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ lines: stdout.trim().split(/\r?\n/), stderr, exitCode }).toEqual({ - lines: ["error:true:[object Error]:TypeError"], - stderr: "", - exitCode: 0, - }); -}); + expect({ lines: stdout.trim().split(/\r?\n/), stderr, exitCode }).toEqual({ + lines: ["error:true:[object Error]:TypeError"], + stderr: "", + exitCode: 0, + }); + }, +); // Bun.connect() on a Windows named pipe takes a dedicated early branch in // Listener.connectInner that heap-allocates a standalone Handlers block. That @@ -2059,7 +2217,7 @@ it("alpnCallback: a selection whose ToString throws surfaces the thrown Error, n // close; the `.server` path does `@fieldParentPtr("handlers", ...)` expecting // a surrounding Listener struct and would read past the standalone // allocation (heap-buffer-overflow under ASAN) and leak the block. -describe.skipIf(!isWindows)("Bun.connect named-pipe client Handlers lifecycle", () => { +describe.concurrent.skipIf(!isWindows)("Bun.connect named-pipe client Handlers lifecycle", () => { it("open → close cleans up without reading past the Handlers allocation", async () => { const src = /* js */ ` const pipe = "\\\\\\\\.\\\\pipe\\\\bun-test-connect-" + Math.random().toString(36).slice(2); @@ -2338,7 +2496,7 @@ describe.concurrent.skipIf(!isWindows)("named-pipe socket closed and event loop }); }); -it("reload() backs out cleanly when a handler getter closes the socket mid-reload", async () => { +it.concurrent("reload() backs out cleanly when a handler getter closes the socket mid-reload", async () => { // socket.reload() reads the new callbacks off the user object property by // property, so a getter can run arbitrary JS — including terminating the // very socket being reloaded, which releases its current handlers. The @@ -2428,12 +2586,11 @@ it("reload() backs out cleanly when a handler getter closes the socket mid-reloa }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stdout).toBe("reload-with-terminate-ok\nsecond-reload:polo\nDONE\n"); + expect({ stdout, stderr }).toEqual({ stdout: "reload-with-terminate-ok\nsecond-reload:polo\nDONE\n", stderr: "" }); expect(exitCode).toBe(0); - void stderr; }); -it("reload() does nothing when the top-level socket getter closes the socket", async () => { +it.concurrent("reload() does nothing when the top-level socket getter closes the socket", async () => { await using proc = Bun.spawn({ cmd: [ bunExe(), @@ -2466,7 +2623,7 @@ it("reload() does nothing when the top-level socket getter closes the socket", a expect(exitCode).toBe(0); }); -it("upgradeTLS() throws when an option getter re-enters upgradeTLS on the same socket", async () => { +it.concurrent("upgradeTLS() throws when an option getter re-enters upgradeTLS on the same socket", async () => { await using proc = Bun.spawn({ cmd: [ bunExe(), @@ -2510,7 +2667,7 @@ it("upgradeTLS() throws when an option getter re-enters upgradeTLS on the same s expect(exitCode).toBe(0); }); -it("node:net connect() reusing a server-accepted handle keeps the listener's handlers working", async () => { +it.concurrent("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 // outbound node:net connect must not release the listener's handlers: the @@ -2625,20 +2782,65 @@ it("node:net connect() reusing a server-accepted handle keeps the listener's han }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stdout).toBe("STEP1\nSTEP2:connected+data\nSTEP3:second-accept\nDONE\n"); + expect({ stdout, stderr }).toEqual({ + stdout: "STEP1\nSTEP2:connected+data\nSTEP3:second-accept\nDONE\n", + stderr: "", + }); expect(exitCode).toBe(0); - void stderr; }); -it.concurrent("setTypeOfService validates its argument instead of asserting", async () => { - // The unfixed native binding called JSValue::asInt32() on the raw argument, - // which asserts isInt32() on assert builds and silently feeds garbage to - // setsockopt on release builds. It must throw a TypeError/RangeError. - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - ` +it.concurrent("write() and end() with a byteOffset and no byteLength send the rest of the buffer", async () => { + const received: Buffer[] = []; + const { promise: closed, resolve, reject } = Promise.withResolvers(); + using listener = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + data(_socket, chunk) { + received.push(chunk); + }, + close() { + resolve(); + }, + error(_socket, err) { + reject(err); + }, + }, + }); + const client = await Bun.connect({ + hostname: "127.0.0.1", + port: listener.port, + socket: { data() {} }, + }); + + const buf = Buffer.from("0123456789"); + expect(() => client.write(buf, 11)).toThrow( + expect.objectContaining({ code: "ERR_OUT_OF_RANGE", message: expect.stringContaining('"byteOffset"') }), + ); + const wrote = { + fromOffset: client.write(buf, 4), + // The offset counts from the start of the view, not of its ArrayBuffer. + fromOffsetInView: client.write(buf.subarray(2, 8), 3), + fromEnd: client.write(buf, 10), + end: client.end(buf, 7), + }; + await closed; + + expect({ wrote, received: Buffer.concat(received).toString() }).toEqual({ + wrote: { fromOffset: 6, fromOffsetInView: 3, fromEnd: 0, end: 3 }, + received: "456789" + "567" + "789", + }); +}); + +it.concurrent("setTypeOfService validates its argument instead of asserting", async () => { + // The unfixed native binding called JSValue::asInt32() on the raw argument, + // which asserts isInt32() on assert builds and silently feeds garbage to + // setsockopt on release builds. It must throw a TypeError/RangeError. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` using listener = Bun.listen({ hostname: "127.0.0.1", port: 0, @@ -2689,7 +2891,7 @@ it.concurrent("setTypeOfService validates its argument instead of asserting", as }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ stdout: stdout.trim().split("\n"), exitCode }).toEqual({ + expect({ stdout: stdout.trim().split("\n"), stderr }).toEqual({ stdout: [ "object=ERR_INVALID_ARG_TYPE", "string=ERR_INVALID_ARG_TYPE", @@ -2700,9 +2902,9 @@ it.concurrent("setTypeOfService validates its argument instead of asserting", as "ok=no-throw", "get=no-throw", ], - exitCode: 0, + stderr: "", }); - void stderr; + expect(exitCode).toBe(0); }); // initialDelay is ms; TCP_KEEPIDLE is seconds. 0 = enable SO_KEEPALIVE and @@ -2790,7 +2992,7 @@ it.concurrent.skipIf(isWindows)("setKeepAlive converts ms to seconds and treats }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ out: JSON.parse(stdout.trim() || "null"), exitCode }).toEqual({ + expect({ out: JSON.parse(stdout.trim() || "null"), stderr }).toEqual({ out: { a_ret: true, a_keepalive: 1, @@ -2802,12 +3004,12 @@ it.concurrent.skipIf(isWindows)("setKeepAlive converts ms to seconds and treats b_keepidle: 4, net_keepidle: 4, }, - exitCode: 0, + stderr: "", }); - void stderr; + expect(exitCode).toBe(0); }); -it("socket handler validation errors throw instead of crashing", async () => { +it.concurrent("socket handler validation errors throw instead of crashing", async () => { // Handlers protects its callbacks only after validation succeeds, so the // validation error paths must throw without tearing down a never-protected // Handlers (debug builds assert on the protect/unprotect balance). Run in @@ -2836,29 +3038,32 @@ it("socket handler validation errors throw instead of crashing", async () => { }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stdout).toBe( - 'connect:Expected at least "data" or "drain" callback\n' + + expect({ stdout, stderr }).toEqual({ + stdout: + 'connect:Expected at least "data" or "drain" callback\n' + 'listen:Expected at least "data" or "drain" callback\n' + 'connect:Expected "onEnd" callback to be a function\n' + 'listen:Expected "onEnd" callback to be a function\n', - ); + stderr: "", + }); expect(exitCode).toBe(0); - void stderr; }); // https://bun-p9.sentry.io/issues/7573683042/ (BUN-3PK7) -it("socket handler validation errors don't steal GC protection from live sockets sharing the same callbacks", async () => { - // node:net passes one module-level handler table to every connection, so - // every live socket protects the same JSFunction identities. A Handlers - // dropped on a validation error before protect() ran must not gcUnprotect - // those shared functions, or GC collects them while a live socket's - // Handlers still points at the freed cells and the socket's finalizer - // later dereferences cell->vm() inside Bun__JSValue__unprotect. - await using proc = spawn({ - cmd: [ - bunExe(), - "-e", - ` +it.concurrent( + "socket handler validation errors don't steal GC protection from live sockets sharing the same callbacks", + async () => { + // node:net passes one module-level handler table to every connection, so + // every live socket protects the same JSFunction identities. A Handlers + // dropped on a validation error before protect() ran must not gcUnprotect + // those shared functions, or GC collects them while a live socket's + // Handlers still points at the freed cells and the socket's finalizer + // later dereferences cell->vm() inside Bun__JSValue__unprotect. + await using proc = spawn({ + cmd: [ + bunExe(), + "-e", + ` let listener; let errors = 0; (function setup() { @@ -2923,19 +3128,19 @@ it("socket handler validation errors don't steal GC protection from live sockets for (let i = 0; i < 20; i++) Bun.gc(true); console.log("done"); `, - ], - env: { ...bunEnv, ...(isWindows ? {} : { Malloc: "1" }) }, - stdout: "pipe", - stderr: "pipe", - }); + ], + 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(stdout).toBe("errors=8\nreceived=hello\ndone\n"); - expect(exitCode).toBe(0); - void stderr; -}); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr }).toEqual({ stdout: "errors=8\nreceived=hello\ndone\n", stderr: "" }); + expect(exitCode).toBe(0); + }, +); -describe("TLS rejectUnauthorized", () => { +describe.concurrent("TLS rejectUnauthorized", () => { // Regenerate with: openssl req -x509 -newkey rsa:2048 -nodes -days 3650 for // each CA, then sign two "localhost" leaves (SAN localhost,127.0.0.1,::1): // one by CA_CRT (SERVER_*) and one by a second, untrusted CA (ROGUE_*). @@ -3144,10 +3349,13 @@ Reo= }; } - it("closes a connection whose server certificate is not trusted", async () => { - using t = await connectTo({ key: ROGUE_KEY, cert: ROGUE_CRT }, { ca: CA_CRT }); - // A rejecting client reports the failed verdict at the callback, like - // node v26.3.0 (secureConnect never fires; socket.authorized is false). + // A rejecting client reports the failed verdict at the callback, like + // node v26.3.0 (secureConnect never fires; socket.authorized is false). + it.each([ + ["closes a connection whose server certificate is not trusted", { ca: CA_CRT }], + ["closes an untrusted connection with tls: true", true], + ] as const)("%s", async (_name, clientTls) => { + using t = await connectTo({ key: ROGUE_KEY, cert: ROGUE_CRT }, clientTls); expect(await t.handshake.promise).toEqual({ authorizedArg: false, authorizedGetter: false, @@ -3160,20 +3368,6 @@ Reo= expect(t.client.getAuthorizationError()?.message).toBe(UNTRUSTED_MESSAGE); }); - it("closes an untrusted connection with tls: true", async () => { - using t = await connectTo({ key: ROGUE_KEY, cert: ROGUE_CRT }, true); - // Same node v26.3.0 contract as above: the rejected handshake reports - // authorized=false at the callback. - expect(await t.handshake.promise).toEqual({ - authorizedArg: false, - authorizedGetter: false, - callbackError: UNTRUSTED_MESSAGE, - getterError: UNTRUSTED_MESSAGE, - }); - await t.closed.promise; - expect(t.received).toEqual([]); - }); - it("reports authorized=false but keeps the connection with rejectUnauthorized: false", async () => { using t = await connectTo({ key: ROGUE_KEY, cert: ROGUE_CRT }, { ca: CA_CRT, rejectUnauthorized: false }); expect(await t.handshake.promise).toEqual({ @@ -3187,61 +3381,13 @@ Reo= expect(t.received.join("")).toBe("hello-from-server\nping"); }); - it("closes an untrusted connection upgraded with only a secureContext", async () => { - using server = Bun.listen({ - hostname: "127.0.0.1", - port: 0, - tls: { key: ROGUE_KEY, cert: ROGUE_CRT }, - socket: { - open() {}, - handshake(socket) { - socket.write("hello-from-server\n"); - }, - data(socket, data) { - socket.write(data); - }, - close() {}, - error() {}, - }, - }); - - const received: string[] = []; - const handshake = Promise.withResolvers<{ authorized: boolean; error: string | null }>(); - const closed = Promise.withResolvers(); - - using tcp = await Bun.connect({ - hostname: "127.0.0.1", - port: server.port, - socket: { data() {}, close() {}, error() {} }, - }); - const { context } = createSecureContext({ ca: CA_CRT }) as any; - const [raw, secure] = tcp.upgradeTLS({ - secureContext: context, - socket: { - handshake(socket: Socket, _success: boolean, authorizationError: Error | null) { - handshake.resolve({ - authorized: socket.authorized, - error: authorizationError?.message ?? null, - }); - }, - data(_socket: Socket, data: Buffer) { - received.push(data.toString()); - }, - close() { - closed.resolve(); - }, - error() {}, - }, - } as any); - using _raw = raw; - using _secure = secure; - - expect(await handshake.promise).toEqual({ authorized: false, error: UNTRUSTED_MESSAGE }); - await closed.promise; - expect(received).toEqual([]); - }); - - it("closes an untrusted connection upgraded with tls: true", async () => { + it.each([ + [ + "closes an untrusted connection upgraded with only a secureContext", + () => ({ secureContext: (createSecureContext({ ca: CA_CRT }) as any).context }), + ], + ["closes an untrusted connection upgraded with tls: true", () => ({ tls: true })], + ] as const)("%s", async (_name, upgradeOptions) => { using server = Bun.listen({ hostname: "127.0.0.1", port: 0, @@ -3269,7 +3415,7 @@ Reo= socket: { data() {}, close() {}, error() {} }, }); const [raw, secure] = tcp.upgradeTLS({ - tls: true, + ...upgradeOptions(), socket: { handshake(socket: Socket, _success: boolean, authorizationError: Error | null) { handshake.resolve({ @@ -3953,87 +4099,30 @@ Reo= // A bare `secureContext` carries no parsed config; the policy comes from // the context's own verify mode. - it("closes an untrusted client certificate on an isServer upgrade with only a secureContext", async () => { - const serverReceived: string[] = []; - const handshake = Promise.withResolvers<{ authorized: boolean; error: string | null; writeResult: number }>(); - const serverClosed = Promise.withResolvers(); - const clientClosed = Promise.withResolvers(); - const { context } = createSecureContext({ - key: SERVER_KEY, - cert: SERVER_CRT, - ca: CA_CRT, - requestCert: true, - rejectUnauthorized: true, - } as any) as any; - using listener = Bun.listen({ - hostname: "127.0.0.1", - port: 0, - socket: { - open() {}, - data(raw, chunk) { - raw.upgradeTLS({ - isServer: true, - initialData: chunk, - secureContext: context, - socket: { - handshake(secure: Socket, _success: boolean, authorizationError: Error | null) { - handshake.resolve({ - authorized: secure.authorized, - error: authorizationError?.message ?? null, - writeResult: secure.write("hello-from-server\n"), - }); - }, - data(_secure: Socket, payload: Buffer) { - serverReceived.push(payload.toString()); - }, - close() { - serverClosed.resolve(); - }, - error() {}, - }, - } as any); - }, - error(_raw, err) { - handshake.reject(err); - serverClosed.reject(err); - }, - close() {}, - }, - }); - using client = await Bun.connect({ - hostname: "127.0.0.1", - port: listener.port, - tls: { ca: CA_CRT, serverName: "localhost", key: ROGUE_KEY, cert: ROGUE_CRT }, - socket: { - open() {}, - handshake(socket) { - socket.write("client-app-data\n"); - }, - data() {}, - close() { - clientClosed.resolve(); - }, - error() {}, - connectError(_socket, err) { - handshake.reject(err); - clientClosed.reject(err); - serverClosed.reject(err); - }, - }, - }); - void client; - expect(await handshake.promise).toEqual({ authorized: false, error: UNTRUSTED_MESSAGE, writeResult: -1 }); - await serverClosed.promise; - expect(serverReceived).toEqual([]); - await clientClosed.promise; - }); - - it("closes an untrusted client certificate on a socket upgraded with isServer: true", async () => { + it.each([ + [ + "closes an untrusted client certificate on an isServer upgrade with only a secureContext", + () => ({ + secureContext: ( + createSecureContext({ + key: SERVER_KEY, + cert: SERVER_CRT, + ca: CA_CRT, + requestCert: true, + rejectUnauthorized: true, + } as any) as any + ).context, + }), + ], + [ + "closes an untrusted client certificate on a socket upgraded with isServer: true", + () => ({ tls: { key: SERVER_KEY, cert: SERVER_CRT, ca: CA_CRT, requestCert: true } }), + ], + ] as const)("%s", async (_name, upgradeOptions) => { const serverReceived: string[] = []; const handshake = Promise.withResolvers<{ authorized: boolean; error: string | null; writeResult: number }>(); const serverClosed = Promise.withResolvers(); const clientClosed = Promise.withResolvers(); - const serverTls = { key: SERVER_KEY, cert: SERVER_CRT, ca: CA_CRT, requestCert: true }; using listener = Bun.listen({ hostname: "127.0.0.1", port: 0, @@ -4043,7 +4132,7 @@ Reo= raw.upgradeTLS({ isServer: true, initialData: chunk, - tls: serverTls, + ...upgradeOptions(), socket: { open() {}, handshake(secure: Socket, _success: boolean, authorizationError: Error | null) { @@ -4091,6 +4180,7 @@ Reo= }, }, }); + void client; expect(await handshake.promise).toEqual({ authorized: false, error: UNTRUSTED_MESSAGE, writeResult: -1 }); await serverClosed.promise; expect(serverReceived).toEqual([]); @@ -4668,8 +4758,9 @@ Reo= open(socket) { setImmediate(() => socket.shutdown()); }, - handshake(_socket, success) { - events.push(`handshake success=${success}`); + handshake(_socket, success, authorizationError) { + const code = (authorizationError as NodeJS.ErrnoException | null)?.code ?? null; + events.push(`handshake success=${success} code=${code}`); }, data() {}, close() { @@ -4682,14 +4773,23 @@ Reo= }, }, }); + const chainCheck = () => (client.getAuthorizationError() as NodeJS.ErrnoException | null)?.code ?? null; try { await flightForwarded.promise; - await pendingReadsDone(); + // The client has read the server's flight once it has a chain verification result. + const deadline = Date.now() + 3000; + do { + await pendingReadsDone(); + } while (chainCheck() !== "UNABLE_TO_VERIFY_LEAF_SIGNATURE" && events.length === 0 && Date.now() < deadline); // The server waits for the client's flight, so the handshake is still pending. - expect(events).toEqual([]); + expect({ chainCheck: chainCheck(), events }).toEqual({ + chainCheck: "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + events: [], + }); client.end(); await closed.promise; - expect(events).toEqual(["handshake success=false", "close"]); + // The failed handshake reports the chain verification error. + expect(events).toEqual(["handshake success=false code=UNABLE_TO_VERIFY_LEAF_SIGNATURE", "close"]); } finally { client.terminate(); relay.close(); @@ -5069,7 +5169,7 @@ describe("shutdown(true)", () => { // Linux-only: uses /proc/self/fd to find and close the connected socket's fd // so getsockname()/getpeername() fail with EBADF. -it.skipIf(!isLinux)( +it.concurrent.skipIf(!isLinux)( "socket.localPort / socket.remotePort return undefined when the underlying fd is gone", async () => { const script = /* js */ ` @@ -5124,16 +5224,15 @@ it.skipIf(!isLinux)( }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe(""); + // If the child died before reporting, the diff shows its raw output. + const out = stdout.startsWith("{") ? JSON.parse(stdout) : stdout; + if (out?.skipped) return; + expect({ out, stderr }).toEqual({ out: { localPort: null, remotePort: null }, stderr: "" }); expect(exitCode).toBe(0); - - const out = JSON.parse(stdout.trim()); - if (out.skipped) return; - expect(out).toEqual({ localPort: null, remotePort: null }); }, ); -describe("TLS handshake callback throw", () => { +describe.concurrent("TLS handshake callback throw", () => { // A throw from a Bun-native handshake callback must reach the socket's own // error handler (the documented contract), not crash the process — node:tls // gets its uncaughtException semantics in net.ts, not in the shared native @@ -5175,16 +5274,18 @@ describe("TLS handshake callback throw", () => { }); }); -it("an unref'd Bun.listen() with no other references keeps accepting across GC", async () => { - // Listener.unref() used to downgrade the wrapper's GC ref to weak while the - // socket was still listening. GC then collected the wrapper (and the - // handlers cell it roots), so the next accepted connection either dispatched - // on_open into a swept cell (SIGABRT / type confusion) or, once the - // finalizer had closed the socket, got ECONNREFUSED. unref() must only - // release the event-loop hold; the wrapper stays reachable until stop(). - // Raw TCP instead of fetch: the server replies and ends without reading the - // request, and on Windows closing with the request unread RSTs the exchange. - const src = ` +it.concurrent( + "an unref'd Bun.listen() with no other references keeps accepting across GC", + async () => { + // Listener.unref() used to downgrade the wrapper's GC ref to weak while the + // socket was still listening. GC then collected the wrapper (and the + // handlers cell it roots), so the next accepted connection either dispatched + // on_open into a swept cell (SIGABRT / type confusion) or, once the + // finalizer had closed the socket, got ECONNREFUSED. unref() must only + // release the event-loop hold; the wrapper stays reachable until stop(). + // Raw TCP instead of fetch: the server replies and ends without reading the + // request, and on Windows closing with the request unread RSTs the exchange. + const src = ` const churnSink = []; function churn() { let a = []; @@ -5221,7 +5322,8 @@ it("an unref'd Bun.listen() with no other references keeps accepting across GC", churn(); Bun.gc(true); churn(); - await Bun.sleep(1); + // Run one event-loop turn so work the collection deferred to the loop finishes before the next GC. + await new Promise(resolve => setImmediate(resolve)); Bun.gc(true); for (const port of ports) { const got = await roundTrip(port); @@ -5230,21 +5332,23 @@ it("an unref'd Bun.listen() with no other references keeps accepting across GC", } console.log("served " + ports.length + " listeners"); `; - // The subprocess must also exit on its own: unref() still has to release - // the listeners' event-loop refs even though their wrappers stay reachable. - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", src], - env: bunEnv, - stderr: "pipe", - stdout: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ - stdout: "served 4 listeners", - stderr: "", - exitCode: 0, - }); -}); + // The subprocess must also exit on its own: unref() still has to release + // the listeners' event-loop refs even though their wrappers stay reachable. + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + env: bunEnv, + stderr: "pipe", + stdout: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: "served 4 listeners", + stderr: "", + exitCode: 0, + }); + }, + 30_000, +); // eight full GCs over 60k live objects take several seconds on a debug build describe.concurrent("TLS session/keylog handlers", () => { // `session`/`keylog` dispatch from the event loop after the SSL stack @@ -5293,10 +5397,16 @@ describe.concurrent("TLS session/keylog handlers", () => { const session = await promise; expect(session).toBeInstanceOf(Buffer); expect(session.length).toBeGreaterThan(0); - // The handshake's key material was logged before the session ticket. - expect(keylogLines.length).toBeGreaterThan(0); - expect(keylogLines[0]).toBeInstanceOf(Buffer); - expect(keylogLines[0].toString()).toContain("_TRAFFIC_SECRET"); + // One NSS key log line per TLS 1.3 secret, in the order the handshake derives them, + // all logged before the session ticket. + expect(keylogLines.every(line => line instanceof Buffer)).toBe(true); + expect(keylogLines.map(line => line.toString().replace(/^(\S+) [0-9a-f]{64} [0-9a-f]{64}\n$/, "$1"))).toEqual([ + "CLIENT_HANDSHAKE_TRAFFIC_SECRET", + "SERVER_HANDSHAKE_TRAFFIC_SECRET", + "CLIENT_TRAFFIC_SECRET_0", + "SERVER_TRAFFIC_SECRET_0", + "EXPORTER_SECRET", + ]); socket.end(); } finally { server.stop(true); @@ -5487,16 +5597,13 @@ describe.concurrent("connect() failure promise settlement", () => { }); it("an error thrown by connectError() becomes the connect() rejection", async () => { - // Grab a port nothing is listening on anymore. - const probe = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); - const port = probe.port; - probe.stop(true); + using refused = await refusedPort(); const boom = new Error("boom-connect-error"); await expect( Bun.connect({ hostname: "127.0.0.1", - port, + port: refused.port, socket: { connectError() { throw boom; @@ -5508,7 +5615,75 @@ describe.concurrent("connect() failure promise settlement", () => { }); }); -describe("allowHalfOpen socket whose peer resets behind pending writes", () => { +// The peer runs on a Worker, so it can accept, answer and close while the connecting thread is blocked and can't poll. +it.skipIf(isWindows).concurrent.each(["Bun.connect", "net.connect"])( + "%s reads the answer from a unix socket peer that closed before the first poll", + async api => { + using dir = tempDir("unix-peer-closes-first", { + "peer.mjs": ` + import { parentPort, workerData as closed } from "node:worker_threads"; + Bun.listen({ + unix: "s.sock", + socket: { + open(socket) { + socket.write("hello"); + socket.terminate(); + Atomics.store(closed, 0, 1); + Atomics.notify(closed, 0); + }, + data() {}, + }, + }); + parentPort.postMessage("listening"); + `, + "client.mjs": ` + import { once } from "node:events"; + import net from "node:net"; + import { Worker } from "node:worker_threads"; + const closed = new Int32Array(new SharedArrayBuffer(4)); + const worker = new Worker(new URL("./peer.mjs", import.meta.url), { workerData: closed }); + await once(worker, "message"); + const events = []; + const done = Promise.withResolvers(); + if (process.argv[2] === "Bun.connect") { + Bun.connect({ + unix: "s.sock", + socket: { + open: () => void events.push("open"), + data: (_socket, chunk) => void events.push("data " + chunk), + end: () => void events.push("end"), + close: () => done.resolve(events.push("close")), + connectError: (_socket, error) => done.resolve(events.push("connectError " + error.code)), + }, + }).catch(() => {}); + } else { + net + .connect("s.sock") + .on("connect", () => events.push("open")) + .on("data", chunk => events.push("data " + chunk)) + .on("end", () => events.push("end")) + .on("error", error => events.push("error " + error.syscall + " " + error.code)) + .on("close", () => done.resolve(events.push("close"))); + } + if (Atomics.wait(closed, 0, 0, 30_000) === "timed-out") events.push("the peer never closed"); + await done.promise; + console.log(events.join(", ")); + await worker.terminate(); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "client.mjs", api], + env: bunEnv, + cwd: String(dir), + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout.trim()).toBe("open, data hello, end, close"); + expect(exitCode).toBe(0); + }, +); + +describe.concurrent("allowHalfOpen socket whose peer resets behind pending writes", () => { // The full victim matrix (Bun.listen/Bun.serve/node:http(s)/fetch, plain and // TLS) runs as test/js/bun/test/parallel/test-net-half-open-peer-reset-*.mjs; // this covers the shared usockets core in bun:test form. Unfixed, epoll @@ -5520,6 +5695,7 @@ describe("allowHalfOpen socket whose peer resets behind pending writes", () => { const ended = Promise.withResolvers(); const closed = Promise.withResolvers(); let endCount = 0; + let closeCount = 0; const big = Buffer.alloc(4 * 1024 * 1024, 0x78); using server = Bun.listen({ @@ -5544,6 +5720,7 @@ describe("allowHalfOpen socket whose peer resets behind pending writes", () => { }, error() {}, close() { + closeCount++; closed.resolve(); }, }, @@ -5567,12 +5744,12 @@ describe("allowHalfOpen socket whose peer resets behind pending writes", () => { await issued.promise; // victim has queued more than the kernel will buffer peer.shutdown(); // FIN await ended.promise; // victim saw it - // The FIN-to-RST gap is where the bug lived; an immediate RST can overtake - // the FIN and just read as ECONNRESET on the readable side. - await Bun.sleep(50); + // Leave a gap between the FIN and the RST. A re-delivered end fires on the victim's next poll, + // so a few event-loop turns expose it. An immediate RST can overtake the FIN and read as ECONNRESET. + for (let turn = 0; turn < 10; turn++) await new Promise(resolve => setImmediate(resolve)); peer.terminate(); // RST await closed.promise; // victim must tear down, not spin or strand - expect(endCount).toBe(1); + expect({ endCount, closeCount }).toEqual({ endCount: 1, closeCount: 1 }); }); }); @@ -5583,7 +5760,7 @@ describe("allowHalfOpen socket whose peer resets behind pending writes", () => { // samples the usockets loop iteration counter, we send SEGMENTS one-byte writes from here, // then it samples again. A loop that wakes per segment reports ~SEGMENTS iterations; one that // does not reports the handful caused by our two control lines. -it("a paused socket does not wake the event loop for every segment its peer sends", async () => { +it.concurrent("a paused socket does not wake the event loop for every segment its peer sends", async () => { const SEGMENTS = 200; await using child = spawn({ cmd: [ @@ -5828,7 +6005,7 @@ describe.concurrent.each(["tcp", "tls"] as const)("%s socket paused when its pee // A paused socket with a backpressured write of its own must also close on the reset: an owner // that resumes only after 'drain' (node:http's flood guard) would otherwise wait forever. -it("a paused socket with a backpressured write still closes when its peer resets", async () => { +it.concurrent("a paused socket with a backpressured write still closes when its peer resets", async () => { const closedWith = Promise.withResolvers(); let backpressured!: () => void; const isBackpressured = new Promise(resolve => (backpressured = resolve)); @@ -5866,17 +6043,59 @@ it("a paused socket with a backpressured write still closes when its peer resets await isBackpressured; peer.terminate(); const error = (await closedWith.promise) as NodeJS.ErrnoException | undefined; - expect(error?.code).toBe("ECONNRESET"); + expect({ reported: error instanceof Error, code: error?.code }).toEqual({ reported: true, code: "ECONNRESET" }); }); -it("a tls socket that pauses after shutdown() still reads the peer's reply once resumed", async () => { - using server = Bun.listen({ - hostname: "127.0.0.1", - port: 0, - tls, - allowHalfOpen: true, - socket: { - data() {}, +// The raw socketpair peer runs synchronously inside open(), so the first poll event is both writable and readable. +it.skipIf(isWindows).concurrent("pause() in drain() defers data reported by the same poll event", async () => { + const [fd, peerFd] = createSocketPair(); + const log: string[] = []; + const received = Promise.withResolvers(); + const big = Buffer.alloc(4 * 1024 * 1024, "x"); + const scratch = Buffer.alloc(1024 * 1024); + try { + using _socket = await Bun.connect({ + fd, + socket: { + open(socket) { + // The peer isn't reading yet, so this is a partial write and the socket polls for writable. + const written = socket.write(big); + writeSync(peerFd, "sent before the pause"); + for (let read = 0; read < written;) read += readSync(peerFd, scratch); + }, + drain(socket) { + // resume() polls for writable again, so drain() runs a second time. + if (log.length > 0) return; + socket.pause(); + log.push("drain: pause()"); + setImmediate(() => { + log.push("resume()"); + socket.resume(); + }); + }, + data(_socket, chunk) { + log.push(`data: ${chunk.toString()}`); + received.resolve(); + }, + error: (_socket, error) => received.reject(error), + close: () => received.reject(new Error("the socket closed before the data arrived")), + }, + }); + await received.promise; + expect(log).toEqual(["drain: pause()", "resume()", "data: sent before the pause"]); + } finally { + closeSync(peerFd); + } +}); + +it("a tls socket that pauses after shutdown() still reads the peer's reply once resumed", async () => { + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls, + allowHalfOpen: true, + socket: { + data() {}, end: socket => void socket.end("reply"), }, }); @@ -6104,29 +6323,50 @@ describe.concurrent("close() error after the peer resets the connection", () => // socket's error, and its refused connect reported ECONNRESET. // // The fd arrangement in the fixture is what makes the stale read land on the new socket -// on POSIX, where a new socket gets the lowest free number. The outcome it checks, a -// refused connect reports ECONNREFUSED, holds on every platform, so it is not skipped -// anywhere: on Windows the fixture is only that check. +// on POSIX, where a new socket gets the lowest free number. The fixture reports whether +// the arrangement held, so the test fails if the runtime starts opening a different number +// of fds instead of passing without reaching the stale read. A refused connect reports +// ECONNREFUSED on every platform, so the test isn't skipped anywhere. Socket handles aren't +// fd numbers on Windows, so the fixture only reports the outcome there. describe.concurrent("a socket closed by data() while its peer's reset is being dispatched", () => { it("does not consume the connect error of a socket opened from close()", async () => { const source = ` - import { closeSync, openSync } from "node:fs"; + import { closeSync, fstatSync, openSync } from "node:fs"; + + const checkFds = process.platform !== "win32"; + function isOpen(fd) { + try { + fstatSync(fd); + return true; + } catch { + return false; + } + } - // A port nothing listens on. The established connection keeps it bound, so no - // listener can take it while the test runs. + // Same as refusedPort() in the test file. const sink = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); - const holder = await Bun.connect({ hostname: "127.0.0.1", port: sink.port, socket: { data() {} } }); + const holder = await Bun.connect({ + hostname: "127.0.0.1", + port: sink.port, + localAddress: "127.0.0.1", + socket: { data() {} }, + }); const refusedPort = holder.localPort; + let acceptedFd = -1; + const report = {}; const outcome = Promise.withResolvers(); const server = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data(socket) { + acceptedFd = socket.fd; socket.terminate(); }, close() { + // terminate() already closed the accepted socket's fd. + if (checkFds) report.acceptedFdFreeInClose = !isOpen(acceptedFd); Bun.connect({ hostname: "127.0.0.1", port: refusedPort, @@ -6136,6 +6376,9 @@ describe.concurrent("a socket closed by data() while its peer's reset is being d connectError: (_socket, error) => outcome.resolve(error.code), }, }).catch(() => {}); + // Bun.connect() to an IP literal creates its socket before it returns, and that + // socket has to reuse the accepted socket's fd number for the stale read to hit it. + if (checkFds) report.acceptedFdReusedByConnect = isOpen(acceptedFd); }, }, }); @@ -6153,7 +6396,8 @@ describe.concurrent("a socket closed by data() while its peer's reset is being d peer.write("x"); peer.terminate(); - console.log(await outcome.promise); + report.outcome = await outcome.promise; + console.log(JSON.stringify(report)); holder.terminate(); sink.stop(true); server.stop(true); @@ -6168,12 +6412,15 @@ describe.concurrent("a socket closed by data() while its peer's reset is being d stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ stdout, stderr }).toEqual({ stdout: "ECONNREFUSED\n", stderr: "" }); + const expectedReport = isWindows + ? { outcome: "ECONNREFUSED" } + : { acceptedFdFreeInClose: true, acceptedFdReusedByConnect: true, outcome: "ECONNREFUSED" }; + expect({ stdout, stderr }).toEqual({ stdout: JSON.stringify(expectedReport) + "\n", stderr: "" }); expect(exitCode).toBe(0); }); }); -it("concurrent end() on two allowHalfOpen TLS peers closes both sockets", async () => { +it.concurrent("concurrent end() on two allowHalfOpen TLS peers closes both sockets", async () => { // Each end() sends close_notify and defers its fd close for the peer's // reply. The reply arrives without a FIN, so the ZERO_RETURN path has to // complete the deferred close instead of keeping the socket half-open. @@ -6182,6 +6429,7 @@ it("concurrent end() on two allowHalfOpen TLS peers closes both sockets", async const clientClosed = Promise.withResolvers(); const serverShake = Promise.withResolvers(); const clientShake = Promise.withResolvers(); + const closes = { server: 0, client: 0 }; using server = Bun.listen({ hostname: "127.0.0.1", @@ -6192,7 +6440,10 @@ it("concurrent end() on two allowHalfOpen TLS peers closes both sockets", async open: s => serverOpen.resolve(s), handshake: () => serverShake.resolve(), data() {}, - close: () => serverClosed.resolve(), + close: () => { + closes.server++; + serverClosed.resolve(); + }, }, }); const client = await Bun.connect({ @@ -6203,7 +6454,10 @@ it("concurrent end() on two allowHalfOpen TLS peers closes both sockets", async socket: { handshake: () => clientShake.resolve(), data() {}, - close: () => clientClosed.resolve(), + close: () => { + closes.client++; + clientClosed.resolve(); + }, }, }); @@ -6215,6 +6469,7 @@ it("concurrent end() on two allowHalfOpen TLS peers closes both sockets", async serverSock.end(); await Promise.all([serverClosed.promise, clientClosed.promise]); + expect(closes).toEqual({ server: 1, client: 1 }); }); // In a process of its own: BoringSSL's SSL_set_session abort()s once the handshake has started. @@ -6303,6 +6558,11 @@ describe.concurrent("write() that is the first to observe the peer's reset retur while (!fs.existsSync(process.env.RESET_DONE_FILE) && Date.now() < deadline) { Atomics.wait(cell, 0, 0, 5); } + // The kernel can apply the RST after the peer's close() returns. remotePort calls getpeername(), + // which fails on POSIX once the socket is reset. + while (process.platform !== "win32" && socket.remotePort !== undefined && Date.now() < deadline) { + Atomics.wait(cell, 0, 0, 1); + } writes.push(socket.write("ping"), socket.write("ping")); }, error() {}, @@ -6708,3 +6968,617 @@ describe.concurrent("a handler that closes its socket and then waits for a promi }); }); }); + +describe.concurrent("end(data) larger than the send buffer", () => { + const payload = randomFillSync(Buffer.allocUnsafe(8 << 20)); + + it.each([ + ["tcp", "with"], + ["tcp", "without"], + ["tls", "with"], + ["tls", "without"], + ] as const)("%s delivers the whole chunk before the FIN %s a drain handler", async (transport, drainHandler) => { + const received: Buffer[] = []; + const serverClosed = Promise.withResolvers(); + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls: transport === "tls" ? { key: tls.key, cert: tls.cert } : undefined, + socket: { + data: (_socket, chunk) => void received.push(chunk), + close: () => serverClosed.resolve(), + }, + }); + const clientClosed = Promise.withResolvers(); + const returned: number[] = []; + const errors: Error[] = []; + const drain = jest.fn(); + await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + tls: transport === "tls" ? { ca: tls.cert } : undefined, + socket: { + open(socket) { + returned.push(socket.end(payload), socket.write("more"), socket.end("more")); + }, + data() {}, + ...(drainHandler === "with" ? { drain } : {}), + close: () => clientClosed.resolve(), + error: (_socket, error) => void errors.push(error), + }, + }); + await Promise.all([serverClosed.promise, clientClosed.promise]); + expect({ returned, errors, drains: drain.mock.calls.length }).toEqual({ + returned: [payload.length, -1, -1], + errors: [], + drains: 0, + }); + expect(Buffer.concat(received).equals(payload)).toBe(true); + }); + + // By the time this resolves, the loop has done every read that was due when it started. + async function pendingReadsDone() { + await using server = net.createServer(socket => socket.end("x")).listen(0, "127.0.0.1"); + await once(server, "listening"); + const socket = net.connect((server.address() as net.AddressInfo).port, "127.0.0.1"); + await once(socket, "data"); + socket.destroy(); + } + + describe.each(["tcp", "unix", "tls"] as const)("%s", transport => { + // The peer sends its request and its FIN at once, and reads nothing until the socket under test has seen that FIN. + it.skipIf(isWindows && transport === "unix").each([ + ["listen", "data", "without"], + ["listen", "data", "with"], + ["listen", "end", "with"], + ["connect", "data", "without"], + ["connect", "data", "with"], + ["connect", "end", "with"], + ] as const)( + "Bun.%s socket sends the tail queued in %s() after the peer's FIN, %s an end handler", + async (side, callSite, endHandler) => { + const STEP = 256 * 1024; + const TAIL = 2 << 20; + using dir = transport === "unix" ? tempDir("end-after-fin", {}) : undefined; + const requested = Promise.withResolvers(); + const ended = Promise.withResolvers(); + const closed = Promise.withResolvers(); + const peerClosed = Promise.withResolvers(); + const received: Buffer[] = []; + const errors: string[] = []; + let sent = 0; + let endReturned = -2; + let peer: Socket | undefined; + + // Fills the kernel first, because Windows takes a first send of any size whole. + function fillAndEnd(socket: Socket) { + let took = STEP; + while (took === STEP && sent + STEP + TAIL <= payload.length) { + took = socket.write(payload.subarray(sent, sent + STEP)); + sent += Math.max(took, 0); + } + endReturned = socket.end(payload.subarray(sent, sent + TAIL)); + sent += TAIL; + } + const underTest: SocketHandler = { + data(socket) { + if (callSite === "data") fillAndEnd(socket); + requested.resolve(); + }, + ...(endHandler === "with" + ? { + end(socket: Socket) { + if (callSite === "end") fillAndEnd(socket); + ended.resolve(); + }, + } + : {}), + close() { + requested.resolve(); + ended.resolve(); + closed.resolve(); + }, + error: (_socket, error) => void errors.push(String(error)), + }; + function sendRequestAndFin(socket: Socket) { + peer = socket; + socket.pause(); + socket.write("request"); + socket.shutdown(); + } + const peerHandlers: SocketHandler = { + // A TLS socket can't be paused before it has read the handshake. + ...(transport === "tls" ? { handshake: sendRequestAndFin } : { open: sendRequestAndFin }), + data: (_socket, chunk) => void received.push(chunk), + end() {}, + close: () => peerClosed.resolve(), + error() {}, + }; + + const accepting = { + allowHalfOpen: side !== "listen", + tls: transport === "tls" ? { key: tls.key, cert: tls.cert } : undefined, + socket: side === "listen" ? underTest : peerHandlers, + }; + const connecting = { + allowHalfOpen: side !== "connect", + tls: transport === "tls" ? { ca: tls.cert } : undefined, + socket: side === "connect" ? underTest : peerHandlers, + }; + const path = dir && join(String(dir), "s.sock"); + using listener = + path === undefined + ? Bun.listen({ hostname: "127.0.0.1", port: 0, ...accepting }) + : Bun.listen({ unix: path, ...accepting }); + using _connected = await (path === undefined + ? Bun.connect({ hostname: "127.0.0.1", port: (listener as Bun.TCPSocketListener).port, ...connecting }) + : Bun.connect({ unix: path, ...connecting })); + + await requested.promise; + await (endHandler === "with" ? ended.promise : pendingReadsDone()); + peer?.resume(); + await Promise.all([closed.promise, peerClosed.promise]); + + expect({ endReturned, errors }).toEqual({ endReturned: TAIL, errors: [] }); + expect(Buffer.concat(received).equals(payload.subarray(0, sent))).toBe(true); + }, + ); + }); + + // In its own process: an uncaught error sets the exit code. + it.skipIf(isWindows)( + "closes without an uncaught error when the peer disconnects before the tail is sent", + async () => { + using dir = tempDir("end-peer-gone", {}); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + /* js */ ` + const closed = Promise.withResolvers(); + const server = Bun.listen({ + unix: "s.sock", + socket: { + data: socket => console.log("end", socket.end(Buffer.alloc(16 << 20))), + close: () => (console.log("close"), closed.resolve()), + }, + }); + await Bun.connect({ + unix: "s.sock", + socket: { open: socket => void socket.write("request"), data: socket => socket.close() }, + }); + await closed.promise; + server.stop(true); + `, + ], + 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 }).toEqual({ stdout: `end ${16 << 20}\nclose\n`, stderr: "" }); + expect(exitCode).toBe(0); + }, + ); +}); + +describe.concurrent.each(["tcp", "tls"] as const)( + "%s socket whose peer sends its FIN with nothing queued", + transport => { + async function peerEndsFirst(side: "listen" | "connect", allowHalfOpen: boolean) { + const events: string[] = []; + const sawEnd = Promise.withResolvers>(); + const closed = Promise.withResolvers(); + const peerClosed = Promise.withResolvers(); + const underTest: SocketHandler = { + data() {}, + end(socket) { + events.push("end"); + sawEnd.resolve(socket); + }, + close() { + events.push("close"); + closed.resolve(); + }, + error: (_socket, error) => void events.push(`error ${error}`), + }; + const peerHandlers: SocketHandler = { + open: socket => void socket.shutdown(), + data: (_socket, chunk) => void events.push(`peer got ${chunk}`), + end() {}, + close: () => peerClosed.resolve(), + error() {}, + }; + using listener = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + allowHalfOpen: side === "listen" ? allowHalfOpen : true, + tls: transport === "tls" ? { key: tls.key, cert: tls.cert } : undefined, + socket: side === "listen" ? underTest : peerHandlers, + }); + using _connected = await Bun.connect({ + hostname: "127.0.0.1", + port: listener.port, + allowHalfOpen: side === "connect" ? allowHalfOpen : true, + tls: transport === "tls" ? { ca: tls.cert } : undefined, + socket: side === "connect" ? underTest : peerHandlers, + }); + const socket = await sawEnd.promise; + if (allowHalfOpen) socket.end("still open"); + await Promise.all([closed.promise, peerClosed.promise]); + return events; + } + + it.each(["listen", "connect"] as const)("Bun.%s closes a default socket after its end handler", async side => { + expect(await peerEndsFirst(side, false)).toEqual(["end", "close"]); + }); + + it.each(["listen", "connect"] as const)("Bun.%s keeps an allowHalfOpen socket open until end()", async side => { + expect((await peerEndsFirst(side, true)).sort()).toEqual(["close", "end", "peer got still open"]); + }); + }, +); + +// With a `handshake` handler, `open` runs when the TCP connection opens, before the TLS handshake. +it.concurrent("a TLS socket ended from open() reports the failed handshake and closes", async () => { + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls: { key: tls.key, cert: tls.cert }, + socket: { data() {}, error() {} }, + }); + const events: string[] = []; + const closed = Promise.withResolvers(); + await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + tls: { ca: tls.cert }, + socket: { + open(socket) { + events.push("open"); + socket.end(); + }, + handshake: (_socket, success, error) => void events.push(`handshake ${success} ${error?.code}`), + close() { + events.push("close"); + closed.resolve(); + }, + data() {}, + error: (_socket, error) => closed.reject(error), + connectError: (_socket, error) => closed.reject(error), + }, + }); + await closed.promise; + expect(events).toEqual(["open", "handshake false ECONNRESET", "close"]); +}); + +it.concurrent("closes an allowHalfOpen socket when shutdown() runs after its poll went idle", async () => { + const ended = Promise.withResolvers>(); + const drained = Promise.withResolvers(); + const closed = Promise.withResolvers(); + const peerClosed = Promise.withResolvers(); + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + allowHalfOpen: true, + socket: { + data() {}, + end: socket => ended.resolve(socket), + // The EOF path arms one writable wakeup, so drain is the last event before the poll goes idle. + drain: () => drained.resolve(), + close: () => closed.resolve(), + error: (_socket, error) => closed.reject(error), + }, + }); + using _peer = await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + allowHalfOpen: true, + socket: { + open: socket => void socket.shutdown(), + data() {}, + end() {}, + close: () => peerClosed.resolve(), + error: (_socket, error) => peerClosed.reject(error), + }, + }); + const socket = await ended.promise; + await drained.promise; + // Windows reports the FIN once more after drain. Two loop turns get past that report. + for (let i = 0; i < 2; i++) await new Promise(resolve => setImmediate(resolve)); + socket.shutdown(); + await Promise.all([closed.promise, peerClosed.promise]); +}); + +it.concurrent("the TLS half of an upgraded default socket closes after the peer's FIN", async () => { + const peerClosed = Promise.withResolvers(); + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls: { key: tls.key, cert: tls.cert }, + allowHalfOpen: true, + socket: { + open: socket => void socket.shutdown(), + data() {}, + end: socket => void socket.end(), + close: () => peerClosed.resolve(), + error() {}, + }, + }); + const raw = await Bun.connect({ hostname: "127.0.0.1", port: server.port, socket: { data() {} } }); + const events: string[] = []; + const closed = Promise.withResolvers(); + raw.upgradeTLS({ + tls: { ca: tls.cert }, + socket: { + data() {}, + end: () => void events.push("end"), + close() { + events.push("close"); + closed.resolve(); + }, + error: (_socket, error) => void events.push(`error ${error}`), + }, + }); + await Promise.all([closed.promise, peerClosed.promise]); + expect(events).toEqual(["end", "close"]); +}); + +it.concurrent.each(["tcp", "tls"] as const)( + "%s frees end(data)'s queued tail when the peer closes without reading it", + async transport => { + const N = 8 << 20; + let accepted = 0; + const queued = Promise.withResolvers(); + const closed = Promise.withResolvers<{ held: number; bytesWritten: number }>(); + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls: transport === "tls" ? { key: tls.key, cert: tls.cert } : undefined, + socket: { + data(socket) { + // Fills the kernel first, because Windows takes a first send of any size whole. + const step = Buffer.alloc(256 * 1024); + for (let took = step.length, steps = 0; took === step.length && steps < 64; steps++) { + took = socket.write(step); + accepted += Math.max(took, 0); + } + accepted += socket.end(Buffer.alloc(N)); + queued.resolve(estimateShallowMemoryUsageOf(socket)); + }, + close: socket => + closed.resolve({ held: estimateShallowMemoryUsageOf(socket), bytesWritten: socket.bytesWritten }), + error() {}, + }, + }); + const peer = await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + tls: transport === "tls" ? { ca: tls.cert } : undefined, + socket: { + open(socket) { + socket.pause(); + socket.write("request"); + }, + data() {}, + error() {}, + }, + }); + expect(await queued.promise).toBeGreaterThan(1 << 20); + peer.terminate(); + const { held, bytesWritten } = await closed.promise; + expect(held).toBeLessThan(64 * 1024); + expect(bytesWritten).toBe(accepted); + }, +); + +describe.concurrent("Bun.connect with a tls object that sets no option", () => { + const CLIENT_HELLO_RECORD = 22; + + // Resolves with the first bytes a raw peer gets from a client whose open() writes "SECRET". + async function firstChunkSent(options: { tls?: unknown }, transport: "tcp" | "unix" = "tcp") { + using dir = transport === "unix" ? tempDir("tls-unset-connect", {}) : undefined; + const firstChunk = Promise.withResolvers(); + const peer = net.createServer(socket => { + socket.on("error", () => {}); + socket.once("data", chunk => { + firstChunk.resolve(chunk); + socket.destroy(); + }); + }); + await once(dir ? peer.listen(join(String(dir), "s.sock")) : peer.listen(0, "127.0.0.1"), "listening"); + const address = peer.address()!; + const client = await Bun.connect({ + ...(typeof address === "string" ? { unix: address } : { hostname: "127.0.0.1", port: address.port }), + ...options, + socket: { + open(socket: Socket) { + socket.write("SECRET"); + }, + handshake() {}, + data() {}, + error() {}, + }, + } as any); + try { + return await firstChunk.promise; + } finally { + client.end(); + peer.close(); + } + } + const sentClientHello = (chunk: Buffer) => ({ firstByte: chunk[0], cleartext: chunk.includes("SECRET") }); + + it.each([ + ["{}", () => ({})], + ["{ rejectUnauthorized: undefined }", () => ({ rejectUnauthorized: undefined })], + ["{ requestCert: false }", () => ({ requestCert: false })], + ["{ ca: undefined, cert: undefined, key: undefined }", () => ({ ca: undefined, cert: undefined, key: undefined })], + ["{ unknownKey: 1 }", () => ({ unknownKey: 1 })], + ["Object.create(null)", () => Object.create(null)], + ] as [string, () => object][])("tls: %s sends a ClientHello and no cleartext", async (_, makeTls) => { + expect(sentClientHello(await firstChunkSent({ tls: makeTls() }))).toEqual({ + firstByte: CLIENT_HELLO_RECORD, + cleartext: false, + }); + }); + + it.skipIf(isWindows)("tls: {} sends a ClientHello and no cleartext over a unix socket", async () => { + expect(sentClientHello(await firstChunkSent({ tls: {} }, "unix"))).toEqual({ + firstByte: CLIENT_HELLO_RECORD, + cleartext: false, + }); + }); + + it.each([ + ["absent", {}], + ["undefined", { tls: undefined }], + ["null", { tls: null }], + ["false", { tls: false }], + ] as [string, { tls?: unknown }][])("tls %s still connects without TLS", async (_, options) => { + expect((await firstChunkSent(options)).toString()).toBe("SECRET"); + }); + + it("rejects an untrusted certificate, like tls: true", async () => { + using listener = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls, + socket: { data() {}, error() {} }, + }); + async function handshakeWith(tlsOption: unknown) { + const events: unknown[] = []; + const closed = Promise.withResolvers(); + await Bun.connect({ + hostname: "127.0.0.1", + port: listener.port, + tls: tlsOption, + socket: { + // Without TLS the server can't parse this and closes, so the test fails right away instead of timing out. + open(socket: Socket) { + socket.write("SECRET"); + }, + handshake(socket: Socket, success: boolean, error: (Error & { code?: string }) | null) { + events.push({ success, code: error?.code, authorized: socket.authorized }); + }, + data() {}, + error() {}, + close() { + closed.resolve(); + }, + }, + } as any); + await closed.promise; + return events; + } + const rejected = [{ success: false, code: "DEPTH_ZERO_SELF_SIGNED_CERT", authorized: false }]; + expect({ object: await handshakeWith({}), shorthand: await handshakeWith(true) }).toEqual({ + object: rejected, + shorthand: rejected, + }); + }); + + it("completes a verified handshake when the certificate is trusted", async () => { + using dir = tempDir("tls-unset-trusted", { "ca.pem": tls.cert }); + using listener = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls, + socket: { data() {}, error() {} }, + }); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + /* js */ ` + await Bun.connect({ + hostname: "127.0.0.1", + port: ${listener.port}, + tls: {}, + socket: { + open(socket) { + socket.write("SECRET"); + }, + handshake(socket, success, error) { + console.log(JSON.stringify({ success, code: error?.code ?? null, authorized: socket.authorized })); + socket.end(); + }, + data() {}, + error() {}, + }, + }); + `, + ], + env: { ...bunEnv, NODE_EXTRA_CA_CERTS: join(String(dir), "ca.pem") }, + 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({ success: true, code: null, authorized: true }), + stderr: "", + exitCode: 0, + }); + }); + + it("Bun.listen({ tls: {} }) still listens without TLS", async () => { + const received = Promise.withResolvers(); + using listener = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls: {}, + socket: { + data(_socket, chunk) { + received.resolve(chunk.toString()); + }, + error() {}, + }, + }); + using client = await Bun.connect({ + hostname: "127.0.0.1", + port: listener.port, + socket: { + open(socket) { + socket.write("plain"); + }, + data() {}, + error() {}, + }, + }); + expect(await received.promise).toBe("plain"); + }); +}); + +it.concurrent("end(data) from a drain handler sends the tail it queues", async () => { + const payload = randomFillSync(Buffer.allocUnsafe(8 << 20)); + const received: Buffer[] = []; + const serverClosed = Promise.withResolvers(); + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + data: (_socket, chunk) => void received.push(chunk), + close: () => serverClosed.resolve(), + }, + }); + let took = 0; + let endReturned = -2; + const endWithTheRest = (socket: Socket) => void (endReturned = socket.end(payload.subarray(took))); + await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + socket: { + open(socket) { + took = socket.write(payload); + // Windows takes a first send of any size whole, so no drain follows. + if (took === payload.length) endWithTheRest(socket); + }, + drain: endWithTheRest, + data() {}, + }, + }); + await serverClosed.promise; + expect(endReturned).toBe(payload.length - took); + expect(Buffer.concat(received).equals(payload)).toBe(true); +}); diff --git a/test/js/bun/net/tcp-server.test.ts b/test/js/bun/net/tcp-server.test.ts index 8078f75e683b..7ea1d0930608 100644 --- a/test/js/bun/net/tcp-server.test.ts +++ b/test/js/bun/net/tcp-server.test.ts @@ -393,3 +393,30 @@ describe("TLS listener: stop(true) from inside a selection callback", () => { expect(exitCode).toBe(0); }); }); + +it("runs the event loop between batches of a long accept queue", async () => { + const count = 100; + let accepted = 0; + const acceptedBeforeImmediate = Promise.withResolvers(); + const allAccepted = Promise.withResolvers(); + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open() { + if (++accepted === 1) setImmediate(() => acceptedBeforeImmediate.resolve(accepted)); + if (accepted === count) allAccepted.resolve(); + }, + data() {}, + }, + }); + // Each call runs connect(2) before it returns, so every connection is queued before the listener's first poll event. + const clients = await Promise.all( + Array.from({ length: count }, () => + Bun.connect({ hostname: "127.0.0.1", port: server.port, socket: { data() {} } }), + ), + ); + await allAccepted.promise; + for (const client of clients) client.terminate(); + expect(await acceptedBeforeImmediate.promise).toBeLessThan(count); +}); diff --git a/test/js/bun/net/tls-fatal-error-closes.test.ts b/test/js/bun/net/tls-fatal-error-closes.test.ts index f71abac77fb8..c3848799732a 100644 --- a/test/js/bun/net/tls-fatal-error-closes.test.ts +++ b/test/js/bun/net/tls-fatal-error-closes.test.ts @@ -483,3 +483,32 @@ describe.each(["TLSv1.3", "TLSv1.2"] as const)("a bad record on an established % }); }); }); + +// The peer resets the connection as it accepts it, so the kernel can refuse the send() of the ClientHello. +test.each([false, true])("a client whose peer resets at accept hears ECONNRESET (write in open: %p)", async writes => { + using listener = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { open: socket => void socket.terminate(), data() {} }, + }); + const events: string[] = []; + const closed = Promise.withResolvers(); + await Bun.connect({ + hostname: "127.0.0.1", + port: listener.port, + tls: { rejectUnauthorized: false }, + socket: { + open(socket) { + if (writes) socket.write("x"); + }, + handshake: (_, success, error) => void events.push(`handshake(${success}, ${error?.code})`), + data() {}, + close(_, error) { + events.push(`close(${error?.code})`); + closed.resolve(); + }, + }, + }); + await closed.promise; + expect(events).toEqual(["handshake(false, ECONNRESET)", "close(ECONNRESET)"]); +}); diff --git a/test/js/bun/net/tls-parked-handshake-timeout-fixture.ts b/test/js/bun/net/tls-parked-handshake-timeout-fixture.ts new file mode 100644 index 000000000000..b1d5c2481755 --- /dev/null +++ b/test/js/bun/net/tls-parked-handshake-timeout-fixture.ts @@ -0,0 +1,165 @@ +// Regression fixture: the timeouts of a TLS socket that waits in the low-priority handshake queue. +// +// uSockets runs at most 5 TLS handshake reads per loop iteration. A handshaking socket that +// becomes readable after the budget is spent is PARKED: unlinked from its group, linked into +// loop->data.low_prio_head, and its readable poll is switched off. The start of every later +// iteration takes 5 sockets out of the queue. +// +// The loop holds back the reads of a parked socket, so the socket's timeouts must not run there: +// the owner would act on a peer whose bytes nobody read. They have to run again once the queue +// releases the socket, and after 30 sweep ticks (120 s) in the queue at the latest. +// +// Server and clients share this process, so a loop iteration is one step for both sides (see +// tls-fixture-transport.ts). A client is a plain socket that sends a TLS record one piece at a +// time, so its server socket stays in the handshake and each piece is one more handshake read. +// A round parks 15 of the 20 server sockets: all 20 are paused with an unread piece, all 20 +// resume, and the next iteration reads 5 and parks the rest. The sweep is run by hand, which +// turns no loop iteration, so the queue keeps those 15 for as many ticks as a step needs. +import type { Socket } from "bun"; +import { getEventLoopStats, sweepSocketTimers } from "bun:internal-for-testing"; +import { tls as certs } from "harness"; +import { connect, listen } from "./tls-fixture-transport"; + +// MAX_LOW_PRIO_SOCKETS_PER_LOOP_ITERATION in packages/bun-usockets/src/loop.c. +const BUDGET = 5; +const N = 4 * BUDGET; +// MAX_LOW_PRIO_TICKS in packages/bun-usockets/src/loop.c. +const MAX_PARKED_TICKS = 30; +// The timeout wheel has 240 slots. +const WHEEL = 240; +// Well inside the test's own timeout, so a stalled step still prints the summary. +const STEP_DEADLINE_MS = 20_000; +// A handshake record that announces 512 more bytes. +const RECORD_HEAD = new Uint8Array([0x16, 0x03, 0x01, 0x02, 0x00]); + +const iteration = () => getEventLoopStats().iteration; + +// Each entry counts the `timeout` handlers that ran at one point of a step. +const summary = { + opened: 0, + // One-tick timeouts: [while 15 sockets were parked, after the queue let them go], for each of + // enough rounds that the stamps of the parked sockets pass the end of the wheel. + oneTick: [] as string[], + // No timeout armed: [while parked, in a full turn of the wheel after]. + unarmed: [] as number[], + // The sockets stay parked: [before their 30th tick in the queue, after it]. + parkedTooLong: [] as number[], + errors: 0, +}; + +function report(extra: Record = {}): never { + console.log(JSON.stringify({ ...summary, ...extra })); + process.exit(extra.error ? 1 : 0); +} + +// One turn of the immediate queue is one loop iteration. +async function until(step: string, done: () => boolean) { + const deadline = Date.now() + STEP_DEADLINE_MS; + while (!done()) { + if (Date.now() > deadline) report({ error: `timed out waiting for ${step}` }); + await new Promise(r => setImmediate(r)); + } +} +function afterIterations(n: number) { + const target = iteration() + n; + return until(`${n} loop iterations`, () => iteration() >= target); +} + +const accepted: Socket[] = []; +let clientOpens = 0; +let timeouts = 0; +function takeTimeouts() { + const n = timeouts; + timeouts = 0; + return n; +} + +const { server, address } = listen("s", { + tls: { key: certs.key, cert: certs.cert }, + socket: { + open(s) { + summary.opened++; + s.pause(); + accepted.push(s); + }, + // With this handler, `open` runs when the connection opens and not when the handshake ends. + handshake() {}, + timeout() { + timeouts++; + }, + data() {}, + error() { + summary.errors++; + }, + }, +}); + +const clients: Socket[] = []; + +// Every server socket is paused with an unread piece. Parks N - BUDGET of them and arms `seconds` on all N. +async function park(seconds: number) { + for (const s of accepted) s.resume(); + await afterIterations(1); + for (const s of accepted) s.timeout(seconds); + takeTimeouts(); +} +// Empties the queue and leaves every server socket paused with one more unread piece. +async function release() { + for (const s of accepted) s.pause(); + await afterIterations((N - BUDGET) / BUDGET + 2); + for (const c of clients) c.write(RECORD_HEAD.subarray(0, 1)); + await afterIterations(2); +} + +try { + // One at a time, so that every record head is written before the next step. + for (let i = 0; i < N; i++) { + clients.push( + await connect(address, { + socket: { + open(c) { + clientOpens++; + c.write(RECORD_HEAD); + }, + data() {}, + error() { + summary.errors++; + }, + }, + }), + ); + } + await until(`${N} connections`, () => accepted.length === N && clientOpens === N); + // One more turn and every record head is in the server's receive queue. + await afterIterations(2); + + const oneTick = new Set(); + for (let swept = 0; swept < WHEEL + 2 * MAX_PARKED_TICKS; swept += 21) { + await park(1); + sweepSocketTimers(20); + const whileParked = takeTimeouts(); + await release(); + sweepSocketTimers(1); + oneTick.add(`${whileParked},${takeTimeouts()}`); + } + summary.oneTick = [...oneTick]; + + await park(0); + sweepSocketTimers(20); + summary.unarmed.push(takeTimeouts()); + await release(); + sweepSocketTimers(WHEEL); + summary.unarmed.push(takeTimeouts()); + + await park(1); + sweepSocketTimers(MAX_PARKED_TICKS - 5); + summary.parkedTooLong.push(takeTimeouts()); + sweepSocketTimers(10); + summary.parkedTooLong.push(takeTimeouts()); +} catch (e) { + report({ error: String(e) }); +} finally { + for (const c of clients) c.terminate(); + server.stop(true); +} +report(); diff --git a/test/js/bun/net/tls-parked-handshake-turns-fixture.ts b/test/js/bun/net/tls-parked-handshake-turns-fixture.ts new file mode 100644 index 000000000000..26926b1d4c68 --- /dev/null +++ b/test/js/bun/net/tls-parked-handshake-turns-fixture.ts @@ -0,0 +1,136 @@ +// Regression fixture: the loop releases TLS sockets parked in the low-priority handshake queue +// without waiting for an unrelated timer or event. +// +// uSockets runs at most 5 TLS handshake reads per loop iteration and parks the other handshaking +// sockets that became readable, with their readable poll off. The start of each later iteration +// releases 5 of them (see tls-pause-parked-handshake-fixture.ts). A parked socket's input can't +// wake the loop, so the loop must not block while the queue has sockets in it. +// +// Server and clients share this process (see tls-fixture-transport.ts): +// 1. N clients connect. The server pauses each accepted socket in `open`, so every ClientHello +// stays unread in the kernel. +// 2. The server resumes all N at once. The next iteration reads 5 ClientHellos and parks the +// other N - 5. +// 3. An immediate of that same iteration pauses every socket except the first one parked. The +// queue is last-in-first-out, so that socket is released last. A paused socket is released +// with its reads still off, so no readable event leads the loop to the next iteration. +// 4. The fixture schedules nothing else. That socket's handshake completes only if the loop +// keeps iterating on its own: ceil((N - 6) / 5) iterations to reach it. +import type { Socket } from "bun"; +import { getEventLoopStats } from "bun:internal-for-testing"; +import { tls as certs } from "harness"; +import { connect, listen } from "./tls-fixture-transport"; + +const N = 64; +// MAX_LOW_PRIO_SOCKETS_PER_LOOP_ITERATION in packages/bun-usockets/src/loop.c. +const BUDGET = 5; +// Well inside the test's own timeout, so a stalled step still prints the summary. +const STEP_DEADLINE_MS = 20_000; +// A loop that only wakes for its 4 s timeout sweep needs 12 sweeps to reach the socket. +const PARKED_DEADLINE_MS = 3_000; + +const iteration = () => getEventLoopStats().iteration; + +const summary = { + opened: 0, + // The 5 clients read in step 2, plus the one socket left unpaused. + clientHandshakes: -1, + // Loop iterations from the pause in step 3 to the unpaused socket's handshake. + iterationsToHandshake: -1, + errors: 0, +}; + +function report(extra: Record = {}): never { + console.log(JSON.stringify({ ...summary, ...extra })); + process.exit(extra.error ? 1 : 0); +} + +async function until(step: string, done: () => boolean) { + const deadline = Date.now() + STEP_DEADLINE_MS; + while (!done()) { + if (Date.now() > deadline) report({ error: `timed out waiting for ${step}` }); + await new Promise(r => setImmediate(r)); + } +} +function afterIterations(n: number) { + const target = iteration() + n; + return until(`${n} loop iterations`, () => iteration() >= target); +} + +const accepted: Socket[] = []; +let unpaused: Socket | undefined; +const handshakeDone = Promise.withResolvers(); +let clientOpens = 0; +let clientHandshakes = 0; + +const { server, address } = listen("s", { + tls: { key: certs.key, cert: certs.cert }, + socket: { + open(s) { + summary.opened++; + s.pause(); + accepted.push(s); + }, + handshake(s, success) { + if (success && s === unpaused) handshakeDone.resolve(); + }, + data() {}, + error() { + summary.errors++; + }, + }, +}); + +const clients: Socket[] = []; +try { + for (let i = 0; i < N; i++) { + clients.push( + await connect(address, { + tls: { ca: certs.cert }, + socket: { + // The ClientHello goes out as soon as this returns. + open() { + clientOpens++; + }, + handshake(_, success) { + if (success) clientHandshakes++; + }, + data() {}, + error() { + summary.errors++; + }, + }, + }), + ); + } + await until(`${N} connections`, () => accepted.length === N && clientOpens === N); + // One more iteration and every ClientHello is in the server's receive queue. + await afterIterations(2); + + // Step 2. + for (const s of accepted) s.resume(); + // Immediates run after an iteration's dispatch, so the first one that sees the counter advance + // runs with the budget spent and N - 5 sockets parked. + await afterIterations(1); + + // Step 3. + unpaused = accepted[BUDGET]; + for (const s of accepted) if (s !== unpaused) s.pause(); + + // Step 4. + const parkedAt = iteration(); + const stalled = setTimeout( + () => report({ error: "the socket is still parked", iterationsToHandshake: iteration() - parkedAt }), + PARKED_DEADLINE_MS, + ); + await handshakeDone.promise; + clearTimeout(stalled); + summary.iterationsToHandshake = iteration() - parkedAt; + summary.clientHandshakes = clientHandshakes; +} catch (e) { + report({ error: String(e) }); +} finally { + for (const c of clients) c.terminate(); + server.stop(true); +} +report(); diff --git a/test/js/bun/net/tls-serve-deferred-accept-fixture.ts b/test/js/bun/net/tls-serve-deferred-accept-fixture.ts new file mode 100644 index 000000000000..13cbb8454379 --- /dev/null +++ b/test/js/bun/net/tls-serve-deferred-accept-fixture.ts @@ -0,0 +1,132 @@ +// A TLS connection that arrives with input while the handshake budget of the loop iteration is +// spent is parked in the handshake queue at once, and its timeout does not run there. +// +// With a deferred accept (Linux, FreeBSD) the accept path dispatches a readable event for the new +// socket itself, and over the budget that dispatch is what parks the socket. This server accepts +// 12 connections that each hold the head of a TLS record in one loop iteration: 5 are read, 7 are +// over the budget. Then it runs the timeout sweep by hand for the 3 ticks that a connection has +// to send its request. The 5 sockets that were read are linked, so their timeout comes due and +// the server closes them. The 7 must be in the queue, untouched: the server closes them only +// after the queue released them and 3 more ticks passed. +// +// The clients run in a child process, so that they can connect while this loop is blocked. +// +// Prints one JSON line: { clients, closedWhileParked, closedAfterRelease }. + +import { getEventLoopStats, sweepSocketTimers } from "bun:internal-for-testing"; +import { tempDir, tls as certs } from "harness"; +import { existsSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const N = 12; +// MAX_LOW_PRIO_SOCKETS_PER_LOOP_ITERATION in packages/bun-usockets/src/loop.c. +const BUDGET = 5; +// The 10 s that a connection has to send its request, in sweep ticks. +const TIMEOUT_TICKS = 3; +// Well inside the test's own timeout, so a step that stalls ends in an error. +const STEP_DEADLINE_MS = 20_000; +// A handshake record that announces 512 more bytes. +const RECORD_HEAD = new Uint8Array([0x16, 0x03, 0x01, 0x02, 0x00]); + +if (process.argv[2] === "client") { + await client(Number(process.argv[3]), Number(process.argv[4]), process.argv[5]); +} else { + await server(); +} + +async function server() { + // The clients create this file once every record head is on its way. + using dir = tempDir("tls-serve-deferred-accept", {}); + const everyHeadSent = join(String(dir), "every-head-sent"); + + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + tls: { cert: certs.cert, key: certs.key }, + fetch: () => new Response("ok"), + }); + // The clients answer a byte on this connection with the number of their sockets that have closed. + let answer = Promise.withResolvers(); + let ask: (() => Promise) | undefined; + const control = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + data(socket, chunk) { + answer.resolve(Number(chunk.toString())); + }, + open(socket) { + ask = () => { + answer = Promise.withResolvers(); + socket.write("?"); + return answer.promise; + }; + }, + }, + }); + + await using child = Bun.spawn({ + cmd: [process.execPath, import.meta.path, "client", String(server.port), String(control.port), everyHeadSent], + env: process.env, + stdout: "inherit", + stderr: "inherit", + }); + + // The loop stays blocked until every record head is in the kernel, so one iteration accepts them all. + const deadline = Date.now() + STEP_DEADLINE_MS; + while (!existsSync(everyHeadSent)) { + if (Date.now() > deadline) throw new Error("timed out waiting for the clients to connect"); + Bun.sleepSync(5); + } + await iterations(1); + sweepSocketTimers(TIMEOUT_TICKS); + // These iterations empty the queue: every socket it held is read and linked, with its 3 ticks left. + await iterations(Math.ceil((N - BUDGET) / BUDGET) + 2); + if (!ask) throw new Error("the control connection did not open"); + const closedWhileParked = await ask(); + sweepSocketTimers(TIMEOUT_TICKS); + const closedAfterRelease = await ask(); + + console.log(JSON.stringify({ clients: N, closedWhileParked, closedAfterRelease })); + child.kill(); + control.stop(true); + await server.stop(true); +} + +// An immediate runs once per loop iteration. +async function iterations(n: number) { + const target = getEventLoopStats().iteration + n; + while (getEventLoopStats().iteration < target) await new Promise(resolve => setImmediate(resolve)); +} + +async function client(port: number, controlPort: number, everyHeadSent: string) { + let opened = 0; + let closed = 0; + await Bun.connect({ + hostname: "127.0.0.1", + port: controlPort, + socket: { + // The closes that the server made before it asked are in the kernel already: two turns deliver them. + data(socket) { + setImmediate(() => setImmediate(() => socket.write(String(closed)))); + }, + }, + }); + for (let i = 0; i < N; i++) { + await Bun.connect({ + hostname: "127.0.0.1", + port, + socket: { + open(socket) { + socket.write(RECORD_HEAD); + if (++opened === N) setImmediate(() => setImmediate(() => writeFileSync(everyHeadSent, ""))); + }, + data() {}, + error() {}, + close() { + closed++; + }, + }, + }); + } +} diff --git a/test/js/bun/spawn/pidfd-exit-nested-tick.test.ts b/test/js/bun/spawn/pidfd-exit-nested-tick.test.ts index dbebd6c854b0..499894978682 100644 --- a/test/js/bun/spawn/pidfd-exit-nested-tick.test.ts +++ b/test/js/bun/spawn/pidfd-exit-nested-tick.test.ts @@ -20,8 +20,10 @@ // Fix: register the pidfd level-triggered (no EPOLLONESHOT). A pidfd stays // readable from process exit until close, so a dropped ready_polls slot is // harmless — the next epoll_wait returns it again. +import { createSocketPair } from "bun:internal-for-testing"; import { expect, test } from "bun:test"; -import { isLinux } from "harness"; +import { isLinux, isWindows } from "harness"; +import { closeSync, writeSync } from "node:fs"; // pidfd path is Linux-only; macOS/FreeBSD use EVFILT_PROC which is keyed // on pid and auto-removed by the kernel when the process is reaped. @@ -67,3 +69,22 @@ test.skipIf(!isLinux)( await Promise.all(exits); }, ); + +test.skipIf(isWindows)("a nested event-loop tick dispatches the rest of the outer tick's batch", async () => { + const [socketFd, socketPeer] = createSocketPair(); + const [streamFd, streamPeer] = createSocketPair(); + const received = Promise.withResolvers(); + using _socket = await Bun.connect({ fd: socketFd, socket: { data: () => received.resolve() } }); + // The stream polls its fd one-shot, like a subprocess's stdout. + const text = new Response(Bun.file(streamFd).stream()).text(); + await new Promise(resolve => setImmediate(resolve)); + // Both fds are ready before the next poll, the socket first. + writeSync(socketPeer, "x"); + writeSync(streamPeer, "hi"); + closeSync(streamPeer); + closeSync(socketPeer); + await received.promise; + // This runs in a microtask under the socket's poll callback, so waiting for the timer ticks the loop from inside it. + expect(Bun.sleep(1)).resolves.toBe(undefined); + expect(await text).toBe("hi"); +}); diff --git a/test/js/bun/spawn/spawn.ipc.test.ts b/test/js/bun/spawn/spawn.ipc.test.ts index 1851c4adbe53..1a6ad3383f52 100644 --- a/test/js/bun/spawn/spawn.ipc.test.ts +++ b/test/js/bun/spawn/spawn.ipc.test.ts @@ -1,6 +1,7 @@ import { spawn } from "bun"; +import { dlopen } from "bun:ffi"; import { describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, gcTick, isWindows, tempDir } from "harness"; +import { bunEnv, bunExe, gcTick, isWindows, libcPathForDlopen, 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. @@ -486,3 +487,129 @@ it.skipIf(isWindows)("advanced serialization advertises wire format version 2", expect(JSON.parse(stdout.trim())).toEqual([1, 2, 0, 0, 0]); expect(exitCode).toBe(0); }); + +// The peer hand-rolls the sendmsg(2) so it can attach three descriptors to one +// NODE_HANDLE message, which neither Bun nor libuv ever do. The receiver (this +// test process) has to use the first one and close the others. The peer keeps +// the far ends of the two extras, so EOF on those proves we closed our copies. +const scmRightsPeerSource = ` +const { dlopen, FFIType, ptr } = require("bun:ffi"); +const isDarwin = process.platform === "darwin"; +const libc = dlopen(process.env.LIBC_PATH, { + socket: { args: [FFIType.int, FFIType.int, FFIType.int], returns: FFIType.int }, + socketpair: { args: [FFIType.int, FFIType.int, FFIType.int, FFIType.ptr], returns: FFIType.int }, + sendmsg: { args: [FFIType.int, FFIType.ptr, FFIType.int], returns: FFIType.i64_fast }, + recv: { args: [FFIType.int, FFIType.ptr, FFIType.u64, FFIType.int], returns: FFIType.i64_fast }, + close: { args: [FFIType.int], returns: FFIType.int }, +}); +const AF_UNIX = 1, AF_INET = 2, SOCK_STREAM = 1, SOCK_DGRAM = 2; +const SOL_SOCKET = isDarwin ? 0xffff : 1; +const SCM_RIGHTS = 1; +const MSG_DONTWAIT = isDarwin ? 0x80 : 0x40; + +function socketpair() { + const fds = new Int32Array(2); + if (libc.symbols.socketpair(AF_UNIX, SOCK_STREAM, 0, fds) !== 0) throw new Error("socketpair() failed"); + return fds; +} + +// fds[0] is the handle the message describes; the receiver must close the rest. +const udp = libc.symbols.socket(AF_INET, SOCK_DGRAM, 0); +if (udp < 0) throw new Error("socket() failed"); +const [extraA, keptA] = socketpair(); +const [extraB, keptB] = socketpair(); +const fds = [udp, extraA, extraB]; + +const payload = Buffer.from(JSON.stringify({ cmd: "NODE_HANDLE", type: "dgram.Native", msg: { hello: "handle" } }) + "\\n"); + +// struct cmsghdr: Linux { size_t len; int level; int type; } (data 8-aligned), +// Darwin { socklen_t len; int level; int type; } (data 4-aligned). Both are +// little-endian, so 32-bit writes into a zeroed buffer fill the wider fields too. +const cmsgHeader = isDarwin ? 12 : 16; +const cmsgAlign = isDarwin ? 4 : 8; +const cmsgLen = cmsgHeader + 4 * fds.length; +const cmsgSpace = cmsgHeader + Math.ceil((4 * fds.length) / cmsgAlign) * cmsgAlign; +const control = new Uint8Array(cmsgSpace); +const controlView = new DataView(control.buffer); +controlView.setUint32(0, cmsgLen, true); +controlView.setInt32(isDarwin ? 4 : 8, SOL_SOCKET, true); +controlView.setInt32(isDarwin ? 8 : 12, SCM_RIGHTS, true); +fds.forEach((fd, i) => controlView.setInt32(cmsgHeader + 4 * i, fd, true)); + +// struct iovec { void *base; size_t len; } is the same on both. +const iov = new Uint8Array(16); +const iovView = new DataView(iov.buffer); +iovView.setBigUint64(0, BigInt(ptr(payload)), true); +iovView.setUint32(8, payload.byteLength, true); + +// struct msghdr: name @0, namelen @8, iov @16, iovlen @24 (size_t on Linux, int +// on Darwin), control @32, controllen @40 (size_t on Linux, socklen_t on Darwin +// followed by flags @44), flags @48 on Linux. Zeroed buffer, so the narrower +// Darwin fields and the padding come out right from the same 32-bit writes. +const msg = new Uint8Array(56); +const msgView = new DataView(msg.buffer); +msgView.setBigUint64(16, BigInt(ptr(iov)), true); +msgView.setUint32(24, 1, true); +msgView.setBigUint64(32, BigInt(ptr(control)), true); +msgView.setUint32(40, control.byteLength, true); + +// Bun put the IPC channel on fd 3 (no other extra stdio). +const sent = libc.symbols.sendmsg(3, msg, 0); +if (sent !== payload.byteLength) throw new Error("sendmsg() returned " + sent); +// Drop our copies: from here on only the receiver keeps extraA/extraB open, so +// EOF on keptA/keptB means the receiver closed them. +for (const fd of fds) libc.symbols.close(fd); + +// Nothing is ever written on these pairs, so recv() is 0 (EOF) once every copy +// of the far end is closed and -1 (EAGAIN) while the receiver still holds one. +process.on("message", () => { + const probe = new Uint8Array(1); + const state = fd => (libc.symbols.recv(fd, probe, 1, MSG_DONTWAIT) === 0 ? "closed" : "open"); + process.send({ extras: [state(keptA), state(keptB)] }); +}); +`; + +it.skipIf(isWindows)( + "a NODE_HANDLE message carrying several descriptors delivers the first and closes the rest", + async () => { + using dir = tempDir("ipc-scm-rights", { "peer.js": scmRightsPeerSource }); + const { promise, resolve, reject } = Promise.withResolvers<{ message: unknown; handle: any; peer: unknown }>(); + let first: { message: unknown; handle: any } | undefined; + await using child = spawn({ + cmd: [bunExe(), "peer.js"], + cwd: String(dir), + env: { ...bunEnv, LIBC_PATH: libcPathForDlopen() }, + stdio: ["ignore", "inherit", "inherit"], + serialization: "json", + ipc(message, subprocess, handle) { + if (!first) { + first = { message, handle }; + // By the time the handle reaches us the receive path has already dealt + // with the other descriptors, so the peer can check its ends now. + subprocess.send("probe"); + } else { + resolve({ ...first, peer: message }); + } + }, + onExit(_subprocess, exitCode, signalCode) { + reject(new Error(`peer exited (${exitCode}, ${signalCode}) before reporting`)); + }, + }); + const { message, handle, peer } = await promise; + + // `handle` is the dgram wrap Bun built around the first descriptor, so + // handle.fd is that descriptor as received into this process. + const F_GETFD = 1; + const FD_CLOEXEC = 1; + const libc = dlopen(libcPathForDlopen(), { fcntl: { args: ["int", "int"], returns: "int" } }); + const fdFlags = libc.symbols.fcntl(handle.fd, F_GETFD); + libc.close(); + handle.close(); + + expect({ message, peer, cloexec: fdFlags & FD_CLOEXEC }).toEqual({ + message: { hello: "handle" }, + peer: { extras: ["closed", "closed"] }, + cloexec: FD_CLOEXEC, + }); + }, +); diff --git a/test/js/node/child_process/child_process.test.ts b/test/js/node/child_process/child_process.test.ts index 7845b6d9f361..9b1b4ca1c0be 100644 --- a/test/js/node/child_process/child_process.test.ts +++ b/test/js/node/child_process/child_process.test.ts @@ -29,6 +29,7 @@ import { import { getEventListeners, once, setMaxListeners } from "node:events"; import net from "node:net"; import os from "node:os"; +import { createInterface } from "node:readline"; import tls from "node:tls"; import { promisify } from "node:util"; import path from "path"; @@ -621,6 +622,111 @@ describe("spawn()", () => { } }); + // Windows: spawn() rejects a socket handle (EBADF from uv_spawn). + describe.skipIf(isWindows)("a net.Socket as the child's stdin", () => { + const size = 200_000; + const payload = Buffer.alloc(size, "a"); + const countStdin = `let n = 0; process.stdin.on("data", d => (n += d.length)).on("end", () => console.log(n));`; + + // Call this from the listener that receives the socket. Resolves once the child has read to EOF. + async function handOff(socket: net.Socket) { + const child = spawn(bunExe(), ["-e", countStdin], { env: bunEnv, stdio: [socket, "pipe", "inherit"] }); + try { + let stdout = ""; + child.stdout!.setEncoding("utf8").on("data", chunk => (stdout += chunk)); + const [exitCode] = await once(child, "close"); + return { childRead: Number(stdout), parentRead: socket.bytesRead, exitCode }; + } finally { + socket.destroy(); + } + } + + it.concurrent("the child reads every byte of an accepted socket", async () => { + const handedOff = Promise.withResolvers>>(); + const server = net.createServer(socket => { + socket.on("error", handedOff.reject); + handOff(socket).then(handedOff.resolve, handedOff.reject); + }); + server.on("error", handedOff.reject).listen(0, "127.0.0.1"); + await once(server, "listening"); + const peer = net.connect((server.address() as net.AddressInfo).port, "127.0.0.1"); + try { + peer.on("error", handedOff.reject).end(payload); + expect(await handedOff.promise).toEqual({ childRead: size, parentRead: 0, exitCode: 0 }); + } finally { + peer.destroy(); + server.close(); + } + }); + + it.concurrent("the child reads every byte of a client socket", async () => { + const handedOff = Promise.withResolvers>>(); + const server = net.createServer(peer => peer.on("error", handedOff.reject).end(payload)); + server.on("error", handedOff.reject).listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const socket = net.connect((server.address() as net.AddressInfo).port, "127.0.0.1", () => + handOff(socket).then(handedOff.resolve, handedOff.reject), + ); + socket.on("error", handedOff.reject); + expect(await handedOff.promise).toEqual({ childRead: size, parentRead: 0, exitCode: 0 }); + } finally { + server.close(); + } + }); + + // Serial: bun:test only kills a timed-out test's child processes when the test is serial. + it("the parent reads the rest with resume() after the child exits", async () => { + const done = Promise.withResolvers<{ childRead: number; parentRead: number }>(); + const readFirstChunk = `process.stdin.once("data", d => { console.log(d.length); process.exit(0); });`; + const server = net.createServer(socket => { + socket.on("error", done.reject); + let parentRead = 0; + const child = spawn(bunExe(), ["-e", readFirstChunk], { env: bunEnv, stdio: [socket, "pipe", "inherit"] }); + let stdout = ""; + child.stdout!.setEncoding("utf8").on("data", chunk => (stdout += chunk)); + child.on("close", () => { + socket.on("end", () => done.resolve({ childRead: Number(stdout), parentRead })); + socket.on("data", chunk => (parentRead += chunk.length)).resume(); + peer.end(payload); + }); + }); + server.on("error", done.reject).listen(0, "127.0.0.1"); + await once(server, "listening"); + const peer = net.connect((server.address() as net.AddressInfo).port, "127.0.0.1"); + try { + peer.on("error", done.reject).write("head"); + expect(await done.promise).toEqual({ childRead: 4, parentRead: size }); + } finally { + peer.destroy(); + server.close(); + } + }); + + // The peer never closes, so a parent that still reads the socket never exits. Serial for the same reason. + it("the socket doesn't keep the parent alive after the child exits", async () => { + const parentSource = ` + const net = require("net"); + const { spawn } = require("child_process"); + const server = net.createServer(socket => { + const child = spawn("sh", ["-c", "exit 0"], { stdio: [socket, "inherit", "inherit"] }); + child.on("close", code => console.log("child closed", code)); + server.close(); + }); + server.listen(0, "127.0.0.1", () => net.connect(server.address().port, "127.0.0.1").unref()); + `; + await using parent = Bun.spawn({ + cmd: [bunExe(), "-e", parentSource], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([parent.stdout.text(), parent.exited]); + expect(stdout).toBe("child closed 0\n"); + expect(exitCode).toBe(0); + }); + }); + it("overlapped string shorthand behaves like pipe", async () => { const child = spawn(bunExe(), ["-e", "process.stdin.on('data', d => process.stdout.write('out:' + d))"], { env: bunEnv, @@ -653,6 +759,92 @@ describe("spawn()", () => { expect(status).toBe(0); }); + // child_process wraps each extra pipe in net.connect({ fd }), which adopts the descriptor synchronously. + describe.concurrent("extra pipes (stdio[3+])", () => { + // The children read with blocking fs.readSync loops. The parent's sockets are under test. + function spawnWithExtraPipes(script: string, count: number) { + const child = spawn(bunExe(), ["-e", script], { + env: bunEnv, + stdio: ["ignore", "ignore", "inherit", ...Array(count).fill("pipe")], + }); + return { child, exited: once(child, "exit"), [Symbol.dispose]: () => void child.kill() }; + } + const readLines = (socket: net.Socket) => createInterface({ input: socket })[Symbol.asyncIterator](); + const connectionState = ({ connecting, pending, readyState }: net.Socket) => ({ + connecting, + pending, + readyState, + }); + const connected = { connecting: false, pending: false, readyState: "open" }; + + // Playwright's --remote-debugging-pipe sends requests to the browser's fd 3 and reads replies from fd 4. + it("are connected when spawn() returns and carry requests on stdio[3] and replies on stdio[4]", async () => { + using spawned = spawnWithExtraPipes( + `const fs = require("fs"), buf = Buffer.alloc(1024); + for (let n; (n = fs.readSync(3, buf)) > 0; ) fs.writeSync(4, "reply:" + buf.toString("utf8", 0, n) + "\\n"); + fs.writeSync(4, "eof\\n");`, + 2, + ); + const [requests, replies] = spawned.child.stdio.slice(3) as net.Socket[]; + expect([connectionState(requests), connectionState(replies)]).toEqual([connected, connected]); + + const lines = readLines(replies); + // Wait for each reply before the next request so the child reads one chunk per write. + for (const request of ["Target.getTargets", "Browser.close"]) { + const written = Promise.withResolvers(); + requests.write(request, err => written.resolve(err ?? null)); + expect(await lines.next()).toEqual({ value: "reply:" + request, done: false }); + expect(await written.promise).toBeNull(); + } + requests.end(); + expect(await lines.next()).toEqual({ value: "eof", done: false }); + expect((await lines.next()).done).toBe(true); + expect((await spawned.exited)[0]).toBe(0); + }); + + it("carry both directions over a single pipe", async () => { + using spawned = spawnWithExtraPipes( + `const fs = require("fs"), buf = Buffer.alloc(1024); + fs.writeSync(3, "ready\\n"); + for (let n; (n = fs.readSync(3, buf)) > 0; ) { + const request = buf.toString("utf8", 0, n); + fs.writeSync(3, "reply:" + request + "\\n"); + if (request === "bye") break; + }`, + 1, + ); + const pipe = spawned.child.stdio[3] as net.Socket; + expect(connectionState(pipe)).toEqual(connected); + + const lines = readLines(pipe); + expect(await lines.next()).toEqual({ value: "ready", done: false }); + for (const request of ["hello", "bye"]) { + pipe.write(request); + expect(await lines.next()).toEqual({ value: "reply:" + request, done: false }); + } + expect((await spawned.exited)[0]).toBe(0); + }); + + it("deliver a write larger than the pipe buffer", async () => { + using spawned = spawnWithExtraPipes( + `const fs = require("fs"), buf = Buffer.alloc(65536); + let bytes = 0; + for (let n; (n = fs.readSync(3, buf)) > 0; ) bytes += n; + fs.writeSync(4, bytes + "\\n");`, + 2, + ); + const [input, output] = spawned.child.stdio.slice(3) as net.Socket[]; + const lines = readLines(output); + const size = 1024 * 1024; + const written = Promise.withResolvers(); + input.write(Buffer.alloc(size, "x"), err => written.resolve(err ?? null)); + input.end(); + expect(await lines.next()).toEqual({ value: String(size), done: false }); + expect(await written.promise).toBeNull(); + expect((await spawned.exited)[0]).toBe(0); + }); + }); + describe("a socket as a stdio entry", () => { // Both ends of an established loopback connection: the socket `connect` returns and the one `server` // accepts for it. diff --git a/test/js/node/child_process/child_process_ipc_handle.test.ts b/test/js/node/child_process/child_process_ipc_handle.test.ts index a0694a6bdaf2..072bd40182a9 100644 --- a/test/js/node/child_process/child_process_ipc_handle.test.ts +++ b/test/js/node/child_process/child_process_ipc_handle.test.ts @@ -815,3 +815,70 @@ const server = net.createServer().listen(0, '127.0.0.1', () => { expect(exitCode).toBe(0); }); }); + +// On Windows both processes wake up for each connection on the shared listening socket, and the one that lost the race +// used to block in accept() until the next connection. A third process connects one at a time, so both acceptors are +// idle when a connection arrives. After each one the parent must handle an IPC message and the child must answer a ping. +test.skipIf(!isWindows)( + "a net.Server sent to a child doesn't block either process's event loop in accept()", + async () => { + using dir = tempDir("ipc-handle-shared-accept", { + "parent.js": ` +const { fork } = require('node:child_process'); +const net = require('node:net'); +const N = 100; +const accepted = { parent: 0, child: 0 }; +const server = net.createServer(socket => { accepted.parent++; socket.destroy(); }); +const acceptor = fork('child.js', ['acceptor']); +const client = fork('child.js', ['client']); +const connect = i => client.send({ port: server.address().port, i }); +client.on('message', ({ closed }) => acceptor.send({ ping: closed })); +acceptor.on('message', m => { + if (m === 'ready') return connect(0); + accepted.child = m.accepted; + if (m.pong + 1 < N) return connect(m.pong + 1); + server.close(); + client.disconnect(); + acceptor.disconnect(); +}); +acceptor.on('exit', code => console.log(JSON.stringify({ ...accepted, code }))); +server.listen(0, '127.0.0.1', () => acceptor.send('server', server)); +`, + "child.js": ` +const net = require('node:net'); +if (process.argv[2] === 'acceptor') { + let accepted = 0; + process.on('message', (m, server) => { + if (m !== 'server') return process.send({ pong: m.ping, accepted }); + server.on('connection', socket => { accepted++; socket.destroy(); }); + process.on('disconnect', () => server.close()); + process.send('ready'); + }); +} else { + process.on('message', ({ port, i }) => { + const socket = net.connect(port, '127.0.0.1'); + socket.on('error', () => {}); + socket.on('close', () => process.send({ closed: i })); + }); +} +`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "parent.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]); + const { parent, child, code } = JSON.parse(stdout.trim()); + expect({ accepted: parent + child, parentAccepted: parent > 0, childAccepted: child > 0, code, stderr }).toEqual({ + accepted: 100, + parentAccepted: true, + childAccepted: true, + code: 0, + stderr: "", + }); + expect(exitCode).toBe(0); + }, +); diff --git a/test/js/node/cluster.test.ts b/test/js/node/cluster.test.ts index 034a68b99502..6f99c2ffa540 100644 --- a/test/js/node/cluster.test.ts +++ b/test/js/node/cluster.test.ts @@ -274,6 +274,40 @@ if (cluster.isPrimary) { expect(stdout).toContain("exists after exit: false"); }); +test.skipIf(isWindows)("SCHED_NONE pipe listen rejects a path with a null byte instead of truncating it", async () => { + const dir = tempDirWithFiles("bun-test", { + "main.ts": ` +const cluster = require("node:cluster"); +const net = require("node:net"); +const fs = require("node:fs"); +const path = require("node:path"); + +cluster.schedulingPolicy = cluster.SCHED_NONE; + +if (cluster.isPrimary) { + const worker = cluster.fork(); + worker.on("message", m => { + console.log("worker:", JSON.stringify(m)); + console.log("truncated exists:", fs.existsSync(path.join(__dirname, "nul"))); + worker.disconnect(); + }); + worker.on("exit", code => { + process.exit(code ?? 1); + }); +} else { + const server = net.createServer(() => {}); + server.on("error", e => process.send({ code: e.code, syscall: e.syscall })); + server.listen(path.join(__dirname, "nul\\0byte.sock"), () => process.send({ listening: true })); +} +`, + }); + const { stdout, stderr, exitCode } = await bunRun(joinP(dir, "main.ts"), bunEnv); + expect(stdout).toContain(`worker: {"code":"EINVAL","syscall":"bind"}`); + expect(stdout).toContain("truncated exists: false"); + if (exitCode !== 0) expect(stderr).toBe(""); + expect(exitCode).toBe(0); +}); + test.skipIf(isWindows)("round-robin pipe listen applies readableAll/writableAll to the socket file", async () => { const dir = tempDirWithFiles("bun-test", { "main.ts": ` diff --git a/test/js/node/http/client-timeout-error.test.ts b/test/js/node/http/client-timeout-error.test.ts index 489f65200f61..5fe945e6c7f6 100644 --- a/test/js/node/http/client-timeout-error.test.ts +++ b/test/js/node/http/client-timeout-error.test.ts @@ -52,6 +52,18 @@ describe("node:http client timeout", () => { } }); + it("should emit timeout while the socket is still connecting", async () => { + // A lookup that never answers keeps the socket connecting. + const req = request({ host: "stalled.invalid", port: 80, timeout: 1, lookup: () => {} }); + try { + req.end(); + await once(req, "timeout"); + expect(req.socket!.connecting).toBe(true); + } finally { + req.on("error", () => {}).destroy(); + } + }); + it("should clear timeout when explicitly set to 0", async () => { const server = createServer((req, res) => { res.end("OK"); diff --git a/test/js/node/http/node-http-connect.test.ts b/test/js/node/http/node-http-connect.test.ts index aba4e11854e7..70b2f7e67dec 100644 --- a/test/js/node/http/node-http-connect.test.ts +++ b/test/js/node/http/node-http-connect.test.ts @@ -1231,6 +1231,48 @@ describe("HTTP server CONNECT", () => { }, ); + test.each([ + ["ends after the peer's FIN by default", undefined, "allowHalfOpen=false end close"], + ["stays writable after the peer's FIN with allowHalfOpen: true", true, "allowHalfOpen=true end writable"], + ])("an https 'upgrade' socket %s", async (_label, allowHalfOpen, expected) => { + await using server = https.createServer({ ...tlsCert, allowHalfOpen }); + const events: string[] = []; + const done = Promise.withResolvers(); + server.on("upgrade", (_req, socket) => { + events.push(`allowHalfOpen=${socket.allowHalfOpen}`); + socket.on("close", () => { + events.push("close"); + done.resolve(); + }); + socket.on("end", () => { + events.push("end"); + if (!allowHalfOpen) return; + socket.end("still writable", () => { + events.push("writable"); + socket.removeAllListeners("close"); + done.resolve(); + }); + }); + socket.resume(); + client.end(); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const client = tls.connect({ + port: (server.address() as AddressInfo).port, + host: "127.0.0.1", + ca: tlsCert.cert, + allowHalfOpen: true, + }); + client.on("error", done.reject); + client.write("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"); + try { + await done.promise; + expect(events.join(" ")).toBe(expected); + } finally { + client.destroy(); + } + }); + test.skipIf(isWindows)( "AF_UNIX CONNECT sockets whose peer closes first do not spin the loop on EPOLLHUP", async () => { diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index 4eab91c26c39..ec2389f548ad 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -35,6 +35,41 @@ import tunnel from "tunnel"; import { run as runHTTPProxyTest } from "./node-http-proxy.js"; const { describe, expect, it, beforeAll, afterAll, createDoneDotAll, mock, test } = createTest(import.meta.path); +describe.each([ + ["http", createServer], + ["https", createHttpsServer], +])("%s listen()", (_name, create) => { + it.each([65536, -1, 1.5, NaN, "65536", "1.5", ""])("throws ERR_SOCKET_BAD_PORT for port %p", async port => { + for (const args of [[port], [port, "127.0.0.1"], [{ port }], [{ port, path: "unused.sock" }]]) { + const server = create(); + const onError = mock(); + server.on("error", onError); + expect(() => (server.listen as any)(...args)).toThrow( + expect.objectContaining({ name: "RangeError", code: "ERR_SOCKET_BAD_PORT" }), + ); + await new Promise(resolve => setImmediate(resolve)); + expect(onError).not.toHaveBeenCalled(); + expect(server.listening).toBe(false); + } + }); + + it("rejects an options.port string that only starts with a number", () => { + expect(() => create().listen({ port: "80x" as any })).toThrow( + expect.objectContaining({ name: "RangeError", code: "ERR_SOCKET_BAD_PORT" }), + ); + }); + + it("prefers options.port over options.path", async () => { + const server = create(); + await once(server.listen({ port: "0" as any, host: "127.0.0.1", path: "unused.sock" }), "listening"); + try { + expect((server.address() as AddressInfo).port).toBeGreaterThan(0); + } finally { + server.close(); + } + }); +}); + function listen(server: Server, protocol: string = "http"): Promise { return new Promise((resolve, reject) => { const timeout = setTimeout(() => reject("Timed out"), 5000).unref(); @@ -69,19 +104,29 @@ describe("node:http", () => { } }); it("is not marked encrypted (#5867)", async () => { + // `"encrypted" in socket` is how callers tell a TLSSocket from a net.Socket, so a false value reads as HTTPS. + const tlsOnly = ["encrypted", "authorized", "authorizationError", "servername", "_secureEstablished"]; + const seen: Record = {}; + const server = createServer((req, res) => { + seen.request = tlsOnly.filter(name => name in req.socket); + res.end("Hello World"); + }); + server.on("connection", socket => { + seen.connection = tlsOnly.filter(name => name in socket); + }); try { - var server = createServer((req, res) => { - expect((req.connection as any).encrypted).toBe(false); - res.writeHead(200, { "Content-Type": "text/plain" }); - res.end("Hello World"); - }); - const url = await listen(server); - const res = await fetch(new URL("", url)); - expect(await res.text()).toBe("Hello World"); - } catch (e) { - throw e; + const { port } = new URL(await listen(server)); + const { promise, resolve, reject } = Promise.withResolvers(); + http + .get({ port, agent: false }, res => { + seen.client = tlsOnly.filter(name => name in res.socket); + res.resume().on("end", resolve).on("error", reject); + }) + .on("error", reject); + await promise; + expect(seen).toEqual({ connection: [], request: [], client: [] }); } finally { - server!.close(); + server.close(); } }); it("request & response body streaming (large)", async () => { @@ -450,8 +495,7 @@ describe("node:http", () => { }); describe("request", () => { - function runTest(done: Function, callback: (server: Server, port: number, done: (err?: Error) => void) => void) { - let timer; + function runTest(done: Function, callback: (server: Server, port: number, done: (err?: unknown) => void) => void) { const server = createServer((req, res) => { if (Object.getPrototypeOf(req.headers) !== Object.prototype) { // Like Node.js, req.headers is a plain object. @@ -462,7 +506,7 @@ describe("node:http", () => { if (reqUrl.pathname === "/redirect") { // Temporary redirect res.writeHead(301, { - Location: `http://localhost:${(server as any).port}/redirected`, + Location: `http://localhost:${(server.address() as AddressInfo).port}/redirected`, }); res.end("Got redirect!\n"); return; @@ -492,22 +536,8 @@ describe("node:http", () => { res.end("Hello World"); return; } - if (reqUrl.pathname.includes("timeout")) { - if (timer) clearTimeout(timer); - req.on("timeout", () => { - console.log("req timeout"); - }); - res.on("timeout", () => { - console.log("res timeout"); - }); - timer = setTimeout(() => { - if (res.closed) { - return; - } - - res.end("Hello World"); - timer = null; - }, 3000).unref(); + if (reqUrl.pathname === "/timeout") { + // Never respond. The client destroys the request when it times out. return; } if (reqUrl.pathname === "/pathTest") { @@ -584,12 +614,12 @@ describe("node:http", () => { res.end("Hello World"); } }); - server.listen({ port: 0 }, (_?: any, __?: any, port?: number) => { - var _done = (...args) => { + server.listen({ port: 0 }, () => { + const _done = (err?: unknown) => { server.close(); - done(...args); + done(err); }; - callback(server, port!, _done); + callback(server, (server.address() as AddressInfo).port, _done); }); } @@ -745,40 +775,76 @@ describe("node:http", () => { }); it("should allow us to set timeout with request.setTimeout or `timeout` in options", done => { - runTest(done, (server, serverPort, done) => { - const createDone = createDoneDotAll(done); - const req1Done = createDone(); - const req2Done = createDone(); - - const req1 = request( - { - host: "localhost", - port: serverPort, - path: "/timeout", - timeout: 500, - }, - res => { - req1Done(new Error("Should not have received response")); - }, - ); - req1.on("timeout", () => req1Done()); - - const req2 = request( - { - host: "localhost", - port: serverPort, - path: "/timeout", - }, - res => { - req2Done(new Error("Should not have received response")); - }, - ); - - req2.setTimeout(500, () => { - req2Done(); - }); - req1.end(); - req2.end(); + runTest(done, async (server, serverPort, done) => { + const requests: { + req: http.ClientRequest; + reason: Error; + events: string[]; + errors: Error[]; + destroyedAtTimeout: boolean[]; + closed: Promise; + }[] = []; + let failure; + try { + for (const useOptions of [true, false]) { + const reason = new Error(useOptions ? "options timeout cleanup" : "setTimeout cleanup"); + const req = request({ + host: "localhost", + port: serverPort, + path: "/timeout", + ...(useOptions ? { timeout: 500 } : {}), + }); + const { promise: closed, resolve: onClose } = Promise.withResolvers(); + const state = { + req, + reason, + events: [] as string[], + errors: [] as Error[], + destroyedAtTimeout: [] as boolean[], + closed, + }; + requests.push(state); + req.on("socket", () => state.events.push("socket")); + req.on("response", res => { + state.events.push("response"); + res.resume(); + req.destroy(reason); + }); + req.on("error", error => { + state.events.push("error"); + state.errors.push(error); + }); + req.once("close", () => { + state.events.push("close"); + onClose(); + }); + const onTimeout = () => { + state.events.push("timeout"); + state.destroyedAtTimeout.push(req.destroyed); + req.destroy(reason); + }; + if (useOptions) req.on("timeout", onTimeout); + else req.setTimeout(500, onTimeout); + req.end(); + } + // 'timeout' doesn't destroy the request, so wait for both destroy() calls to finish. + await Promise.all(requests.map(({ closed }) => closed)); + for (const { req, reason, events, errors, destroyedAtTimeout } of requests) { + expect(events).toEqual(["socket", "timeout", "error", "close"]); + expect(destroyedAtTimeout).toEqual([false]); + expect(errors).toHaveLength(1); + expect(errors[0]).toBe(reason); + expect(req.destroyed).toBe(true); + } + } catch (error) { + failure = error; + } finally { + for (const { req, reason } of requests) { + if (!req.destroyed) req.destroy(reason); + } + await Promise.all(requests.map(({ closed }) => closed)); + done(failure); + } }); }); @@ -1183,16 +1249,30 @@ describe("node:http", () => { }); }); - it("should emit a socket event when connecting", async done => { - runTest(done, async (server, serverPort, done) => { + it("should emit a socket event when connecting", done => { + runTest(done, (server, serverPort, done) => { const req = request(`http://localhost:${serverPort}`, {}); - // Destroying an in-flight request surfaces a "socket hang up" error, - // exactly like Node.js; swallow it so it does not leak into the next - // test as an unhandled error. - req.on("error", () => {}); - req.on("socket", function onRequestSocket(socket) { + const events: string[] = []; + const errors: Error[] = []; + req.on("error", error => { + events.push("error"); + errors.push(error); + }); + req.on("socket", () => { + events.push("socket"); req.destroy(); - done(); + }); + req.once("close", () => { + events.push("close"); + try { + expect(events).toEqual(["socket", "error", "close"]); + expect(errors).toHaveLength(1); + expect(errors[0]).toHaveProperty("code", "ECONNRESET"); + expect(req.destroyed).toBe(true); + done(); + } catch (error) { + done(error); + } }); req.end(); }); @@ -1524,31 +1604,28 @@ describe("node https server", async () => { key: nodefs.readFileSync(path.join(import.meta.dir, "fixtures", "cert.key")), cert: nodefs.readFileSync(path.join(import.meta.dir, "fixtures", "cert.pem")), }; - const createServer = onRequest => { - return new Promise<{ server: https.Server; done: () => void; url: URL }>(resolve => { - const server = createHttpsServer(httpsOptions, (req, res) => { - onRequest(req, res); - }); - listen(server, "https").then(url => { - resolve({ - server, - done: () => server.close(), - url, - }); - }); - }); - }; - it("is marked encrypted (#5867)", async () => { - const { server, url, done } = await createServer(async (req, res) => { - expect(req.connection.encrypted).toBe(true); + it.each([ + { how: "https.createServer(options)", start: () => createHttpsServer(httpsOptions).listen(0) }, + { how: "http.createServer().listen({ tls })", start: () => createServer().listen({ port: 0, tls: httpsOptions }) }, + ])("the socket of $how is marked encrypted (#5867)", async ({ start }) => { + let seen; + const server = start().on("request", (req, res) => { + const { encrypted, _secureEstablished, authorized, authorizationError, servername } = req.connection; + seen = { encrypted, _secureEstablished, authorized, authorizationError, servername }; res.end(); }); try { - await fetch(url, { tls: { rejectUnauthorized: false } }); - } catch (e) { - throw e; + await once(server, "listening"); + await fetch(`https://localhost:${server.address().port}`, { tls: { rejectUnauthorized: false } }); + expect(seen).toEqual({ + encrypted: true, + _secureEstablished: true, + authorized: false, + authorizationError: null, + servername: "localhost", + }); } finally { - done(); + server.close(); } }); }); @@ -5428,6 +5505,29 @@ it("connectionListener hands off Upgrade and CONNECT like Node", async () => { } }); +it("connectionListener reads from an injected socket that its previous owner paused", async () => { + const server = createServer((_req, res) => res.end("injected-ok")); + const front = createNetServer(socket => { + socket.pause(); + server.emit("connection", socket); + }); + try { + await once(front.listen(0, "127.0.0.1"), "listening"); + const { promise, resolve, reject } = Promise.withResolvers<{ statusCode: number | undefined; body: string }>(); + get({ host: "127.0.0.1", port: (front.address() as AddressInfo).port, agent: false }, response => { + let body = ""; + response.setEncoding("utf8"); + response.on("data", chunk => (body += chunk)); + response.on("end", () => resolve({ statusCode: response.statusCode, body })); + response.on("error", reject); + }).on("error", reject); + expect(await promise).toEqual({ statusCode: 200, body: "injected-ok" }); + } finally { + front.close(); + server.close(); + } +}); + // A TLS client that is mid-handshake when an https server with a 'clientError' listener is closed still // belongs to that server: once its handshake completes, a malformed request from it reaches 'clientError' // (as in Node). The connection used to go uncounted until the handshake finished, so close() considered the @@ -7922,3 +8022,39 @@ it("a listener can cancel Readable.toWeb(req) and end the response in the same t expect({ stdout, stderr }).toEqual({ stdout: "keep-alive timeout 4000\n200 ok\n", stderr: "" }); expect(exitCode).toBe(0); }); + +describe("a client request destroyed in the middle of its upload", () => { + it.each([ + ["write(chunk, cb)", "destroy(err)", ["error EBOOM", "write EBOOM"]], + ["end(cb)", "destroy(err)", ["error EBOOM", "end EBOOM"]], + ["end(chunk, cb)", "destroy(err)", ["error EBOOM", "end EBOOM"]], + ["end(cb)", "destroy()", ["end ECANCELED", "error ECONNRESET"]], + ["end(chunk, cb)", "destroy()", ["end ECANCELED", "error ECONNRESET"]], + ])("fails the callback of %s after %s", async (call, destroy, expected) => { + const peers: import("node:net").Socket[] = []; + const server = createNetServer({ pauseOnConnect: true }, peer => void peers.push(peer.on("error", () => {}))); + await once(server.listen(0, "127.0.0.1"), "listening"); + const req = http.request({ port: (server.address() as AddressInfo).port, host: "127.0.0.1", method: "POST" }); + try { + const events: string[] = []; + const log = (name: string) => (err?: NodeJS.ErrnoException | null) => void events.push(`${name} ${err?.code}`); + req.on("finish", log("finish")); + req.on("error", log("error")); + await once(req, "socket"); + await once(req.socket!, "connect"); + // The peer isn't reading, so the request's own writes queue behind a stuck one. + const filler = Buffer.alloc(1 << 20); + while (req.socket!.write(filler)) {} + if (call === "write(chunk, cb)") req.write("chunk", log("write")); + else if (call === "end(cb)") req.end(log("end")); + else req.end("chunk", log("end")); + req.destroy(destroy === "destroy()" ? undefined : Object.assign(new Error("boom"), { code: "EBOOM" })); + await new Promise(resolve => req.once("close", resolve)); + expect(events).toEqual(expected); + } finally { + req.destroy(); + for (const peer of peers) peer.destroy(); + server.close(); + } + }); +}); diff --git a/test/js/node/http/node-https-server.test.ts b/test/js/node/http/node-https-server.test.ts index a5489cd66603..ef87d60925e0 100644 --- a/test/js/node/http/node-https-server.test.ts +++ b/test/js/node/http/node-https-server.test.ts @@ -43,6 +43,17 @@ function tlsRequest(port: number): Promise { return promise; } +// Resolves with what a TLS client that sends no request sees first: "secureConnect", or the error code. +function tlsHandshake(port: number): Promise { + const { promise, resolve } = Promise.withResolvers(); + const socket = tls.connect({ port, host: "127.0.0.1", ca: validCert.cert }, () => { + resolve("secureConnect"); + socket.destroy(); + }); + socket.on("error", (err: NodeJS.ErrnoException) => resolve(String(err.code))); + return promise; +} + describe.concurrent("https.Server with no usable key and cert", () => { test.each<[string, (handler: http.RequestListener) => http.Server]>([ ["createServer(requestListener)", handler => https.createServer(handler)], @@ -256,8 +267,10 @@ describe("https.createServer forwards every TLS server option", () => { ["sessionTimeout", -1, "ERR_OUT_OF_RANGE"], ["sigalgs", "", "ERR_INVALID_ARG_VALUE"], ["crl", 42, "ERR_INVALID_ARG_TYPE"], + ["crl", "not a crl", "ERR_CRYPTO_OPERATION_FAILED"], + ["ciphers", "no-such-cipher", "ERR_SSL_NO_CIPHER_MATCH"], ["minVersion", "TLSv9", "ERR_TLS_INVALID_PROTOCOL_VERSION"], - ])("throws on an invalid %s like tls.createServer", (name, value, code) => { + ])("throws on an invalid %s: %p like tls.createServer", (name, value, code) => { expect(() => https.createServer({ ...validCert, [name]: value })).toThrow(expect.objectContaining({ code })); expect(() => https.createServer({ [name]: value })).toThrow(expect.objectContaining({ code })); }); @@ -273,6 +286,8 @@ describe("https.createServer forwards every TLS server option", () => { ["ticketKeys", ""], ["crl", ""], ["crl", 0], + ["ca", ""], + ["ca", 0], ["passphrase", 0], ])("listens with %s: %p like Node", async (name, value) => { await using server = https.createServer({ ...validCert, [name]: value }); @@ -291,6 +306,590 @@ describe("https.createServer forwards every TLS server option", () => { expect(() => https.createServer({ ...validCert, ecdhCurve: "X448:prime256v1" })).toThrow(error); }, ); + + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1384 + describe("builds the TLS context in the constructor", () => { + // ca1 signs agent1. ca2 signs agent3 and agent4. + const ca1 = read("ca1-cert.pem"); + const identity = { key: read("agent1-key.pem"), cert: read("agent1-cert.pem") }; + const verified = { ca: ca1, servername: "agent1", rejectUnauthorized: true }; + + type Construct = (options: https.ServerOptions, listener?: http.RequestListener) => http.Server; + const constructors: [string, Construct][] = [ + ["https.createServer()", (options, listener) => https.createServer(options, listener)], + ["new https.Server()", (options, listener) => new https.Server(options, listener)], + ]; + // Node's http.Server ignores TLS options. + if (process.versions.bun) { + constructors.push(["http.createServer()", (options, listener) => http.createServer(options as {}, listener)]); + } + + test.each(constructors)("%s ignores changes to its options after it returns", async (_label, construct) => { + const ca = [ca1]; + const key = Buffer.from(identity.key); + let requests = 0; + await using server = construct({ key, cert: identity.cert, ca, requestCert: true }, (req, res) => { + requests++; + verdict(req, res); + }); + ca.push(mtls.ca); + key.fill(0); + const port = await listen(server); + + expect(await get(port, "agent4", verified)).toBe("refused"); + expect(requests).toBe(0); + expect(await get(port, "agent1", verified)).toBe("authorized=true error=null"); + }); + + test.each(constructors)("%s throws on a key or certificate that BoringSSL rejects", (_label, construct) => { + function thrown(options: https.ServerOptions) { + try { + construct(options); + } catch (error) { + return (error as NodeJS.ErrnoException).code; + } + } + const encrypted = { + key: readFileSync(join(import.meta.dirname, "fixtures", "cert.encrypted.key"), "utf8"), + cert: readFileSync(join(import.meta.dirname, "fixtures", "cert.pem"), "utf8"), + }; + const mismatched = { key: read("agent3-key.pem"), cert: identity.cert }; + + expect({ + mismatched: thrown(mismatched), + certificate: thrown({ key: identity.key, cert: "not a certificate" }), + wrongPassphrase: thrown({ ...encrypted, passphrase: "wrong" }), + // Node validates the HTTP options first. + httpOption: thrown({ ...mismatched, headersTimeout: "soon" as any }), + }).toEqual({ + mismatched: "ERR_OSSL_X509_KEY_VALUES_MISMATCH", + certificate: "ERR_OSSL_PEM_NO_START_LINE", + wrongPassphrase: "ERR_OSSL_BAD_DECRYPT", + httpOption: "ERR_INVALID_ARG_TYPE", + }); + }); + + test("can't listen in cleartext after the constructor threw", async () => { + function Legacy(this: https.Server, options: https.ServerOptions) { + try { + (https.Server as any).call(this, options); + } catch {} + } + Object.setPrototypeOf(Legacy.prototype, https.Server.prototype); + const legacy: https.Server = new (Legacy as any)({ key: read("agent3-key.pem"), cert: identity.cert }); + const { promise, resolve } = Promise.withResolvers(); + legacy.on("error", () => resolve("error")); + legacy.listen(0, "127.0.0.1", () => resolve("listening")); + try { + expect(await promise).toBe("error"); + } finally { + legacy.close(); + } + }); + + test("applies tls.DEFAULT_MIN_VERSION", async () => { + const saved = tls.DEFAULT_MIN_VERSION; + tls.DEFAULT_MIN_VERSION = "TLSv1.3"; + try { + await using server = https.createServer(identity); + tls.DEFAULT_MIN_VERSION = saved; + const port = await listen(server); + expect(await handshake(port, { ...verified, maxVersion: "TLSv1.2" })).toContain("PROTOCOL_VERSION"); + expect(await handshake(port, verified)).toStartWith("TLS_"); + } finally { + tls.DEFAULT_MIN_VERSION = saved; + } + }); + + test("resumes a session across close() and a second listen()", async () => { + await using server = https.createServer(identity); + async function connect(port: number, session?: Buffer) { + const client = tls.connect({ port, host: "127.0.0.1", ...verified, maxVersion: "TLSv1.2", session }); + await once(client, "secureConnect"); + return client; + } + const first = await connect(await listen(server)); + const session = first.getSession()!; + first.destroy(); + server.close(); + await once(server, "close"); + + const second = await connect(await listen(server), session); + const reused = second.isSessionReused(); + second.destroy(); + expect(reused).toBe(true); + }); + + // The default CA list is process-wide and can't be restored. + test("verifies client certificates against tls.setDefaultCACertificates()", async () => { + const script = ` + const https = require("node:https"), tls = require("node:tls"), fs = require("node:fs"); + const read = name => fs.readFileSync(process.env.KEYS + "/" + name, "utf8"); + tls.setDefaultCACertificates([read("ca2-cert.pem")]); + const server = https.createServer( + { key: read("agent1-key.pem"), cert: read("agent1-cert.pem"), requestCert: true, rejectUnauthorized: false }, + (req, res) => res.end("authorized=" + req.socket.authorized), + ); + server.listen(0, "127.0.0.1", () => { + https.get({ + port: server.address().port, host: "127.0.0.1", servername: "agent1", ca: read("ca1-cert.pem"), agent: false, + key: read("agent4-key.pem"), cert: read("agent4-cert.pem"), + }, res => res.on("data", chunk => console.log(String(chunk))).on("end", () => server.close())); + }); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: { ...bunEnv, KEYS: join(import.meta.dirname, "../test/fixtures/keys") }, + 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: "authorized=true\n", stderr: "", exitCode: 0 }); + }); + }); +}); + +describe("https.Server has the methods of tls.Server", () => { + const read = (name: string) => readFileSync(join(import.meta.dirname, "../test/fixtures/keys", name), "utf8"); + // agent2 is self-signed. ca1 signs agent1, ca2 signs agent3. + const agent1 = { key: read("agent1-key.pem"), cert: read("agent1-cert.pem") }; + const agent2 = { key: read("agent2-key.pem"), cert: read("agent2-cert.pem") }; + const agent3 = { key: read("agent3-key.pem"), cert: read("agent3-cert.pem") }; + const ca = [read("ca1-cert.pem"), read("ca2-cert.pem"), agent2.cert]; + + // The chain is verified. The names in these tests aren't the ones in the certificates. + function connect(port: number, options?: tls.ConnectionOptions) { + return tls.connect({ port, host: "127.0.0.1", ca, checkServerIdentity: () => undefined, ...options }); + } + + // Resolves with the CN of the certificate the server presents, or the error code. + function servedCN(port: number, servername?: string, options?: tls.ConnectionOptions) { + const { promise, resolve } = Promise.withResolvers(); + const client = connect(port, { servername, ...options }); + client.on("secureConnect", () => { + resolve(client.getPeerCertificate().subject.CN); + client.destroy(); + }); + client.on("error", (err: NodeJS.ErrnoException) => resolve(String(err.code))); + return promise; + } + + test("http.Server doesn't have them", () => { + const server = http.createServer() as Partial; + expect([server.addContext, server.setSecureContext, server.getTicketKeys, server.setTicketKeys]).toEqual([ + undefined, + undefined, + undefined, + undefined, + ]); + }); + + test("addContext() selects a certificate by SNI, before and after listen()", async () => { + await using server = https.createServer(agent2, (req, res) => res.end((req.socket as tls.TLSSocket).servername)); + server.addContext("a.example.com", agent1); + const port = await listen(server); + server.addContext("*.b.example.com", tls.createSecureContext(agent3)); + + expect({ + a: await servedCN(port, "a.example.com"), + b: await servedCN(port, "x.b.example.com"), + other: await servedCN(port, "c.example.com"), + none: await servedCN(port), + }).toEqual({ a: "agent1", b: "agent3", other: "agent2", none: "agent2" }); + + const { promise, resolve, reject } = Promise.withResolvers(); + const options = { port, host: "127.0.0.1", servername: "a.example.com", ca, checkServerIdentity: () => undefined }; + https + .get({ ...options, agent: false }, res => { + let body = ""; + res.on("data", chunk => (body += chunk)).on("end", () => resolve(body)); + }) + .on("error", reject); + expect(await promise).toBe("a.example.com"); + + // The contexts belong to the server, not to one listener. + server.close(); + await once(server, "close"); + expect(await servedCN(await listen(server), "x.b.example.com")).toBe("agent3"); + }); + + test("addContext() with a hostname it already has replaces the context", async () => { + await using server = https.createServer(agent2); + server.addContext("a.example.com", agent1); + const port = await listen(server); + expect(await servedCN(port, "a.example.com")).toBe("agent1"); + server.addContext("a.example.com", agent3); + expect(await servedCN(port, "a.example.com")).toBe("agent3"); + }); + + test("addContext() throws without a hostname or with a bad certificate", async () => { + await using server = https.createServer(agent2); + const check = () => { + expect(() => server.addContext("", agent1)).toThrow( + expect.objectContaining({ code: "ERR_TLS_REQUIRED_SERVER_NAME" }), + ); + expect(() => server.addContext("a.example.com", { key: agent3.key, cert: agent1.cert })).toThrow( + expect.objectContaining({ code: "ERR_OSSL_X509_KEY_VALUES_MISMATCH" }), + ); + }; + check(); + const port = await listen(server); + check(); + expect(await servedCN(port, "a.example.com")).toBe("agent2"); + }); + + test("setSecureContext() applies to new handshakes and keeps open connections", async () => { + await using server = https.createServer(agent2, (_req, res) => res.end("ok")); + const port = await listen(server); + + const open = connect(port); + await once(open, "secureConnect"); + expect(open.getPeerCertificate().subject.CN).toBe("agent2"); + server.setSecureContext(agent1); + expect(await servedCN(port)).toBe("agent1"); + + let response = ""; + open.on("data", chunk => (response += chunk)); + open.end("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + await once(open, "close"); + expect(response).toEndWith("ok"); + + // The next listener serves the new context too. + server.close(); + await once(server, "close"); + expect(await servedCN(await listen(server))).toBe("agent1"); + }); + + test("setSecureContext() before listen() replaces the constructor's context", async () => { + await using server = https.createServer({ ...agent2, minVersion: "TLSv1.3" }); + const pfx = readFileSync(join(import.meta.dirname, "../test/fixtures/keys/agent1.pfx")); + server.setSecureContext({ pfx, passphrase: "sample" }); + const port = await listen(server); + // An option the call leaves out goes back to its default. + expect(await servedCN(port, undefined, { maxVersion: "TLSv1.2" })).toBe("agent1"); + }); + + test("setSecureContext() keeps the server's requestCert and rejectUnauthorized", async () => { + await using server = https.createServer({ ...agent2, ca: ca[0], requestCert: true }, (req, res) => + res.end(`authorized=${(req.socket as tls.TLSSocket).authorized}`), + ); + const port = await listen(server); + server.setSecureContext({ ...agent3, ca: ca[0] }); + + function request(options: tls.ConnectionOptions) { + const { promise, resolve } = Promise.withResolvers(); + let response = ""; + const client = connect(port, options); + client.on("secureConnect", () => client.write("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")); + client.on("data", chunk => (response += chunk)); + client.on("error", () => {}); + client.on("close", () => resolve(response.match(/authorized=\w+/)?.[0] ?? "refused")); + return promise; + } + expect(await request(agent1)).toBe("authorized=true"); + expect(await request({})).toBe("refused"); + }); + + test("setSecureContext() throws on bad options and leaves the context alone", async () => { + await using server = https.createServer(agent2); + const port = await listen(server); + expect(() => server.setSecureContext(undefined as any)).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + expect(() => server.setSecureContext({ ...agent1, minVersion: "TLSv9" as any })).toThrow( + expect.objectContaining({ code: "ERR_TLS_INVALID_PROTOCOL_VERSION" }), + ); + expect(() => server.setSecureContext({ key: agent3.key, cert: agent1.cert })).toThrow( + expect.objectContaining({ code: "ERR_OSSL_X509_KEY_VALUES_MISMATCH" }), + ); + expect(await servedCN(port)).toBe("agent2"); + }); + + describe("SNICallback", () => { + test.each<[string, (callback: () => void) => void]>([ + ["synchronously", callback => callback()], + ["in a microtask", queueMicrotask], + ["in a later turn of the event loop", setImmediate], + ])("serves the context it selects %s", async (_label, defer) => { + const calls: string[] = []; + await using server: https.Server = https.createServer( + { + ...agent2, + SNICallback(this: tls.TLSSocket & { server: unknown }, servername, callback) { + calls.push(`${servername} ${this instanceof net.Socket} ${this.server === server}`); + defer(() => { + if (servername === "a.example.com") callback(null, tls.createSecureContext(agent1)); + else if (servername === "raw.example.com") callback(null, tls.createSecureContext(agent3).context); + else callback(null, undefined); + }); + }, + }, + (req, res) => res.end((req.socket as tls.TLSSocket).servername), + ); + // Only the default SNICallback reads these. + server.addContext("b.example.com", agent3); + const port = await listen(server); + + expect({ + a: await servedCN(port, "a.example.com"), + raw: await servedCN(port, "raw.example.com"), + b: await servedCN(port, "b.example.com"), + none: await servedCN(port), + }).toEqual({ a: "agent1", raw: "agent3", b: "agent2", none: "agent2" }); + expect(calls).toEqual(["a.example.com true true", "raw.example.com true true", "b.example.com true true"]); + + const { promise, resolve, reject } = Promise.withResolvers(); + const options = { + port, + host: "127.0.0.1", + servername: "a.example.com", + ca, + checkServerIdentity: () => undefined, + }; + https + .get({ ...options, agent: false }, res => { + let body = ""; + res.on("data", chunk => (body += chunk)).on("end", () => resolve(body)); + }) + .on("error", reject); + expect(await promise).toBe("a.example.com"); + }); + + test("must be a function", () => { + expect(() => https.createServer({ ...agent2, SNICallback: 1 as any })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + }); + + test.each<[string, NonNullable, string]>([ + ["passes an error", (_name, callback) => callback(new Error("no context")), "no context"], + [ + "passes an error later", + (_name, callback) => void setImmediate(callback, new Error("no context")), + "no context", + ], + [ + "passes an object that isn't a SecureContext", + (_name, callback) => callback(null, {} as any), + "Invalid SNI context", + ], + ["throws", () => { throw new Error("thrown"); }, "thrown"], // prettier-ignore + ])("drops the connection and emits 'tlsClientError' when it %s", async (_label, SNICallback, message) => { + const events: string[] = []; + await using server = https.createServer({ ...agent2, SNICallback }, () => events.push("request")); + const reported = Promise.withResolvers(); + server.on("secureConnection", () => events.push("secureConnection")); + server.on("clientError", (err, socket) => { + events.push(`clientError ${err.message}`); + socket.destroy(); + }); + server.on("tlsClientError", (err, socket) => { + events.push(`tlsClientError ${err.message} ${socket instanceof net.Socket}`); + reported.resolve(); + }); + + expect(await servedCN(await listen(server), "a.example.com")).toBe("ECONNRESET"); + await reported.promise; + expect(events).toEqual([`clientError ${message}`, `tlsClientError ${message} true`]); + }); + }); + + test("servers with the same ticket keys resume each other's sessions", async () => { + await using first = https.createServer(agent2); + await using second = https.createServer(agent2); + await using third = https.createServer(agent2); + expect(first.getTicketKeys()).toHaveLength(48); + expect(first.getTicketKeys()).not.toEqual(second.getTicketKeys()); + expect(() => first.setTicketKeys(Buffer.alloc(47))).toThrow( + expect.objectContaining({ code: "ERR_INTERNAL_ASSERTION" }), + ); + const ports = [await listen(first), await listen(second), await listen(third)]; + second.setTicketKeys(first.getTicketKeys()); + + async function handshake(port: number, session?: Buffer) { + const client = connect(port, { maxVersion: "TLSv1.2", session }); + await once(client, "secureConnect"); + const result = { session: client.getSession()!, reused: client.isSessionReused() }; + client.destroy(); + return result; + } + const { session } = await handshake(ports[0]); + expect((await handshake(ports[1], session)).reused).toBe(true); + expect((await handshake(ports[2], session)).reused).toBe(false); + }); +}); + +describe("req.socket of an https.Server", () => { + const read = (name: string) => readFileSync(join(import.meta.dirname, "../test/fixtures/keys", name), "utf8"); + // ca1 signs agent1, ca2 signs agent3. + const options = { + key: read("agent1-key.pem"), + cert: read("agent1-cert.pem"), + ca: read("ca2-cert.pem"), + requestCert: true, + }; + const client = { + host: "127.0.0.1", + servername: "agent1", + ca: read("ca1-cert.pem"), + key: read("agent3-key.pem"), + cert: read("agent3-cert.pem"), + agent: false, + }; + + function get(port: number, options?: https.RequestOptions) { + const { promise, resolve, reject } = Promise.withResolvers(); + https + .get({ ...client, port, ...options }, res => { + let body = ""; + res.on("data", chunk => (body += chunk)).on("end", () => resolve(JSON.parse(body))); + }) + .on("error", reject); + return promise; + } + + test("is a tls.TLSSocket that reports the client certificate and the session", async () => { + await using server = https.createServer(options, (req, res) => { + const socket = req.socket as tls.TLSSocket; + const peer = socket.getPeerCertificate(true); + res.end( + JSON.stringify({ + constructor: socket.constructor.name, + isTLSSocket: socket instanceof tls.TLSSocket, + isNetSocket: socket instanceof net.Socket, + authorized: socket.authorized, + peer: socket.getPeerCertificate().subject.CN, + issuer: peer.issuerCertificate.subject.CN, + peerX509: socket.getPeerX509Certificate()!.subject.includes("CN=agent3"), + own: (socket.getCertificate() as tls.PeerCertificate).subject.CN, + ownX509: socket.getX509Certificate()!.subject.includes("CN=agent1"), + cipher: socket.getCipher().name, + protocol: socket.getProtocol(), + reused: socket.isSessionReused(), + ephemeralKeyInfo: socket.getEphemeralKeyInfo(), + finished: socket.getFinished()!.length, + peerFinished: socket.getPeerFinished()!.length, + keyingMaterial: socket.exportKeyingMaterial(16, "label").length, + }), + ); + }); + expect(await get(await listen(server), { maxVersion: "TLSv1.2", ciphers: "ECDHE-RSA-AES128-GCM-SHA256" })).toEqual({ + constructor: "TLSSocket", + isTLSSocket: true, + isNetSocket: true, + authorized: true, + peer: "agent3", + issuer: "ca2", + peerX509: true, + own: "agent1", + ownX509: true, + cipher: "ECDHE-RSA-AES128-GCM-SHA256", + protocol: "TLSv1.2", + reused: false, + ephemeralKeyInfo: null, + finished: 12, + peerFinished: 12, + keyingMaterial: 16, + }); + }); + + test("returns an empty certificate without a client certificate, and null once closed", async () => { + const closed = Promise.withResolvers(); + await using server = https.createServer({ ...options, rejectUnauthorized: false }, (req, res) => { + const socket = req.socket as tls.TLSSocket; + socket.on("close", () => closed.resolve(socket)); + res.end(JSON.stringify({ authorized: socket.authorized, peer: socket.getPeerCertificate() })); + }); + const port = await listen(server); + expect(await get(port, { key: undefined, cert: undefined })).toEqual({ authorized: false, peer: {} }); + const socket = await closed.promise; + expect([socket.getPeerCertificate(), socket.getCertificate(), socket.getProtocol()]).toEqual([null, null, null]); + }); + + test("is still a plain net.Socket on an http.Server", async () => { + await using server = http.createServer((req, res) => res.end(String(req.socket instanceof tls.TLSSocket))); + const response = await fetch(`http://127.0.0.1:${await listen(server)}/`); + expect(await response.text()).toBe("false"); + }); +}); + +describe("https.Server wraps a socket passed to emit('connection') in TLS", () => { + const read = (name: string) => readFileSync(join(import.meta.dirname, "../test/fixtures/keys", name), "utf8"); + const identity = { key: read("agent1-key.pem"), cert: read("agent1-cert.pem") }; + const verified = { ca: read("ca1-cert.pem"), servername: "agent1" }; + + function describeSocket(this: http.Server, req: http.IncomingMessage, res: http.ServerResponse) { + const socket = req.socket as tls.TLSSocket & { server: unknown }; + res.writeHead(200, { Connection: "close" }); + res.end(`${socket instanceof tls.TLSSocket} ${socket.encrypted} ${socket.alpnProtocol} ${socket.server === this}`); + } + + test.each<[string, https.ServerOptions]>([ + ["the default ALPNProtocols", {}], + ["ALPNProtocols", { ALPNProtocols: ["x", "http/1.1"] }], + ["ALPNCallback", { ALPNCallback: ({ protocols }) => protocols.at(-1) }], + ])("a net.Socket, with %s", async (_label, options) => { + const server = https.createServer({ ...identity, ...options }, describeSocket); + const secureConnection = once(server, "secureConnection"); + await using front = net.createServer(socket => void server.emit("connection", socket)); + const port = await listen(front as any); + + const { promise, resolve, reject } = Promise.withResolvers(); + https + .get({ port, host: "127.0.0.1", ...verified, ALPNProtocols: ["http/1.1"], agent: false }, res => { + let body = ""; + res.on("data", chunk => (body += chunk)).on("end", () => resolve(body)); + }) + .on("error", reject); + expect(await promise).toBe("true true http/1.1 true"); + expect((await secureConnection)[0]).toBeInstanceOf(tls.TLSSocket); + }); + + test("the socket of another server's 'connect' event", async () => { + const server = https.createServer(identity, describeSocket); + const front = http.createServer(); + const frontClosed = Promise.withResolvers(); + front.on("connect", (_req, socket) => { + socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + server.emit("connection", socket); + // The tunnel still counts as a connection of the server that accepted it. + front.close(() => frontClosed.resolve()); + }); + const port = await listen(front); + + const tunnel = http.request({ port, host: "127.0.0.1", method: "CONNECT", path: "agent1:443" }).end(); + const [, socket] = await once(tunnel, "connect"); + const client = tls.connect({ socket, ...verified }); + let response = ""; + client.on("data", chunk => (response += chunk)); + client.write("GET / HTTP/1.1\r\nHost: agent1\r\n\r\n"); + await once(client, "close"); + expect(response).toContain("true true false true"); + await frontClosed.promise; + }); + + test.each([ + ["calls 'clientError' listeners", true], + ["destroys the socket without a 'clientError' listener", false], + ])("%s when the handshake times out", async (_label, handled) => { + const server = https.createServer({ ...identity, handshakeTimeout: 1 }); + const events: string[] = []; + server.on("tlsClientError", (err: NodeJS.ErrnoException) => events.push(`tlsClientError ${err.code}`)); + if (handled) { + server.on("clientError", (err: NodeJS.ErrnoException, socket) => { + events.push(`clientError ${err.code} destroyed=${socket.destroyed}`); + socket.destroy(); + }); + } + await using front = net.createServer(socket => void server.emit("connection", socket)); + const client = net.connect(await listen(front as any), "127.0.0.1"); + client.on("error", () => {}); + await once(client, "close"); + expect(events).toEqual([ + ...(handled ? ["clientError ERR_TLS_HANDSHAKE_TIMEOUT destroyed=false"] : []), + "tlsClientError ERR_TLS_HANDSHAKE_TIMEOUT", + ]); + }); }); describe("server.blockList", () => { @@ -327,7 +926,8 @@ describe("server.blockList", () => { expect(server.blockList).toBe(blockList); const port = await listen(server); - expect(await get(https, { port })).toBe("ECONNRESET"); + // The server closes the peer at accept, before the handshake. + expect(await tlsHandshake(port)).toBe("ECONNRESET"); expect(events).toEqual([]); server.blockList = blockListOf("10.0.0.1"); @@ -397,9 +997,13 @@ describe("server.blockList", () => { const server = mod.createServer(options, (req, res) => { requests++; res.end("served"); }); server.blockList = { check() { throw new Error(name + " check failed"); } }; await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const target = { host: "127.0.0.1", port: server.address().port, agent: false, rejectUnauthorized: false }; + // An https server closes the peer at accept, before the handshake. const outcome = await new Promise(resolve => - mod.get({ host: "127.0.0.1", port: server.address().port, agent: false, rejectUnauthorized: false }, res => resolve(res.statusCode)) - .on("error", err => resolve(err.code))); + (name === "node:https" + ? require("node:tls").connect(target, () => resolve("secureConnect")) + : mod.get(target, res => resolve(res.statusCode)) + ).on("error", err => resolve(err.code))); server.close(); return { outcome, requests }; } @@ -425,6 +1029,258 @@ describe("server.blockList", () => { }); }); +// https://nodejs.org/api/tls.html#event-connection +describe.concurrent("an https connection before its TLS handshake", () => { + type Socket = net.Socket & { _secureEstablished?: boolean; parser?: object | null }; + + function listenWithTls(server: http.Server): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); + server.once("error", reject); + server.listen({ port: 0, host: "127.0.0.1", tls: validCert }, () => + resolve((server.address() as AddressInfo).port), + ); + return promise; + } + + type Create = (listener?: http.RequestListener) => http.Server; + const entryPoints: [string, Create, (server: http.Server) => Promise][] = [ + ["https.createServer()", listener => https.createServer(validCert, listener), listen], + ]; + // Node's http.Server ignores key and cert, and its listen() takes no tls option. + if (process.versions.bun) { + entryPoints.push( + ["http.createServer({ key, cert })", listener => http.createServer({ ...validCert } as {}, listener), listen], + ["http.createServer().listen({ tls })", listener => http.createServer(listener), listenWithTls], + ); + } + + function connections(server: http.Server) { + const { promise, resolve, reject } = Promise.withResolvers(); + server.getConnections((err, count) => (err ? reject(err) : resolve(count))); + return promise; + } + + // Sends the first byte of a TLS record and stalls. With a byte to read, TCP_DEFER_ACCEPT doesn't delay the accept. + async function stalledClient(port: number) { + const socket = net.connect(port, "127.0.0.1"); + socket.on("error", () => {}); + socket.resume(); + await once(socket, "connect"); + socket.write(Buffer.from([0x16])); + return socket; + } + + // events.once() would reject if the peer's reset arrives before 'close'. + function closed(socket: net.Socket) { + const { promise, resolve } = Promise.withResolvers(); + socket.on("close", () => resolve()); + return promise; + } + + function get(port: number) { + const { promise, resolve, reject } = Promise.withResolvers(); + https + .get({ host: "127.0.0.1", port, agent: false, ca: validCert.cert }, res => { + let body = ""; + res.on("data", chunk => (body += chunk)).on("end", () => resolve(body)); + }) + .on("error", reject); + return promise; + } + + test.each(entryPoints)( + "%s emits 'connection' at accept and 'secureConnection' after the handshake", + async (_label, create, listenOn) => { + const events: string[] = []; + const counts: number[] = []; + const server = create((req, res) => { + events.push(`request encrypted=${(req.socket as tls.TLSSocket).encrypted}`); + server.getConnections((_err, count) => { + counts.push(count); + res.end("ok"); + }); + }); + server.timeout = 60_000; + const state = (socket: Socket) => + `secure=${!!socket._secureEstablished} parser=${socket.parser != null} timeout=${socket.timeout || 0}`; + server.on("connection", (socket: Socket) => events.push(`connection ${state(socket)}`)); + server.on("secureConnection", (socket: Socket) => events.push(`secureConnection ${state(socket)}`)); + const port = await listenOn(server); + const stalled = await stalledClient(port); + try { + // The listener accepts in order, so the stalled client is a connection before this request is served. + expect(await get(port)).toBe("ok"); + expect(events).toEqual([ + "connection secure=false parser=false timeout=0", + "connection secure=false parser=false timeout=0", + "secureConnection secure=true parser=true timeout=60000", + "request encrypted=true", + ]); + expect(counts).toEqual([2]); + } finally { + stalled.destroy(); + server.close(); + } + }, + ); + + test("emits no second 'connection' when the handshake fails", async () => { + const events: string[] = []; + const server = https.createServer(validCert, () => expect.unreachable()); + for (const event of ["connection", "secureConnection"]) server.on(event, () => events.push(event)); + const accepted = once(server, "connection"); + const client = await stalledClient(await listen(server)); + try { + const [socket] = (await accepted) as [Socket]; + const socketClosed = once(socket, "close"); + client.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); + await Promise.all([socketClosed, closed(client)]); + expect(events).toEqual(["connection"]); + expect(await connections(server)).toBe(0); + } finally { + client.destroy(); + server.close(); + } + }); + + test("a 'connection' listener can destroy the socket", async () => { + const events: string[] = []; + const server = https.createServer(validCert, () => expect.unreachable()); + server.on("connection", socket => { + events.push("connection"); + socket.destroy(); + }); + server.on("secureConnection", () => events.push("secureConnection")); + try { + expect(await tlsHandshake(await listen(server))).toBe("ECONNRESET"); + expect(events).toEqual(["connection"]); + } finally { + server.close(); + } + }); + + // server-destroy and http-shutdown destroy the sockets they collected from 'connection'. + test("close() finishes once the sockets from 'connection' are destroyed", async () => { + const sockets = new Set(); + const server = https.createServer(validCert, () => expect.unreachable()); + server.on("connection", socket => sockets.add(socket)); + const accepted = once(server, "connection"); + const stalled = await stalledClient(await listen(server)); + try { + await accepted; + const serverClosed = Promise.withResolvers(); + server.close(serverClosed.resolve); + for (const socket of sockets) socket.destroy(); + expect(await serverClosed.promise).toBeUndefined(); + } finally { + stalled.destroy(); + } + }); + + test("counts toward server.maxConnections, which drops the next peer before its handshake", async () => { + const events: string[] = []; + const server = https.createServer(validCert, () => expect.unreachable()); + server.maxConnections = 1; + for (const event of ["connection", "secureConnection", "drop"]) server.on(event, () => events.push(event)); + const port = await listen(server); + const stalled = await stalledClient(port); + try { + expect(await tlsHandshake(port)).toBe("ECONNRESET"); + expect(events).toEqual(["connection", "drop"]); + expect(await connections(server)).toBe(1); + } finally { + stalled.destroy(); + server.close(); + } + }); + + test.each([ + ["close()", (server: http.Server) => void server.close()], + ["closeIdleConnections()", (server: http.Server) => server.closeIdleConnections()], + ])("%s in a 'connection' listener doesn't close it", async (_label, sweep) => { + const events: string[] = []; + const server = https.createServer(validCert, (_req, res) => { + events.push("request"); + res.end("ok"); + }); + server.on("connection", () => { + events.push("connection"); + sweep(server); + }); + server.on("secureConnection", () => events.push("secureConnection")); + try { + expect(await get(await listen(server))).toBe("ok"); + expect(events).toEqual(["connection", "secureConnection", "request"]); + } finally { + server.closeAllConnections(); + server.close(); + } + }); + + test("closeAllConnections() and the headersTimeout sweep skip it", async () => { + const server = https.createServer({ ...validCert, headersTimeout: 50, connectionsCheckingInterval: 10 }, () => + expect.unreachable(), + ); + const accepted = once(server, "connection"); + const port = await listen(server); + const stalled = await stalledClient(port); + const [stalledSocket] = (await accepted) as [Socket]; + // Finishes its handshake and sends no request, so the sweep answers 408. The stalled connection is older. + const idle = tls.connect({ port, host: "127.0.0.1", ca: validCert.cert }); + idle.on("error", () => {}); + let response = ""; + idle.on("data", chunk => (response += chunk)); + try { + await closed(idle); + expect(response).toStartWith("HTTP/1.1 408 "); + server.closeAllConnections(); + expect(stalledSocket.destroyed).toBe(false); + expect(await connections(server)).toBe(1); + } finally { + stalled.destroy(); + idle.destroy(); + server.close(); + } + }); + + test("a socket timeout neither reaches the server's 'timeout' event nor destroys the socket", async () => { + const events: string[] = []; + const server = https.createServer(validCert, () => expect.unreachable()); + server.on("timeout", () => events.push("server timeout")); + const timedOut = Promise.withResolvers(); + server.on("connection", socket => { + socket.setTimeout(1); + socket.on("timeout", () => timedOut.resolve(socket)); + }); + const stalled = await stalledClient(await listen(server)); + try { + const socket = await timedOut.promise; + expect({ events, destroyed: socket.destroyed }).toEqual({ events: [], destroyed: false }); + } finally { + stalled.destroy(); + server.close(); + } + }); + + test("server.timeout replaces a longer timeout from a 'connection' listener after the handshake", async () => { + const server = https.createServer(validCert, () => {}); + server.timeout = 50; + server.on("connection", socket => socket.setTimeout(60_000)); + const timedOut = once(server, "timeout"); + const client = tls.connect({ port: await listen(server), host: "127.0.0.1", ca: validCert.cert }); + client.on("error", () => {}); + try { + client.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); + const [socket] = (await timedOut) as [Socket]; + expect(socket.timeout).toBe(50); + socket.destroy(); + } finally { + client.destroy(); + server.close(); + } + }); +}); + // A close waits, up to 10 s, for ciphertext that write() reported and the kernel has not taken. closeAllConnections() does not. test.each([ ["in the middle of its request", 0], diff --git a/test/js/node/net/handle-leak.test.ts b/test/js/node/net/handle-leak.test.ts index c7b45512a893..10f7cb73cd30 100644 --- a/test/js/node/net/handle-leak.test.ts +++ b/test/js/node/net/handle-leak.test.ts @@ -1,18 +1,20 @@ import { expect } from "bun:test"; import { isASAN, isWindows, rss } from "harness"; +import { rmSync } from "node:fs"; import * as net from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { setTimeout } from "node:timers/promises"; -const listen_path = join(tmpdir(), "test-net-successful-connection-handle-leak.sock"); +// Use a per-process path so a stale socket from a killed run can't fail the listen with EADDRINUSE. +const listen_path = join(tmpdir(), `test-net-successful-connection-handle-leak-${process.pid}.sock`); -const { promise, resolve } = Promise.withResolvers(); +const { promise: listening, resolve, reject } = Promise.withResolvers(); const server = net .createServer() .listen(listen_path) - .on("listening", () => resolve()); -await promise; + .on("listening", () => resolve()) + .on("error", reject); +await listening; const address = server.address(); console.log("server address", address); @@ -22,68 +24,46 @@ console.log("server address", address); const warmup_total = isASAN ? 10_000 : 50_000; const measured_total = isASAN ? 20_000 : 100_000; -let started; +async function run(total: number) { + let started = 0; + while (started < total) { + const promises: Promise[] = []; + for (let i = 0; i < 100; i++) { + const { promise, resolve, reject } = Promise.withResolvers(); + const socket = net + .connect({ path: listen_path }) + .on("connect", () => { + socket.on("close", () => resolve()); + socket.end(); + }) + .on("error", reject); -started = 0; -while (started < warmup_total) { - const promises: Promise[] = []; - for (let i = 0; i < 100; i++) { - const { promise, resolve, reject } = Promise.withResolvers(); - const socket = net - .connect({ path: listen_path }) - .on("connect", () => { - socket.on("close", () => resolve()); - socket.end(); - }) - .on("error", e => { - reject(e); - }); - - promises.push(promise); - started++; - } - await Promise.all(promises); - await setTimeout(1); - if (started % 10_000 === 0) { - console.log(`Completed ${started} connections. RSS: ${(rss() / 1024 / 1024) | 0} MB`); + promises.push(promise); + started++; + } + await Promise.all(promises); + if (started % 10_000 === 0) { + console.log(`Completed ${started} connections. RSS: ${(rss() / 1024 / 1024) | 0} MB`); + } } } -Bun.gc(true); -const warmup_rss = rss(); +let warmup_rss: number, post_rss: number; +try { + await run(warmup_total); + Bun.gc(true); + warmup_rss = rss(); -started = 0; -while (started < measured_total) { - const promises: Promise[] = []; - for (let i = 0; i < 100; i++) { - const { promise, resolve, reject } = Promise.withResolvers(); - const socket = net - .connect({ path: listen_path }) - .on("connect", () => { - socket.on("close", () => resolve()); - socket.end(); - }) - .on("error", e => { - reject(e); - }); - - promises.push(promise); - started++; - } - await Promise.all(promises); - await setTimeout(1); - if (started % 10_000 === 0) { - console.log(`Completed ${started} connections. RSS: ${(rss() / 1024 / 1024) | 0} MB`); - } + await run(measured_total); + // Mirror the warmup sample: collect before measuring so the assertion compares + // like-for-like and isn't sensitive to garbage still in flight from the last batch. + Bun.gc(true); + post_rss = rss(); +} finally { + server.close(); + rmSync(listen_path, { force: true }); } -// Mirror the warmup sample: collect before measuring so the assertion compares -// like-for-like and isn't sensitive to garbage still in flight from the last batch. -Bun.gc(true); -const post_rss = rss(); - -server.close(); - // Per-Socket fields added for onread/tls bookkeeping raise steady-state RSS a // few MB across ~30k iterations; a real per-connection leak would blow past 24. let margin = 1024 * 1024 * 24; diff --git a/test/js/node/net/listener-throw-connect-fixture.js b/test/js/node/net/listener-throw-connect-fixture.js new file mode 100644 index 000000000000..d1810a53636c --- /dev/null +++ b/test/js/node/net/listener-throw-connect-fixture.js @@ -0,0 +1,37 @@ +// A client 'connect' or 'ready' listener (THROW_FROM) throws. The throw must reach +// uncaughtException, not the socket's 'error' event, and the socket stays connected. +// node, THROW_FROM=connect: ["connect","uncaught:connect-boom","server-data:A","close:false"] +// node, THROW_FROM=ready: ["connect","ready","uncaught:ready-boom","server-data:A","close:false"] +const net = require("node:net"); + +const throwFrom = process.env.THROW_FROM; +const ev = []; +let c; +process.on("uncaughtException", e => { + ev.push("uncaught:" + e.message); + c.write("A"); +}); + +const srv = net.createServer(s => { + s.on("error", () => {}); + s.on("data", d => { + ev.push("server-data:" + d); + c.destroy(); + }); +}); + +srv.listen(0, "127.0.0.1", () => { + c = net.connect(srv.address().port, "127.0.0.1"); + for (const name of ["connect", "ready"]) { + c.on(name, () => { + ev.push(name); + if (name === throwFrom) throw new Error(name + "-boom"); + }); + } + c.on("error", e => ev.push("socket-error:" + e.message)); + c.on("close", had => { + ev.push("close:" + had); + console.log(JSON.stringify(ev)); + process.exit(0); + }); +}); diff --git a/test/js/node/net/net-syscall-fault.test.ts b/test/js/node/net/net-syscall-fault.test.ts index b9166e329b79..cfd5197905d6 100644 --- a/test/js/node/net/net-syscall-fault.test.ts +++ b/test/js/node/net/net-syscall-fault.test.ts @@ -1,8 +1,10 @@ import { socketFaultInjection as fault } from "bun:internal-for-testing"; +import { estimateShallowMemoryUsageOf } from "bun:jsc"; import { afterEach, describe, expect, test } from "bun:test"; -import { isWindows } from "harness"; +import { bunEnv, bunExe, tls as certs, isWindows, tempDir } from "harness"; import { once } from "node:events"; import net from "node:net"; +import tls from "node:tls"; // Windows uses the libuv eventing backend; bsd_recv/bsd_send are still the // chokepoints there but errno semantics differ. Land POSIX coverage first. @@ -165,6 +167,85 @@ describe.skipIf(skip)("node:net under injected syscall faults", () => { ); } + // Calls `write` from the 'connection' (server) or 'connect' (client) listener. Resolves once both sockets closed. + async function writeInOpenListener( + side: "client" | "server", + write: (s: net.Socket, fd: number, written: (err?: Error | null) => void) => void, + ) { + let writeError: NodeJS.ErrnoException | null | undefined; + let socketError: NodeJS.ErrnoException | undefined; + let received = Buffer.alloc(0); + const sockets: net.Socket[] = []; + const closed: Promise[] = []; + function track(s: net.Socket) { + sockets.push(s); + // Not events.once(), which rejects on 'error'. + closed.push(new Promise(resolve => s.once("close", resolve))); + } + function writer(s: net.Socket) { + s.on("error", err => (socketError = err)); + write(s, (s as any)._handle.fd, err => (writeError = err ?? null)); + } + function reader(s: net.Socket) { + s.on("error", () => {}); + s.on("data", c => (received = Buffer.concat([received, c]))); + } + + const server = net.createServer(s => { + track(s); + if (side === "server") writer(s); + else reader(s); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const client = net.connect({ port: (server.address() as net.AddressInfo).port, host: "127.0.0.1" }); + track(client); + if (side === "client") client.once("connect", () => writer(client)); + else reader(client); + + try { + await Promise.all([once(client, "connect"), once(server, "connection")]); + await Promise.all(closed); + } finally { + for (const s of sockets) s.destroy(); + server.close(); + } + + const shape = (err: NodeJS.ErrnoException | null | undefined) => err && { code: err.code, syscall: err.syscall }; + return { writeError: shape(writeError), socketError: shape(socketError), received: received.length }; + } + + describe.each(["client", "server"] as const)("write in the %s's open listener", side => { + test("send → EPIPE on the flush after the listener fails the write", async () => { + const result = await writeInOpenListener(side, (s, fd, written) => { + // The first send is short, so 199 bytes stay buffered until the listener returns. + fault.set({ syscall: "send", action: "short", bytes: 1, repeat: 1, fd }); + s.write(Buffer.alloc(200, "h"), written); + fault.set({ syscall: "send", action: "errno", errno: "EPIPE", repeat: 1, fd }); + s.write(Buffer.alloc(4096, "b")); + s.end(); + }); + expect(result).toEqual({ + writeError: { code: "EPIPE", syscall: "write" }, + socketError: { code: "EPIPE", syscall: "write" }, + received: 1, + }); + }); + + test("send → ECONNRESET doesn't send the failed write again", async () => { + const result = await writeInOpenListener(side, (s, fd, written) => { + // Fails once: a second send of the same bytes would succeed. + fault.set({ syscall: "send", action: "errno", errno: "ECONNRESET", repeat: 1, fd }); + s.write(Buffer.alloc(200, "h"), written); + }); + expect(result).toEqual({ + writeError: { code: "ECONNRESET", syscall: "write" }, + socketError: { code: "ECONNRESET", syscall: "write" }, + received: 0, + }); + }); + }); + // us_socket_write_check_error gives send() errnos that are neither // would-block/transient nor known peer-gone (EPROTOTYPE: macOS returns it // racily from send() on healthy sockets) a bounded retry through the @@ -275,6 +356,61 @@ describe.skipIf(skip)("node:net under injected syscall faults", () => { ); } + // A cluster worker adopts each accepted connection with socket.connect({ fd }). + test("send → fatal flush of a buffered write emits 'error' once in a cluster worker", async () => { + using dir = tempDir("net-fault-cluster", { + "fixture.js": ` + const cluster = require("node:cluster"); + const net = require("node:net"); + + if (cluster.isPrimary) { + const worker = cluster.fork(); + worker.on("message", msg => { + if (msg.port) { + const peer = net.connect(msg.port, "127.0.0.1"); + peer.on("error", () => {}); + peer.resume(); + } else { + console.log(JSON.stringify(msg.events)); + worker.kill(); + process.exit(0); + } + }); + } else { + const { socketFaultInjection: fault } = require("bun:internal-for-testing"); + const events = []; + const server = net.createServer(c => { + const fd = c._handle.fd; + c.on("error", e => events.push("error:" + e.code)); + c.on("close", hadError => { + events.push("close:" + hadError); + setImmediate(() => process.send({ events })); + }); + // The first send is short, so 199 bytes stay buffered and the write callback waits. + fault.set({ syscall: "send", action: "short", bytes: 1, repeat: 1, fd }); + c.write(Buffer.alloc(200, "h"), err => events.push("write:" + (err ? err.code : "ok"))); + // The second send is the flush on the writable event. + fault.set({ syscall: "send", action: "errno", errno: "EPIPE", repeat: 1, fd }); + }); + server.listen(0, "127.0.0.1", () => process.send({ port: server.address().port })); + } + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.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: JSON.stringify(["write:EPIPE", "error:EPIPE", "close:true"]) + "\n", + stderr: "", + exitCode: 0, + }); + }); + test("connect → ECONNREFUSED is reported on connecting socket", async () => { const server = net.createServer(); server.listen(0, "127.0.0.1"); @@ -291,6 +427,13 @@ describe.skipIf(skip)("node:net under injected syscall faults", () => { } }); + test.each(["ENETUNREACH", "EHOSTUNREACH"])("connect → %s is not reported as ECONNREFUSED", async errno => { + fault.set({ syscall: "connect", action: "errno", errno, repeat: 1 }); + const client = net.connect({ port: 1, host: "127.0.0.1" }); + const [err] = (await once(client, "error")) as [NodeJS.ErrnoException]; + expect({ code: err.code, syscall: err.syscall }).toEqual({ code: errno, syscall: "connect" }); + }); + test("fd targeting: rule on the server fd does not affect the client", async () => { using p = await connectedPair(); // The server socket's recv should error; the client should still receive @@ -462,3 +605,238 @@ describe.skipIf(skip)("node:net seeded syscall fuzz", () => { } }); }); + +// A short send leaves the rest of the chunk in the native queue, and each writable event sends more of it. +describe.skipIf(skip)("node:net pending-write queue under short sends", () => { + // Every byte depends on its position and the seed, so bytes sent from the wrong offset or out of order don't compare equal. + function patterned(size: number, seed = 0x9e3779b9) { + const block = Buffer.allocUnsafe(65521); + let x = seed; + for (let i = 0; i < block.length; i++) { + x ^= x << 13; + x ^= x >>> 17; + x ^= x << 5; + block[i] = x; + } + return Buffer.alloc(size, block); + } + + function receive(reader: net.Socket, expected: Buffer) { + const state = { received: 0, intact: true }; + reader.on("data", chunk => { + state.intact &&= chunk.equals(expected.subarray(state.received, state.received + chunk.length)); + state.received += chunk.length; + }); + return state; + } + + // The distinct values of `socket.bytesWritten` on each turn of the loop until `written` settles. + async function bytesWrittenUntil(written: Promise, socket: net.Socket) { + const samples = new Set(); + let pending = true; + const settled = written.finally(() => (pending = false)); + while (pending) { + samples.add(socket.bytesWritten); + await new Promise(resolve => setImmediate(resolve)); + } + await settled; + return [...samples]; + } + + test.each([ + // 64 sends of 16,384 bytes, so the last one empties the queue exactly. + ["client", 16384], + ["server", 16384], + ["client", 10000], + ] as const)( + "a write drained by writable events arrives intact (%s writer, %d bytes per send)", + async (side, bytes) => { + using p = await connectedPair(); + const [writer, reader] = side === "client" ? [p.client, p.serverSock] : [p.serverSock, p.client]; + const handle = (writer as any)._handle; + const payload = patterned(1024 * 1024); + const state = receive(reader, payload); + const idle = estimateShallowMemoryUsageOf(handle); + + fault.set({ syscall: "send", action: "short", bytes, repeat: -1, fd: handle.fd }); + const { promise: written, resolve, reject } = Promise.withResolvers(); + writer.write(payload, err => (err ? reject(err) : resolve())); + const queued = estimateShallowMemoryUsageOf(handle) - idle; + const bytesWritten = await bytesWrittenUntil(written, writer); + const heldAfter = estimateShallowMemoryUsageOf(handle) - idle; + writer.end(); + await once(reader, "end"); + + expect({ queued, bytesWritten, heldAfter, ...state }).toEqual({ + queued: payload.length - bytes, + bytesWritten: [payload.length], + heldAfter: 0, + received: payload.length, + intact: true, + }); + }, + ); + + // Socket.prototype._write reaches the native write while a tail is queued. One writev then carries the tail and the chunk. + test.each([ + ["stops inside the tail", 1024], + ["takes exactly the tail", 16384 - 1024], + ["takes the tail and part of the chunk", 32768], + ])("a write behind a queued tail arrives in order when the writev %s", async (_name, writev) => { + using p = await connectedPair(); + const handle = (p.client as any)._handle; + const first = patterned(16384); + const second = patterned(65536, 0x85ebca6b); + const expected = Buffer.concat([first, second]); + const state = receive(p.serverSock, expected); + + fault.set({ syscall: "send", action: "short", bytes: 1024, repeat: -1, fd: handle.fd }); + fault.set({ syscall: "writev", action: "short", bytes: writev, repeat: -1, fd: handle.fd }); + (p.client as any)._write(first, "buffer", () => {}); + const { promise: written, resolve, reject } = Promise.withResolvers(); + (p.client as any)._write(second, "buffer", (err?: Error | null) => (err ? reject(err) : resolve())); + const bytesWritten = await bytesWrittenUntil(written, p.client); + p.client.end(); + await once(p.serverSock, "end"); + + expect({ bytesWritten, ...state }).toEqual({ + bytesWritten: [expected.length], + received: expected.length, + intact: true, + }); + }); + + test("a write made from the 'drain' listener queues behind nothing and arrives in order", async () => { + using p = await connectedPair(); + const handle = (p.client as any)._handle; + const first = patterned(512 * 1024); + const second = patterned(512 * 1024, 0x85ebca6b); + const expected = Buffer.concat([first, second]); + const state = receive(p.serverSock, expected); + const callbacks: string[] = []; + + fault.set({ syscall: "send", action: "short", bytes: 16384, repeat: -1, fd: handle.fd }); + const { promise: written, resolve, reject } = Promise.withResolvers(); + p.client.once("drain", () => { + callbacks.push(`drain at ${p.client.bytesWritten}`); + p.client.write(second, err => { + callbacks.push("second"); + err ? reject(err) : resolve(); + }); + }); + const flushed = p.client.write(first, err => { + callbacks.push("first"); + if (err) reject(err); + }); + await written; + const bytesWritten = p.client.bytesWritten; + p.client.end(); + await once(p.serverSock, "end"); + + expect({ flushed, callbacks, bytesWritten, ...state }).toEqual({ + flushed: false, + // Like Node, 'drain' comes before the callback of the write that drained. + callbacks: [`drain at ${first.length}`, "first", "second"], + bytesWritten: expected.length, + received: expected.length, + intact: true, + }); + }); + + test("a reset by the peer halfway through the queue fails the write once, frees the queue and keeps bytesWritten", async () => { + using p = await connectedPair(); + const handle = (p.client as any)._handle; + const payload = patterned(1024 * 1024); + const state = receive(p.serverSock, payload); + const idle = estimateShallowMemoryUsageOf(handle); + const callbacks: (string | undefined)[] = []; + const closed = Promise.withResolvers(); + p.client.on("error", () => {}); + p.client.on("close", () => closed.resolve()); + + fault.set({ syscall: "send", action: "short", bytes: 16384, repeat: -1, fd: handle.fd }); + p.client.write(payload, err => callbacks.push((err as NodeJS.ErrnoException | undefined)?.code)); + while (state.received < 128 * 1024) await once(p.serverSock, "data"); + p.serverSock.resetAndDestroy(); + await closed.promise; + + expect(callbacks).toHaveLength(1); + // ECANCELED if the loop reads the reset first, the send's errno if the writable event comes first. + expect(["ECANCELED", "ECONNRESET", "EPIPE"]).toContain(callbacks[0]); + expect(estimateShallowMemoryUsageOf(handle) - idle).toBeLessThan(1024); + expect({ bytesWritten: p.client.bytesWritten, intact: state.intact }).toEqual({ + bytesWritten: payload.length, + intact: true, + }); + }); + + test("a send that fails halfway through the queue fails the write once, frees the queue and keeps bytesWritten", async () => { + using p = await connectedPair(); + const handle = (p.client as any)._handle; + const payload = patterned(1024 * 1024); + const state = receive(p.serverSock, payload); + const idle = estimateShallowMemoryUsageOf(handle); + const callbacks: (string | undefined)[] = []; + const closed = Promise.withResolvers(); + p.client.on("error", () => {}); + p.client.on("close", () => closed.resolve()); + + fault.set({ syscall: "send", action: "short", bytes: 16384, repeat: -1, fd: handle.fd }); + p.client.write(payload, err => callbacks.push((err as NodeJS.ErrnoException | undefined)?.code)); + while (state.received < 128 * 1024) await once(p.serverSock, "data"); + fault.clear(); + fault.set({ syscall: "send", action: "errno", errno: "EPIPE", repeat: -1, fd: handle.fd }); + await closed.promise; + + expect(estimateShallowMemoryUsageOf(handle) - idle).toBeLessThan(1024); + expect({ callbacks, bytesWritten: p.client.bytesWritten, intact: state.intact }).toEqual({ + callbacks: ["EPIPE"], + bytesWritten: payload.length, + intact: true, + }); + }); + + // The TLS queue holds plaintext, and TLS has no writev path. + test("tls: writes drained by writable events arrive in order", async () => { + const server = tls.createServer({ key: certs.key, cert: certs.cert }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const connection = once(server, "secureConnection") as Promise<[tls.TLSSocket]>; + const client = tls.connect({ + port: (server.address() as net.AddressInfo).port, + host: "127.0.0.1", + ca: certs.cert, + rejectUnauthorized: true, + }); + try { + const [[serverSock]] = await Promise.all([connection, once(client, "secureConnect")]); + const handle = (client as any)._handle; + const first = patterned(1024 * 1024); + const second = patterned(256 * 1024, 0x85ebca6b); + const expected = Buffer.concat([first, second]); + const state = receive(serverSock, expected); + const idle = estimateShallowMemoryUsageOf(handle); + const callbacks: string[] = []; + + fault.set({ syscall: "send", action: "short", bytes: 16384, repeat: -1, fd: handle.fd }); + const { promise: written, resolve, reject } = Promise.withResolvers(); + client.write(first, err => (err ? reject(err) : callbacks.push("first"))); + client.write(second, err => (err ? reject(err) : (callbacks.push("second"), resolve()))); + const bytesWritten = await bytesWrittenUntil(written, client); + const heldAfter = estimateShallowMemoryUsageOf(handle) - idle; + client.end(); + await once(serverSock, "end"); + + expect({ callbacks, bytesWritten, heldAfter, ...state }).toEqual({ + callbacks: ["first", "second"], + bytesWritten: [expected.length], + heldAfter: 0, + received: expected.length, + intact: true, + }); + } finally { + client.destroy(); + server.close(); + } + }); +}); diff --git a/test/js/node/net/node-destroy-fixture.js b/test/js/node/net/node-destroy-fixture.js index b380a543f390..75cca3562cf2 100644 --- a/test/js/node/net/node-destroy-fixture.js +++ b/test/js/node/net/node-destroy-fixture.js @@ -1,4 +1,12 @@ const net = require("node:net"); + +// The test runner doesn't kill a concurrent test's children when it times out, +// so exit here if the process hangs. The unref'd timer doesn't keep the loop alive. +setTimeout(() => { + console.error("still running after 10s"); + process.exit(2); +}, 10_000).unref(); + const { promise, resolve } = Promise.withResolvers(); const client = net.createConnection(process.env.PORT, "localhost"); client.on("connect", () => { diff --git a/test/js/node/net/node-fin-fixture.js b/test/js/node/net/node-fin-fixture.js index 6097d4751050..18979e7168d8 100644 --- a/test/js/node/net/node-fin-fixture.js +++ b/test/js/node/net/node-fin-fixture.js @@ -1,5 +1,12 @@ var net = require("net"); +// The test runner doesn't kill a concurrent test's children when it times out, +// so exit here if the process hangs. The unref'd timer doesn't keep the loop alive. +setTimeout(() => { + console.error("still running after 10s"); + process.exit(2); +}, 10_000).unref(); + var client = new net.Socket(); client.connect(process.env.PORT, "localhost", function () { client.write("Hello, server"); diff --git a/test/js/node/net/node-net-allowHalfOpen.test.js b/test/js/node/net/node-net-allowHalfOpen.test.js index fed467fcda61..7dae2d22e2e3 100644 --- a/test/js/node/net/node-net-allowHalfOpen.test.js +++ b/test/js/node/net/node-net-allowHalfOpen.test.js @@ -1,5 +1,7 @@ -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { bunRun, isWindows, nodeExe, tempDir, tempDirWithFiles } from "harness"; +import { randomFillSync, randomUUID } from "node:crypto"; +import { once } from "node:events"; import net from "node:net"; import { join } from "node:path"; @@ -115,6 +117,32 @@ test("allowHalfOpen: false should work on client-side", async () => { ).toEqual(["Hello, World", "Received FIN"]); }); +// On Windows this is a named pipe, where end() used to close the read side too. +test("delivers the reply to the last write before end() over a pipe", async () => { + using dir = tempDir("net-pipe-pingpong", {}); + const path = isWindows ? `\\\\.\\pipe\\bun-test-pingpong-${randomUUID()}` : join(String(dir), "pingpong.sock"); + const server = net.createServer({ allowHalfOpen: true }, socket => { + socket.on("data", () => socket.write("PONG")); + socket.on("end", () => socket.end()); + }); + await once(server.listen(path), "listening"); + try { + const client = net.createConnection(path); + const replies = []; + client.setEncoding("utf8"); + client.on("connect", () => client.write("PING")); + client.on("data", reply => { + if (replies.push(reply) > 1) return; + client.write("PING"); + client.end(); + }); + await once(client, "close"); + expect(replies).toEqual(["PONG", "PONG"]); + } finally { + server.close(); + } +}); + // A paused allowHalfOpen socket whose AF_UNIX peer writes a tail and closes (EPOLLHUP while paused) must keep // the tail for resume() and then deliver data, end and close once each; the paused window must not spin the loop. test.skipIf(isWindows)("allowHalfOpen: paused socket whose unix peer closed delivers its tail on resume", async () => { @@ -127,3 +155,46 @@ test.skipIf(isWindows)("allowHalfOpen: paused socket whose unix peer closed deli ); expect(result).toEqual({ stdout: JSON.stringify(expected) + "\nidle", stderr: "", exitCode: 0, signalCode: null }); }); + +// The client sends its request and FIN together, then reads. The reply is bigger than one send() +// takes, so part of it is still queued in user space when the server sees the FIN. +describe.each([ + { allowHalfOpen: true, replyOn: "end" }, + { allowHalfOpen: true, replyOn: "data" }, + { allowHalfOpen: false, replyOn: "data" }, +])("server with allowHalfOpen: $allowHalfOpen that replies on '$replyOn'", ({ allowHalfOpen, replyOn }) => { + test("sends the whole reply after the client's FIN", async () => { + const N = 8 * 1024 * 1024; + const payload = randomFillSync(Buffer.allocUnsafe(N)); + const serverSide = Promise.withResolvers(); + const server = net.createServer({ allowHalfOpen }, socket => { + socket.on("error", serverSide.reject); + socket.on("close", serverSide.resolve); + socket.on("data", () => { + if (replyOn === "data") socket.end(payload); + }); + socket.on("end", () => { + if (replyOn === "end") socket.end(payload); + }); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const clientSide = Promise.withResolvers(); + let got = 0; + let mismatchAt = -1; + const client = net.connect({ port: server.address().port, host: "127.0.0.1", allowHalfOpen: true }); + client.on("error", clientSide.reject); + client.on("connect", () => client.end("request\n")); + client.on("data", chunk => { + if (mismatchAt === -1 && !chunk.equals(payload.subarray(got, got + chunk.length))) mismatchAt = got; + got += chunk.length; + }); + client.on("close", clientSide.resolve); + await Promise.all([clientSide.promise, serverSide.promise]); + expect({ got, mismatchAt }).toEqual({ got: N, mismatchAt: -1 }); + } finally { + server.close(); + } + }); +}); diff --git a/test/js/node/net/node-net-server.test.ts b/test/js/node/net/node-net-server.test.ts index 574dca0c9c4b..21190720f85c 100644 --- a/test/js/node/net/node-net-server.test.ts +++ b/test/js/node/net/node-net-server.test.ts @@ -1,5 +1,5 @@ -import { realpathSync } from "fs"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { existsSync, realpathSync } from "fs"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import { AddressInfo, createServer, Server, Socket } from "net"; import { createTest } from "node-harness"; import { once } from "node:events"; @@ -66,6 +66,147 @@ describe("net.createServer listen", () => { ); }); + // A kernel booted with ipv6.disable=1 fails socket(AF_INET6) with EAFNOSUPPORT. A seccomp filter does the same here. + it.skipIf(process.platform !== "linux" || (process.arch !== "x64" && process.arch !== "arm64"))( + "should fall back to 0.0.0.0 when no host is given and the kernel has no IPv6", + async () => { + using dir = tempDir("net-listen-no-ipv6", { + "deny-socket.c": ` + typedef unsigned short u16; typedef unsigned char u8; typedef unsigned int u32; + struct sock_filter { u16 code; u8 jt; u8 jf; u32 k; }; + struct sock_fprog { u16 len; struct sock_filter *filter; }; + int prctl(int option, ...); + #if defined(__x86_64__) + #define NR_SOCKET 41 + #else + #define NR_SOCKET 198 + #endif + /* Every later socket(family, ...) fails with err. Filters stack, so this can run twice. */ + int deny_socket_family(int family, int err) { + struct sock_filter f[] = { + { 0x20, 0, 0, 0 }, /* A = nr (BPF_LD|BPF_W|BPF_ABS) */ + { 0x15, 0, 3, NR_SOCKET }, /* if nr != socket: allow (BPF_JMP|BPF_JEQ|BPF_K) */ + { 0x20, 0, 0, 16 }, /* A = args[0] (family) */ + { 0x15, 0, 1, (u32)family }, /* if A != family: allow */ + { 0x06, 0, 0, 0x00050000 | (u32)err }, /* SECCOMP_RET_ERRNO | err (BPF_RET|BPF_K) */ + { 0x06, 0, 0, 0x7fff0000 }, /* SECCOMP_RET_ALLOW */ + }; + struct sock_fprog prog = { sizeof(f) / sizeof(f[0]), f }; + if (prctl(38 /* PR_SET_NO_NEW_PRIVS */, 1, 0, 0, 0) != 0) return -1; + if (prctl(22 /* PR_SET_SECCOMP */, 2 /* SECCOMP_MODE_FILTER */, &prog) != 0) return -2; + return 0; + } + `, + "fixture.ts": ` + import { cc } from "bun:ffi"; + import { once } from "node:events"; + import { createServer, type AddressInfo } from "node:net"; + import { join } from "node:path"; + + const { symbols } = cc({ + source: join(import.meta.dir, "deny-socket.c"), + symbols: { deny_socket_family: { args: ["i32", "i32"], returns: "i32" } }, + }); + const AF_UNIX = 1, AF_INET6 = 10, EMFILE = 24, EAFNOSUPPORT = 97; + const result: Record = { seccomp: symbols.deny_socket_family(AF_INET6, EAFNOSUPPORT) }; + if (result.seccomp !== 0) { + console.log(JSON.stringify(result)); + process.exit(0); + } + const listenError = async (...args: any[]) => { + const server = createServer().listen(...args); + try { + await once(server, "listening"); + } catch (err: any) { + return { code: err.code, syscall: err.syscall, address: err.address, message: err.message }; + } + const listening = server.address(); + server.close(); + return { listening }; + }; + + const noHost = createServer().listen(0); + await once(noHost, "listening"); + const { address, family, port } = noHost.address() as AddressInfo; + result.noHost = { address, family }; + const busy = await listenError(port); + result.noHostPortBusy = { ...busy, message: busy.message.replace(String(port), "PORT") }; + noHost.close(); + + result.explicitIPv6 = await listenError(0, "::"); + try { + Bun.listen({ hostname: "::", port: 0, socket: { data() {} } }).stop(); + result.bunListen = "listening"; + } catch (e: any) { + result.bunListen = { code: e.code, syscall: e.syscall, errno: typeof e.errno }; + } + + result.seccompUnix = symbols.deny_socket_family(AF_UNIX, EMFILE); + const { code, syscall } = await listenError(join(import.meta.dir, "listen.sock")); + result.unix = { code, syscall }; + console.log(JSON.stringify(result)); + `, + }); + 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(""); + const result = JSON.parse(stdout); + if (result.seccomp !== 0) { + console.warn("SKIP net listen without IPv6: seccomp filter not permitted here (" + result.seccomp + ")"); + return; + } + expect(result).toEqual({ + seccomp: 0, + noHost: { address: "0.0.0.0", family: "IPv4" }, + noHostPortBusy: { + code: "EADDRINUSE", + syscall: "listen", + address: "0.0.0.0", + message: "listen EADDRINUSE: address already in use 0.0.0.0:PORT", + }, + explicitIPv6: { + code: "EAFNOSUPPORT", + syscall: "listen", + address: "::", + message: "listen EAFNOSUPPORT: address family not supported ::", + }, + bunListen: { code: "EAFNOSUPPORT", syscall: "listen", errno: "number" }, + seccompUnix: 0, + unix: { code: "EMFILE", syscall: "listen" }, + }); + expect(exitCode).toBe(0); + }, + ); + + // Metro calls listen(port, host, undefined, cb). + it.each([ + ["port, host, undefined, cb", (cb: () => void) => [0, "127.0.0.1", undefined, cb], "127.0.0.1"], + ["port, host, null, cb", (cb: () => void) => [0, "127.0.0.1", null, cb], "127.0.0.1"], + [ + "port, host, undefined, undefined, cb", + (cb: () => void) => [0, "127.0.0.1", undefined, undefined, cb], + "127.0.0.1", + ], + ["port, undefined, undefined, cb", (cb: () => void) => [0, undefined, undefined, cb], "::"], + ["port, undefined, backlog, cb", (cb: () => void) => [0, undefined, 511, cb], "::"], + ["port, host, backlog, cb", (cb: () => void) => [0, "127.0.0.1", 511, cb], "127.0.0.1"], + ["port, backlog, cb", (cb: () => void) => [0, 511, cb], "::"], + ["options, backlog, cb", (cb: () => void) => [{ port: 0, host: "127.0.0.1" }, 511, cb], "127.0.0.1"], + ])("listen(%s) calls the callback", async (_name, args, address) => { + await using server = createServer(); + const { promise, resolve, reject } = Promise.withResolvers(); + server.on("error", reject); + (server.listen as any)(...args(resolve)); + await promise; + expect((server.address() as AddressInfo).address).toBe(address); + }); + it("should call listening", done => { const { mustCall } = createCallCheckCtx(done); @@ -177,6 +318,34 @@ describe("net.createServer listen", () => { ); }); + it.skipIf(isWindows)("emits EINVAL for a unix socket path with a null byte", async () => { + using dir = tempDir("net-listen-nul", {}); + const path = join(String(dir), "nul\0byte.sock"); + const server: Server = createServer(); + const listened = Promise.withResolvers(); + server.on("error", listened.resolve); + server.listen(path, () => listened.resolve(null)); + const listenError = await listened.promise; + server.close(); + expect({ code: listenError?.code, syscall: listenError?.syscall }).toEqual({ code: "EINVAL", syscall: "listen" }); + expect(existsSync(join(String(dir), "nul"))).toBe(false); + + // A server at the truncated path must not be reachable through the longer one. + const truncated: Server = createServer().listen(join(String(dir), "nul")); + await once(truncated, "listening"); + const connected = Promise.withResolvers(); + const socket = new Socket(); + socket.on("error", connected.resolve); + socket.connect(path, () => connected.resolve(null)); + const connectError = await connected.promise; + socket.destroy(); + truncated.close(); + expect({ code: connectError?.code, syscall: connectError?.syscall }).toEqual({ + code: "EINVAL", + syscall: "connect", + }); + }); + it("should bind IPv4 0.0.0.0 when listen on 0.0.0.0, issue#7355", done => { const { mustCall } = createCallCheckCtx(done); @@ -259,6 +428,23 @@ describe("net.createServer listen", () => { expect(order).toEqual(["error:EADDRINUSE", "nextTick"]); }); + // "localhost" usually resolves to both ::1 and 127.0.0.1. + it("reports EADDRINUSE when another server holds the port on localhost", async () => { + const occupant: Server = createServer(); + occupant.listen(0, "localhost"); + await once(occupant, "listening"); + const { port } = occupant.address() as AddressInfo; + + const server: Server = createServer(); + const { promise, resolve } = Promise.withResolvers(); + server.on("error", (err: NodeJS.ErrnoException) => resolve(err.code)); + server.listen(port, "localhost", () => resolve("listening on " + (server.address() as AddressInfo).address)); + const result = await promise; + server.close(); + occupant.close(); + expect(result).toBe("EADDRINUSE"); + }); + // How vite, get-port and friends probe for a free port: listen, then close() // from the 'listening' handler. A peer that connects in between must be reset // by the kernel when the listening fd closes, not accepted into the closing @@ -492,6 +678,40 @@ describe("net.createServer events", () => { }); }); + it("getConnections() counts open connections after close()", async () => { + const getConnections = (server: Server) => + new Promise((resolve, reject) => + server.getConnections((err, count) => (err ? reject(err) : resolve(count))), + ); + const accepted: Socket[] = []; + const server = createServer(socket => accepted.push(socket)); + await once(server.listen(0, "127.0.0.1"), "listening"); + const { port } = server.address() as AddressInfo; + const clients = [new Socket().connect(port, "127.0.0.1"), new Socket().connect(port, "127.0.0.1")]; + try { + while (accepted.length < 2) await once(server, "connection"); + + let sync = true; + const deferred = new Promise(resolve => server.getConnections(() => resolve(!sync))); + sync = false; + expect(await deferred).toBe(true); + + const closed = once(server, "close"); + server.close(); + expect(await getConnections(server)).toBe(2); + + accepted[0].destroy(); + await once(accepted[0], "close"); + expect(await getConnections(server)).toBe(1); + + accepted[1].destroy(); + await closed; + expect(await getConnections(server)).toBe(0); + } finally { + for (const socket of clients) socket.destroy(); + } + }); + it("should error on an invalid port", () => { const server = createServer(); @@ -502,6 +722,12 @@ describe("net.createServer events", () => { ); }); + it.each(["", " ", "\t"])("should error on the blank port %j", port => { + expect(() => createServer().listen(port)).toThrow( + expect.objectContaining({ name: "RangeError", code: "ERR_SOCKET_BAD_PORT" }), + ); + }); + it("should call abort with signal", done => { const { mustCall, mustNotCall } = createCallCheckCtx(done); @@ -593,6 +819,61 @@ describe("net.createServer events", () => { }); }); + // Windows still reports a fatal flush as a drain. + it.skipIf(isWindows)( + "fails a write made in the 'connection' listener when the peer resets before it's flushed", + async () => { + using dir = tempDir("net-open-flush-reset", { + "client.js": ` + const s = require("node:net").connect(Number(process.env.RESET_PORT), "127.0.0.1"); + s.on("error", () => {}); + s.once("data", () => { + s.resetAndDestroy(); + require("node:fs").writeFileSync(process.env.RESET_MARKER, ""); + }); + `, + }); + const marker = join(String(dir), "reset"); + + let sawReset = false; + let writeFailed: boolean | undefined; + let finished = false; + const errors: (string | undefined)[] = []; + const closed = Promise.withResolvers(); + const server = createServer(c => { + c.on("error", (err: NodeJS.ErrnoException) => errors.push(err.code)); + c.on("finish", () => (finished = true)); + c.on("close", closed.resolve); + // More than the kernel's send buffer, so the first send is short. + c.write(Buffer.alloc(16 * 1024 * 1024, "x"), err => (writeFailed = err != null)); + c.end(); + // The rest is flushed when this listener returns. Return only after the peer's reset. + const deadline = performance.now() + 10_000; + while (!(sawReset = existsSync(marker)) && performance.now() < deadline) {} + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + try { + await using client = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "client.js")], + env: { ...bunEnv, RESET_PORT: String((server.address() as AddressInfo).port), RESET_MARKER: marker }, + stdio: ["ignore", "inherit", "inherit"], + }); + expect({ hadError: await closed.promise, sawReset, writeFailed, finished }).toEqual({ + hadError: true, + sawReset: true, + writeFailed: true, + finished: false, + }); + expect(errors).toBeOneOf([["ECONNRESET"], ["EPIPE"]]); + expect(await client.exited).toBe(0); + } finally { + server.close(); + } + }, + ); + it("#8374", async () => { const server = createServer(); const socketPath = join(tmpdir(), "test-unix-socket"); diff --git a/test/js/node/net/node-net.test.ts b/test/js/node/net/node-net.test.ts index 3efd0530162a..f350d8e997fe 100644 --- a/test/js/node/net/node-net.test.ts +++ b/test/js/node/net/node-net.test.ts @@ -10,16 +10,18 @@ import { gc, isASAN, isDebug, + isLinux, isMusl, isWindows, tempDir, tls as tlsCert, tmpdirSync, } from "harness"; -import { execFileSync } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { once } from "node:events"; import fs from "node:fs"; +import { request } from "node:http"; import { BlockList, connect, @@ -37,7 +39,8 @@ import { Stream, } from "node:net"; import { join } from "node:path"; -import { TLSSocket } from "node:tls"; +import { duplexPair } from "node:stream"; +import { connect as tlsConnect, TLSSocket } from "node:tls"; const socket_domain = tmpdirSync(); @@ -202,7 +205,7 @@ describe("net.Socket read", () => { }, }) : Bun.listen({ - hostname: "localhost", + hostname: "127.0.0.1", port: 0, socket: { open(socket) { @@ -534,7 +537,8 @@ describe("net.Socket write", () => { socket.once("connect", (...args) => { socket.write("script\n", err => { if (err) return reject(err); - socket.end(() => setTimeout(resolve, 3)); + socket.once("close", resolve); + socket.end(); }); }); socket.connect(port, "127.0.0.1"); @@ -554,7 +558,7 @@ describe("net.Socket write", () => { // path) then called `deinit()`/`destroy()` on freed memory, and // `getListener` read `handlers.mode` through the same dangling pointer. // These only fault under ASAN/debug-poison, so they are gated accordingly. - it.skipIf(!isDebug && !isASAN)( + it.concurrent.skipIf(!isDebug && !isASAN)( "native handle does not retain a dangling handlers pointer after connectError (scope.exit path)", async () => { const fixture = ` @@ -591,7 +595,7 @@ describe("net.Socket write", () => { }, ); - it.skipIf(!isDebug && !isASAN)( + it.concurrent.skipIf(!isDebug && !isASAN)( "native handle does not retain a dangling handlers pointer after close (getListener)", async () => { const fixture = ` @@ -631,7 +635,7 @@ describe("net.Socket write", () => { }, ); - it.skipIf(!isDebug && !isASAN)( + it.concurrent.skipIf(!isDebug && !isASAN)( "reconnecting through a native handle whose handlers were freed does not double-free (connectInner)", async () => { const fixture = ` @@ -678,34 +682,47 @@ describe("net.Socket write", () => { ); }); -it("should handle connection error", done => { - let errored = false; - - // @ts-ignore - const socket = connect(55555, "127.0.0.1", () => { - done(new Error("Should not have connected")); - }); - - socket.on("error", error => { - if (errored) { - return done(new Error("Should not have errored twice")); - } - errored = true; - expect(error).toBeDefined(); - expect(error.message).toBe("connect ECONNREFUSED 127.0.0.1:55555"); - expect((error as any).code).toBe("ECONNREFUSED"); - expect((error as any).syscall).toBe("connect"); - expect((error as any).address).toBe("127.0.0.1"); - expect((error as any).port).toBe(55555); +it("should handle connection error", async () => { + // Bind a port and release it so connecting to it is refused. + const probe = createServer(); + await once(probe.listen(0, "127.0.0.1"), "listening"); + const { port } = probe.address() as import("node:net").AddressInfo; + await new Promise(resolve => probe.close(resolve)); + + const { promise: closed, resolve, reject } = Promise.withResolvers(); + const events: unknown[] = []; + const socket = connect(port, "127.0.0.1", () => reject(new Error("Should not have connected"))); + socket.on("error", (e: any) => + events.push({ message: e.message, code: e.code, syscall: e.syscall, address: e.address, port: e.port }), + ); + socket.on("connect", () => reject(new Error("Should not have connected"))); + socket.on("close", hadError => { + events.push(`close hadError=${hadError}`); + resolve(); }); + await closed; - socket.on("connect", () => { - done(new Error("Should not have connected")); - }); + // Exactly one 'error', then 'close'. + expect(events).toEqual([ + { + message: `connect ECONNREFUSED 127.0.0.1:${port}`, + code: "ECONNREFUSED", + syscall: "connect", + address: "127.0.0.1", + port, + }, + "close hadError=true", + ]); +}); - socket.on("close", () => { - expect(errored).toBe(true); - done(); +// Linux fails a TCP connect() to a multicast address with ENETUNREACH, with or without a route. +it.skipIf(!isLinux)("reports ENETUNREACH, not ECONNREFUSED, when connect() finds no route", async () => { + const socket = connect(1, "224.0.0.1"); + const [error] = await once(socket, "error"); + expect({ message: error.message, code: error.code, syscall: error.syscall }).toEqual({ + message: "connect ENETUNREACH 224.0.0.1:1", + code: "ENETUNREACH", + syscall: "connect", }); }); @@ -744,14 +761,33 @@ it("Socket has a prototype", () => { function Connection2() {} require("util").inherits(Connection, Socket); require("util").inherits(Connection2, require("tls").TLSSocket); + expect(Object.getPrototypeOf(Connection.prototype)).toBe(Socket.prototype); + expect(Object.getPrototypeOf(Connection2.prototype)).toBe(TLSSocket.prototype); }); -it("unref should exit when no more work pending", async () => { - const process = Bun.spawn({ - cmd: [bunExe(), join(import.meta.dir, "node-unref-fixture.js")], - env: bunEnv, +// node-unref-fixture.js and node-ref-default-fixture.js send a request to this TLS server. +function replyingTLSServer() { + return Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls: tlsCert, + socket: { + data(socket) { + socket.end("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); + }, + // The unref fixture exits mid-handshake. + error() {}, + }, }); - expect(await process.exited).toBe(0); +} + +it.concurrent("unref should exit when no more work pending", async () => { + using server = replyingTLSServer(); + const { stdout, stderr, exitCode } = await bunRun(join(import.meta.dir, "node-unref-fixture.js"), { + PORT: String(server.port), + }); + // The fixture prints "Received data. FAIL" and exits 1 if the unref'd socket kept the loop alive until the reply. + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "", stderr: "", exitCode: 0 }); }); // IPv4-only server + injected lookup listing ::1 first forces a refused attempt then a retry; the call runs mid-lookup @@ -787,6 +823,46 @@ it.concurrent.each(["s.unref()", "s.pause()"])("%s survives an autoSelectFamily } }); +it.concurrent.each([false, true])( + "destroy() in a 'connectionAttempt' listener cancels the connect (autoSelectFamily: %p)", + async autoSelectFamily => { + const accepted: string[] = []; + const server = createServer(socket => { + accepted.push(`${socket.remoteAddress}:${socket.remotePort}`); + socket.on("error", () => {}); + socket.end(); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + try { + const port = server.address().port; + // Two addresses, so autoSelectFamily doesn't fall back to the single-address path. + const addresses = [ + { address: "127.0.0.1", family: 4 }, + { address: "::1", family: 6 }, + ]; + const lookup = (_host, opts, cb) => process.nextTick(cb, null, opts.all ? addresses : "127.0.0.1", 4); + const socket = connect({ host: "localhost", port, lookup, autoSelectFamily }); + let attempts = 0; + socket.on("connectionAttempt", () => { + attempts++; + socket.destroy(); + }); + await once(socket, "close"); + + // Loopback connects are accepted in order, so an earlier connect would show up before this one. + const control = connect(port, "127.0.0.1"); + await once(control, "connect"); + const controlKey = `${control.localAddress}:${control.localPort}`; + while (!accepted.includes(controlKey)) await once(server, "connection"); + control.destroy(); + + expect({ attempts, accepted }).toEqual({ attempts: 1, accepted: [controlKey] }); + } finally { + server.close(); + } + }, +); + // https://github.com/oven-sh/bun/issues/37086 — node's pending uv_connect_t keeps the loop alive even on an // unref'd/non-reading handle, so unref()/pause() issued before or while connecting only take effect once connected. describe.concurrent("unref()/pause() around connect()", () => { @@ -834,6 +910,7 @@ describe.concurrent("unref()/pause() around connect()", () => { }); it.each([ + ["before connect()", `s.unref(); s.ref(); s.resume(); s.connect(port, "127.0.0.1");`], ["right after connect()", `s.connect(port, "127.0.0.1"); s.unref(); s.ref(); s.resume();`], ["while the connect is in flight", `s.connect(port, "127.0.0.1"); ${inFlight("s.unref(); s.ref(); s.resume();")}`], ])("ref() after unref() %s keeps holding the loop", async (_, client) => { @@ -887,18 +964,19 @@ describe.concurrent("unref()/pause() around connect()", () => { }); }); -it("socket should keep process alive if unref is not called", async () => { - const process = Bun.spawn({ - cmd: [bunExe(), join(import.meta.dir, "node-ref-default-fixture.js")], - env: bunEnv, +it.concurrent("socket should keep process alive if unref is not called", async () => { + using server = replyingTLSServer(); + const { stdout, stderr, exitCode } = await bunRun(join(import.meta.dir, "node-ref-default-fixture.js"), { + PORT: String(server.port), }); - expect(await process.exited).toBe(1); + // The fixture exits 1 from its 'data' listener. It exits 0 if the socket didn't keep the loop alive until the reply. + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "Received data.", stderr: "", exitCode: 1 }); }); // Node never resumes a socket on the user's behalf: afterConnect only calls // read(0) (lib/net.js), so bytes that arrive before a 'data' listener is // attached stay buffered instead of being emitted to nobody and lost. -it("a connected socket is not flowing until the user reads from it", async () => { +it.concurrent("a connected socket is not flowing until the user reads from it", async () => { const { promise: received, resolve: onClose, reject } = Promise.withResolvers(); const server = createServer(c => { c.on("error", reject); @@ -923,79 +1001,50 @@ it("a connected socket is not flowing until the user reads from it", async () => } }); -it("should not hang after FIN", async () => { - const net = require("node:net"); - const { promise: listening, resolve: resolveListening, reject } = Promise.withResolvers(); - const server = net.createServer(c => { +it.concurrent("should not hang after FIN", async () => { + const server = createServer(c => { c.write("Hello client"); c.end(); }); try { - server.on("error", reject); - server.listen(0, () => { - resolveListening(server.address().port); - }); - const process = Bun.spawn({ - cmd: [bunExe(), join(import.meta.dir, "node-fin-fixture.js")], - stderr: "inherit", - stdin: "ignore", - stdout: "inherit", - env: { - ...bunEnv, - PORT: ((await listening) as number).toString(), - }, + await once(server.listen(0), "listening"); + const { stdout, stderr, exitCode } = await bunRun(join(import.meta.dir, "node-fin-fixture.js"), { + PORT: String((server.address() as import("node:net").AddressInfo).port), }); - const timeout = setTimeout(() => { - process.kill(); - reject(new Error("Timeout")); - }, 60_000); - expect(await process.exited).toBe(0); - clearTimeout(timeout); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "", stderr: "", exitCode: 0 }); } finally { server.close(); } -}, 120_000); +}); -it("should not hang after destroy", async () => { - const net = require("node:net"); - const { promise: listening, resolve: resolveListening, reject } = Promise.withResolvers(); - const server = net.createServer(c => { +it.concurrent("should not hang after destroy", async () => { + const server = createServer(c => { // The client destroys without reading; the resulting RST surfaces as // ECONNRESET here (Node behaves identically) — handle it. c.on("error", () => {}); c.write("Hello client"); }); try { - server.on("error", reject); - server.listen(0, () => { - resolveListening(server.address().port); - }); - const process = Bun.spawn({ - cmd: [bunExe(), join(import.meta.dir, "node-destroy-fixture.js")], - stderr: "inherit", - stdin: "ignore", - stdout: "inherit", - env: { - ...bunEnv, - PORT: ((await listening) as number).toString(), - }, + await once(server.listen(0), "listening"); + const { stdout, stderr, exitCode } = await bunRun(join(import.meta.dir, "node-destroy-fixture.js"), { + PORT: String((server.address() as import("node:net").AddressInfo).port), }); - const timeout = setTimeout(() => { - process.kill(); - reject(new Error("Timeout")); - }, 60_000); - expect(await process.exited).toBe(0); - clearTimeout(timeout); + // The fixture prints any 'error' event to stderr. + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "", stderr: "", exitCode: 0 }); } finally { server.close(); } -}, 120_000); +}); + +// Never calls back, so the connection attempt stays pending and never dials. +const pendingLookup = () => {}; it("should trigger error when aborted even if connection failed #13126", async () => { const signal = AbortSignal.timeout(100); const socket = createConnection({ - host: "example.com", + host: "pending.test", port: 999, + lookup: pendingLookup, signal: signal, }); const { promise, resolve, reject } = Promise.withResolvers(); @@ -1010,12 +1059,54 @@ it("should trigger error when aborted even if connection failed #13126", async ( expect(err.cause?.name).toBe("TimeoutError"); }); +// Aborts a TCP connect that's already in flight. blackholePortSource doesn't work on Windows or musl. +it.skipIf(isWindows || isMusl).each([ + ["an address", `{ host: "127.0.0.1" }`], + [ + "several addresses", + `{ + host: "blackhole.test", + autoSelectFamily: true, + lookup: (hostname, options, callback) => + process.nextTick(callback, null, [{ address: "127.0.0.1", family: 4 }, { address: "127.0.0.2", family: 4 }]), + }`, + ], +])("should trigger error when aborted while connecting to %s #13126", async (_, options) => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + ${blackholePortSource} + const events = []; + const socket = net.createConnection({ ...${options}, port, signal: AbortSignal.timeout(100) }); + for (const name of ["connectionAttempt", "connect"]) socket.on(name, () => events.push(name)); + socket.on("error", error => events.push([error.name, error.code, error.cause?.name].join(" "))); + socket.on("close", () => { + console.log(events.join(",")); + filler.destroy(); + }); + `, + ], + 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: "connectionAttempt,AbortError ABORT_ERR TimeoutError\n", + stderr: "", + exitCode: 0, + }); +}); + it("should trigger error when aborted even if connection failed, and the signal is already aborted #13126", async () => { const signal = AbortSignal.timeout(1); - await Bun.sleep(10); + if (!signal.aborted) await once(signal, "abort"); const socket = createConnection({ - host: "example.com", + host: "pending.test", port: 999, + lookup: pendingLookup, signal: signal, }); const { promise, resolve, reject } = Promise.withResolvers(); @@ -1030,6 +1121,51 @@ it("should trigger error when aborted even if connection failed, and the signal expect(err.cause?.name).toBe("TimeoutError"); }); +it.each([false, true])( + "doesn't connect when the lookup answers after the abort (autoSelectFamily: %p) #13126", + async autoSelectFamily => { + let accepted = 0; + await using server = createServer(connection => { + accepted++; + connection.destroy(); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + + const controller = new AbortController(); + const reason = new Error("lookup aborted"); + const events: unknown[] = []; + const socket = new Socket({ signal: controller.signal }); + for (const name of ["connectionAttempt", "connect", "close"]) socket.on(name, () => events.push(name)); + socket.on("error", (error: NodeJS.ErrnoException) => + events.push({ name: error.name, code: error.code, sameCause: error.cause === reason }), + ); + + const lookupStarted = Promise.withResolvers<() => void>(); + socket.connect({ + host: "held-lookup.test", + port: (server.address() as import("node:net").AddressInfo).port, + autoSelectFamily, + lookup(_hostname, options, callback) { + lookupStarted.resolve(() => + options.all ? callback(null, [{ address: "127.0.0.1", family: 4 }]) : callback(null, "127.0.0.1", 4), + ); + }, + }); + const answerLookup = await lookupStarted.promise; + const closed = new Promise(resolve => socket.once("close", resolve)); + controller.abort(reason); + await closed; + + answerLookup(); + await new Promise(resolve => setImmediate(resolve)); + expect({ events, accepted, destroyed: socket.destroyed }).toEqual({ + events: [{ name: "AbortError", code: "ABORT_ERR", sameCause: true }, "close"], + accepted: 0, + destroyed: true, + }); + }, +); + // Regression test for #30697: net.connect({ localPort, lookup }) on the // happy-eyeballs path must not throw a ReferenceError before the socket // is opened. @@ -1232,81 +1368,6 @@ it.if(isWindows)("should not leak when connect({path}) fails asynchronously whil }); }); -// On Windows, unix paths route through the named-pipe codepath which reports -// failure asynchronously; this test targets the synchronous-failure branch in -// Listener.connectInner. -it.skipIf(isWindows)( - "should not leak when connect({path}) fails synchronously on a reused handle", - async () => { - // node:net creates a detached native socket (`_handle`) and passes it as - // `prev` to connectInner. connectInner unconditionally `socket.ref()`s - // before `doConnect`. A nonexistent unix path makes `doConnect` throw - // synchronously while the socket is still `.detached`, so - // `handleConnectError`'s own deref (gated on `!isDetached()`) does not - // fire — the ref taken here must be released by the caller for reused - // sockets too, not only freshly-allocated ones. Without that, every - // failed reconnect leaks one native TCPSocket struct + its connection - // string. - const script = ` - const net = require("node:net"); - const { heapStats } = require("bun:jsc"); - const path = "/tmp/bun-test-nonexistent-" + process.pid + ".sock"; - - function once() { - return new Promise(resolve => { - const s = new net.Socket(); - s.on("error", () => {}); - s.on("close", resolve); - s.connect({ path }); - }); - } - async function run(n) { - for (let i = 0; i < n; i += 100) { - const batch = []; - for (let j = 0; j < 100; j++) batch.push(once()); - await Promise.all(batch); - } - Bun.gc(true); - await Bun.sleep(20); - Bun.gc(true); - } - - // Count live mimalloc pages across all size bins. Each leaked - // TCPSocket struct is ~300-400 bytes; 8k of them fill ~25 pages - // (release) / ~160 pages (debug+ASAN). Unlike RSS this is the - // allocator's own bookkeeping, so it's independent of OS page - // reclamation. - function pageCount() { - return heapStats().mimalloc.page_bins.reduce((a, b) => a + b.current, 0); - } - - // Warm up with the SAME workload as the measured run: on builds where - // JSC shares mimalloc, its heap keeps growing until the first full-size - // batch, so equal batches make the delta isolate the per-run leak. - await run(8000); - const before = pageCount(); - await run(8000); - const after = pageCount(); - console.log(JSON.stringify({ before, after, delta: after - before })); - `; - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", script], - env: { ...bunEnv, BUN_GARBAGE_COLLECTOR_LEVEL: "0" }, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe(""); - const { before, after, delta } = JSON.parse(stdout.trim().split("\n").pop()!); - // Without the balancing deref: +25 pages (release) / +163 (debug+ASAN). - // With it the socket delta is 0, but since #34009 JSC shares mimalloc and - // adds up to +14 of heap noise on aarch64/darwin release (build 75589). - expect(delta, `mimalloc page count: ${before} -> ${after}`).toBeLessThan(20); - expect(exitCode).toBe(0); - }, - 60_000, -); - // node only reaches readStart() from _read(), and read(0) never calls _read once // the readable side has ended, so a `readable: false` client leaves the peer's // bytes unread. Bun's handle reads unless stopped, and pushing those bytes into @@ -1409,11 +1470,11 @@ describe("Socket fd adoption", () => { // A named pipe with both ends open in this process: the non-blocking read end // is opened first so opening the write end does not block. The other fd kind // node's `new Socket({ fd })` accepts besides TCP sockets is a pipe. - function openFifo(name: string) { + function openFifo(name: string, writeFlags: string | number = "w") { const path = join(tmpdirSync(), name); execFileSync("mkfifo", [path]); const rfd = fs.openSync(path, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK); - const wfd = fs.openSync(path, "w"); + const wfd = fs.openSync(path, writeFlags); return { rfd, wfd }; } // Everything the (non-blocking) read end has right now; "" at EOF or when empty. @@ -1462,6 +1523,48 @@ describe("Socket fd adoption", () => { } }); + describe.skipIf(isWindows)("over a non-blocking pipe", () => { + const nonBlocking = fs.constants.O_WRONLY | fs.constants.O_NONBLOCK; + + it("delivers writes bigger than the pipe's buffer", async () => { + const { rfd, wfd } = openFifo("full.fifo", nonBlocking); + try { + const received = new Response(Bun.file(rfd).stream()).bytes(); + const socket = new Socket({ fd: wfd, readable: false, writable: true }); + const chunks = [ + Buffer.alloc(300_000, "a"), + Buffer.alloc(10, "b"), + Buffer.alloc(200_000, "c"), + Buffer.from("d"), + ]; + socket.write(chunks[0]); + socket.cork(); + for (const chunk of chunks.slice(1)) socket.write(chunk); + socket.uncork(); + socket.end(); + await once(socket, "close"); + expect(Buffer.from(await received).equals(Buffer.concat(chunks))).toBe(true); + expect(socket.bytesWritten).toBe(500_011); + } finally { + fs.closeSync(rfd); + } + }); + + it("destroy() cancels a write the pipe had no room for", async () => { + const { rfd, wfd } = openFifo("cancel.fifo", nonBlocking); + try { + const socket = new Socket({ fd: wfd, readable: false, writable: true }); + const events: string[] = []; + socket.write(Buffer.alloc(300_000), (err: any) => events.push(`write ${err?.code}`)); + socket.destroy(); + await once(socket, "close"); + expect(events).toEqual(["write ECANCELED"]); + } finally { + fs.closeSync(rfd); + } + }); + }); + // Node's createHandle() wraps PIPE and TCP fds only; a regular file is // ERR_INVALID_FD_TYPE there (process.stdout over a file is a SyncWriteStream, // not a net.Socket). @@ -1563,19 +1666,110 @@ describe("Socket fd adoption", () => { expect(error?.message).toBe("Unsupported fd type: UNKNOWN"); }); - it("a bare { fd } does not throw so connect({ fd }) can attach a native handle", () => { - // No explicit writable: true -> no adoption, no fstat. child_process - // extra stdio relies on this path (connect({ fd }) attaches natively). - expect(() => new Socket({ fd: 0x7ffff })).not.toThrow(); + it.skipIf(isWindows)("checks the fd type before typeOfService and doesn't close the fd when it throws", async () => { + const { rfd, wfd } = openFifo("invalid-tos.fifo"); + const file = fs.openSync(join(tmpdirSync(), "invalid-tos.txt"), "w"); + const codeFor = (fd: number) => { + try { + new Socket({ fd, writable: true, typeOfService: 999 } as import("node:net").SocketConstructorOpts); + } catch (e) { + return (e as NodeJS.ErrnoException).code; + } + }; + try { + const codes = { file: codeFor(file), fifo: codeFor(wfd) }; + // A socket that adopted the fd would end and close it on the next tick. + await new Promise(resolve => setImmediate(resolve)); + expect({ ...codes, fifoFd: fstatCode(wfd) }).toEqual({ + file: "ERR_INVALID_FD_TYPE", + fifo: "ERR_OUT_OF_RANGE", + fifoFd: "open", + }); + } finally { + fs.closeSync(wfd); + fs.closeSync(rfd); + fs.closeSync(file); + } + }); + + // spawn() makes "pipe" stdio slots out of socketpairs, so this is what a child sees on fd 3 and up. + it.skipIf(isWindows)("reads from and writes to a socket fd as soon as it's constructed", async () => { + const child = spawn( + bunExe(), + [ + "-e", + ` + const net = require("node:net"); + const fs = require("node:fs"); + const options = { + bare: { fd: 3 }, + duplex: { fd: 4, readable: true, writable: true }, + readOnly: { fd: 5, readable: true, writable: false }, + }; + const report = {}; + let open = 3; + for (const name in options) { + const fd = options[name].fd; + const socket = new net.Socket(options[name]); + const events = (report[name] = ["pending=" + socket.pending + " connecting=" + socket.connecting]); + socket.setEncoding("utf8"); + socket.on("connect", () => events.push("connect")); + socket.on("data", data => { + events.push("data:" + data); + if (socket.writable) socket.end("echo:" + data); + else socket.destroy(); + }); + socket.on("end", () => events.push("end")); + socket.on("error", err => events.push("error:" + err.code)); + socket.on("close", () => { + try { + fs.fstatSync(fd); + events.push("close, fd still open"); + } catch (err) { + events.push("close, fd " + err.code); + } + if (--open === 0) console.log(JSON.stringify(report)); + }); + } + `, + ], + { env: bunEnv, stdio: ["ignore", "pipe", "inherit", "pipe", "pipe", "pipe"] }, + ); + const text = async (stream: NodeJS.ReadableStream) => Buffer.concat(await Array.fromAsync(stream)).toString(); + const [, stdout, , bare, duplex, readOnly] = child.stdio as Socket[]; + readOnly.on("error", () => {}); + for (const pipe of [bare, duplex, readOnly]) pipe.write("ping"); + const [report, fromBare, fromDuplex, [exitCode]] = await Promise.all([ + text(stdout), + text(bare), + text(duplex), + once(child, "exit"), + ]); + const echoed = ["pending=false connecting=false", "data:ping", "end", "close, fd EBADF"]; + expect({ report: JSON.parse(report), fromBare, fromDuplex, exitCode }).toEqual({ + report: { + bare: echoed, + duplex: echoed, + readOnly: ["pending=false connecting=false", "data:ping", "close, fd EBADF"], + }, + fromBare: "echo:ping", + fromDuplex: "echo:ping", + exitCode: 0, + }); + }); + + it("a bare { fd } that can't be fstat'ed does not throw", () => { + const socket = new Socket({ fd: 0x7ffff }); + expect({ destroyed: socket.destroyed, pending: socket.pending }).toEqual({ destroyed: false, pending: true }); }); // node's _writeGeneric restarts the idle timer before every write; the // synchronous fd write path has no handle doing that for it. - it.skipIf(isWindows)("writes to an adopted fd restart the setTimeout() idle timer", async () => { + it.concurrent.skipIf(isWindows)("writes to an adopted fd restart the setTimeout() idle timer", async () => { const { rfd, wfd } = openFifo("timeout.fifo"); const socket = new Socket({ fd: wfd, readable: false, writable: true }); try { - const idle = 500; + const idle = 200; const writes: number[] = []; const timeouts: number[] = []; socket.on("timeout", () => timeouts.push(performance.now())); @@ -1584,13 +1778,13 @@ describe("Socket fd adoption", () => { for (let i = 0; i < 20; i++) { writes.push(performance.now()); socket.write("x"); - await Bun.sleep(50); + await Bun.sleep(idle / 10); } if (timeouts.length === 0) await once(socket, "timeout"); // Whenever 'timeout' fired, a full idle period had passed since the last // write before it: each write restarted the timer. (A scheduler stall // longer than `idle` between two writes is then a legitimate timeout, not - // a failure.) Without the restart the first one lands ~50ms after a write. + // a failure.) Without the restart the first one lands ~20ms after a write. const gaps = timeouts.map(at => at - Math.max(...writes.filter(w => w <= at))); expect(gaps.filter(gap => gap < idle * 0.9)).toEqual([]); expect(drain(rfd)).toBe(Buffer.alloc(20, "x").toString()); @@ -1604,7 +1798,7 @@ describe("Socket fd adoption", () => { // writable, so inherited properties never adopt anything (an own file fd // would throw ERR_INVALID_FD_TYPE); the Duplex flags and the adoption // decision must come from the same view. - it("ignores fd / readable / writable that are not own properties of the options", async () => { + it.concurrent("ignores fd / readable / writable that are not own properties of the options", async () => { const path = join(tmpdirSync(), "inherited-fd.txt"); const fd = fs.openSync(path, "w"); try { @@ -1691,13 +1885,13 @@ describe.concurrent("socket that already sent FIN and is paused with unread data const d = process.cpuUsage(before); console.log("cpu", d.user + d.system); conn.resume(); - }, 1000); + }, 500); }); `); - // A level-triggered hangup left registered would have burned the whole 1s of - // wall time as CPU; 500ms leaves room over the debug+ASAN idle baseline. + // A level-triggered hangup that stays registered spins for the whole 500ms. + // An idle debug+ASAN build uses about 30ms. const cpuMicros = Number((result.lines.find(l => l.startsWith("cpu ")) ?? "cpu -1").slice(4)); - expect({ ...result, cpuIdle: cpuMicros >= 0 && cpuMicros < 500_000 }).toEqual({ + expect({ ...result, cpuIdle: cpuMicros >= 0 && cpuMicros < 250_000 }).toEqual({ lines: ["close 65536", `cpu ${cpuMicros}`], stderr: "", exitCode: 0, @@ -1958,43 +2152,130 @@ describe.concurrent("backpressure-paused socket does not keep the process alive" }); }); -describe("paused socket whose peer sends RST", () => { - // Regression: on Linux, epoll forwarded the raw EPOLLERR bit (8) as a libus - // close code, which the JS error path read as errno 8 and surfaced as a - // bogus `Error: read ENOEXEC` when the socket was not actively reading. - // kqueue already normalized the flag to 0/1. - it("does not surface a bogus errno error", async () => { - const { promise, resolve } = Promise.withResolvers(); - const errors: NodeJS.ErrnoException[] = []; - const server = createServer(c => { - c.on("error", () => {}); - // RST only once the client says it has paused. - c.on("data", () => c.resetAndDestroy()); - }); - try { - await new Promise(r => server.listen(0, "127.0.0.1", r)); - const port = (server.address() as import("node:net").AddressInfo).port; - const c = connect(port, "127.0.0.1", () => { - c.pause(); - c.write("x"); - }); - c.on("error", e => errors.push(e)); - c.on("close", () => resolve()); - await promise; - } finally { - server.close(); - } - expect(errors.map(e => e.code)).not.toContain("ENOEXEC"); - }); - // Like node, an onread socket's pause() stops the handle itself, so the RST - // arrives at a handle that is not registered for reads at all. - it("does not surface a bogus errno error for an onread socket", async () => { - const { promise, resolve } = Promise.withResolvers(); - const errors: NodeJS.ErrnoException[] = []; - const server = createServer(c => { - c.on("error", () => {}); - c.on("data", () => c.resetAndDestroy()); - }); +// On Windows, unix paths route through the named-pipe codepath which reports +// failure asynchronously; this test targets the synchronous-failure branch in +// Listener.connectInner. +it.concurrent.skipIf(isWindows)( + "should not leak when connect({path}) fails synchronously on a reused handle", + async () => { + // node:net creates a detached native socket (`_handle`) and passes it as + // `prev` to connectInner. connectInner unconditionally `socket.ref()`s + // before `doConnect`. A nonexistent unix path makes `doConnect` throw + // synchronously while the socket is still `.detached`, so + // `handleConnectError`'s own deref (gated on `!isDetached()`) does not + // fire. The ref taken here must be released by the caller for reused + // sockets too, not only freshly-allocated ones. Without that, every + // failed reconnect leaks one native TCPSocket struct + its connection + // string. + // + // ASAN builds allocate that struct from the system allocator, so the mimalloc + // page count below can't see the leak. LeakSanitizer reports it at exit instead. + const iterations = isASAN ? 100 : 8000; + const script = ` + const net = require("node:net"); + const { heapStats } = require("bun:jsc"); + const path = "/tmp/bun-test-nonexistent-" + process.pid + ".sock"; + + function once() { + return new Promise(resolve => { + const s = new net.Socket(); + s.on("error", () => {}); + s.on("close", resolve); + s.connect({ path }); + }); + } + async function run(n) { + for (let i = 0; i < n; i += 100) { + const batch = []; + for (let j = 0; j < 100; j++) batch.push(once()); + await Promise.all(batch); + } + Bun.gc(true); + await Bun.sleep(20); + Bun.gc(true); + } + + // Count live mimalloc pages across all size bins. Each leaked + // TCPSocket struct is ~300-400 bytes; 8k of them fill ~25 pages + // (release). Unlike RSS this is the allocator's own bookkeeping, so + // it's independent of OS page reclamation. + function pageCount() { + return heapStats().mimalloc.page_bins.reduce((a, b) => a + b.current, 0); + } + + // Warm up with the SAME workload as the measured run: on builds where + // JSC shares mimalloc, its heap keeps growing until the first full-size + // batch, so equal batches make the delta isolate the per-run leak. + await run(${iterations}); + const before = pageCount(); + await run(${iterations}); + const after = pageCount(); + console.log(JSON.stringify({ before, after, delta: after - before })); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: { + ...bunEnv, + BUN_GARBAGE_COLLECTOR_LEVEL: "0", + ...(isASAN && { + 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]); + // LeakSanitizer reports to stderr. + expect(stderr).toBe(""); + const { before, after, delta } = JSON.parse(stdout.trim().split("\n").pop()!); + // Without the balancing deref: +25 pages (release). + // With it the socket delta is 0, but since #34009 JSC shares mimalloc and + // adds up to +14 of heap noise on aarch64/darwin release (build 75589). + expect(delta, `mimalloc page count: ${before} -> ${after}`).toBeLessThan(20); + expect(exitCode).toBe(0); + }, + 60_000, +); + +describe("paused socket whose peer sends RST", () => { + // Regression: on Linux, epoll forwarded the raw EPOLLERR bit (8) as a libus + // close code, which the JS error path read as errno 8 and surfaced as a + // bogus `Error: read ENOEXEC` when the socket was not actively reading. + // kqueue already normalized the flag to 0/1. + it("does not surface a bogus errno error", async () => { + const { promise, resolve } = Promise.withResolvers(); + const errors: NodeJS.ErrnoException[] = []; + const server = createServer(c => { + c.on("error", () => {}); + // RST only once the client says it has paused. + c.on("data", () => c.resetAndDestroy()); + }); + try { + await new Promise(r => server.listen(0, "127.0.0.1", r)); + const port = (server.address() as import("node:net").AddressInfo).port; + const c = connect(port, "127.0.0.1", () => { + c.pause(); + c.write("x"); + }); + c.on("error", e => errors.push(e)); + c.on("close", () => resolve()); + await promise; + } finally { + server.close(); + } + expect(errors.map(e => e.code)).not.toContain("ENOEXEC"); + }); + // Like node, an onread socket's pause() stops the handle itself, so the RST + // arrives at a handle that is not registered for reads at all. + it("does not surface a bogus errno error for an onread socket", async () => { + const { promise, resolve } = Promise.withResolvers(); + const errors: NodeJS.ErrnoException[] = []; + const server = createServer(c => { + c.on("error", () => {}); + c.on("data", () => c.resetAndDestroy()); + }); try { await new Promise(r => server.listen(0, "127.0.0.1", r)); const port = (server.address() as import("node:net").AddressInfo).port; @@ -2819,7 +3100,7 @@ it("onread: `false` from a callback holding the `true` sentinel still pauses unt // onStreamRead calls the user callback bare, so the process dies rather than // the socket being failed closed. // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/stream_base_commons.js#L179 -it("onread: a callback that throws is an uncaught exception", async () => { +it.concurrent("onread: a callback that throws is an uncaught exception", async () => { // 12 bytes through a 4-byte buffer; the callback throws on the first slice. const fixture = /* js */ ` const net = require("net"); @@ -2839,6 +3120,8 @@ it("onread: a callback that throws is an uncaught exception", async () => { }, }); client.on("error", () => console.log("socket-error")); + // The listening server keeps the process alive if the throw doesn't end it. + setTimeout(() => process.exit(2), 10_000).unref(); }); `; await using proc = Bun.spawn({ @@ -2858,14 +3141,14 @@ it("onread: a callback that throws is an uncaught exception", async () => { expect(lines[0]).toBe("calls:abcd"); expect(lines).not.toContain("socket-error"); expect(["calls:abcd", "calls:abcd,efgh", "calls:abcd,efgh,ijkl"]).toEqual(expect.arrayContaining(lines)); - expect(exitCode).not.toBe(0); + expect(exitCode).toBe(1); }); // Node bounds each kernel read to the onread buffer's size, so a throw that is // swallowed by a process.on('uncaughtException') handler loses no bytes (the // next slice is a separate onStreamRead call). Bun slices one larger native // read in JS, so the catch is per-slice to preserve that. -it("onread: a swallowed throw does not drop the rest of the current native read", async () => { +it.concurrent("onread: a swallowed throw does not drop the rest of the current native read", async () => { const fixture = /* js */ ` process.on("uncaughtException", e => console.log("uncaught:" + e.message)); const net = require("net"); @@ -2939,6 +3222,34 @@ it.skipIf(isWindows)("a write after the peer reset the connection fails with a w } }); +it("a plain socket has none of TLSSocket's property names", async () => { + const tlsOnly = [ + "encrypted", + "authorized", + "authorizationError", + "servername", + "_requestCert", + "_rejectUnauthorized", + ]; + const accepted = Promise.withResolvers(); + const server = createServer(socket => { + accepted.resolve(tlsOnly.filter(name => name in socket)); + socket.end(); + }); + try { + await once(server.listen(0, "127.0.0.1"), "listening"); + const { port } = server.address() as import("node:net").AddressInfo; + const client = connect({ port, host: "127.0.0.1", servername: "example.com" }); + const closed = once(client.resume(), "close"); + await once(client, "connect"); + const connected = tlsOnly.filter(name => name in client); + expect({ connected, accepted: await accepted.promise }).toEqual({ connected: [], accepted: [] }); + await closed; + } finally { + server.close(); + } +}); + // Node hands a write that fails at once to the stream inside write() itself, // so write() returns false and the socket is errored in the same call. The // stream runs the write callbacks and destroys the socket on the next tick. @@ -3131,14 +3442,22 @@ it.skipIf(isWindows)("connect({ localPort }) succeeds when the local port has TI // The writev fast path is `#[cfg(unix)]`, and on Windows the amount a send // accepts is machine dependent, so the buffered precondition cannot be built there. describe.skipIf(isWindows)("socket write while data is buffered natively", () => { - // Counts received bytes per fill value. STALL_ON_ACCEPT=1 blocks the loop on - // accept so the kernel buffers stay full while the client writes. + // Counts received bytes per fill value. pauseOnConnect keeps the kernel buffers + // full until the client writes a byte count to the file "submitted". The server + // creates the file "drained" once it has received that many bytes. const serverFixture = /* js */ ` + import fs from "node:fs"; import net from "node:net"; const KNOWN = [0x61, 0x69, 0x73]; // 'a', 'i', 's' const counts = { a: 0, i: 0, s: 0, other: 0 }; const runs = []; let total = 0; + let drainTo = Infinity; + function reportDrained() { + if (total < drainTo) return; + drainTo = Infinity; + fs.writeFileSync("drained", ""); + } function scan(d) { total += d.length; let pos = 0; @@ -3161,12 +3480,16 @@ describe.skipIf(isWindows)("socket write while data is buffered natively", () => pos = end; } } - const server = net.createServer(c => { - c.on("data", scan); + const server = net.createServer({ pauseOnConnect: true }, c => { + c.on("data", d => { + scan(d); + reportDrained(); + }); let printed = false; const done = () => { if (printed) return; printed = true; + clearInterval(poll); console.log(JSON.stringify({ total, counts, runs })); c.destroy(); server.close(); @@ -3174,9 +3497,13 @@ describe.skipIf(isWindows)("socket write while data is buffered natively", () => c.on("end", done); c.on("close", done); c.on("error", done); - if (process.env.STALL_ON_ACCEPT === "1") { - Bun.sleepSync(1500); - } + const poll = setInterval(() => { + if (!fs.existsSync("submitted")) return; + clearInterval(poll); + drainTo = Number(fs.readFileSync("submitted", "utf8")); + c.resume(); + reportDrained(); + }, 1); }); server.listen(0, "127.0.0.1", () => { console.log(JSON.stringify({ port: server.address().port })); @@ -3187,9 +3514,15 @@ describe.skipIf(isWindows)("socket write while data is buffered natively", () => // layer while data is still buffered. A _write callback that does not fire // synchronously means the chunk is now buffered natively. const clientFixture = /* js */ ` + import fs from "node:fs"; import net from "node:net"; const phase = process.argv[2]; // "loss" | "dup" const port = Number(process.argv[3]); + // Tells the peer to start reading and to create "drained" after drainTo bytes. + function submitted(drainTo) { + fs.writeFileSync("submitted.tmp", String(drainTo)); + fs.renameSync("submitted.tmp", "submitted"); + } const sock = net.connect(port, "127.0.0.1", () => { sock.setNoDelay(true); const writeDirect = chunk => { @@ -3213,15 +3546,34 @@ describe.skipIf(isWindows)("socket write while data is buffered natively", () => finalChunk = Buffer.alloc(64 * 1024, 0x73); sent.s = finalChunk.length; } else { - // Leave a small (< 1MB) native remainder... - for (let attempt = 0; attempt < 64 && !sawPartial; attempt++) { - const C = Buffer.alloc(1024 * 1024, 0x61); + // Leave a small (< 64KB) native remainder... + const size = 64 * 1024; + for (let attempt = 0; attempt < 1024 && !sawPartial; attempt++) { + const C = Buffer.alloc(size, 0x61); sawPartial = !writeDirect(C); sent.a += C.length; } - // ...then block the loop while the peer drains, so the next writev takes - // the whole remainder plus a prefix of the new chunk (written > buffered.len). - if (sawPartial) Bun.sleepSync(1500); + // ...then block the loop so no writable event flushes the remainder while the + // peer reads all but the last chunk. The kernel buffers are nearly empty by then, + // so the next writev sends the whole remainder plus a prefix of the new chunk + // (written > buffered.len). + if (sawPartial) { + // sent.a includes the partial chunk, so this means the very first write was partial. + if (sent.a === size) { + console.error("precondition failed: the first write was partial, so the peer has nothing to drain first"); + sock.destroy(); + process.exit(3); + } + submitted(sent.a - size); + const deadline = Date.now() + 60_000; + while (!fs.existsSync("drained")) { + if (Date.now() > deadline) { + console.error("the peer did not drain the connection"); + process.exit(4); + } + Bun.sleepSync(1); + } + } finalChunk = Buffer.alloc(32 * 1024 * 1024, 0x69); sent.i = finalChunk.length; } @@ -3236,6 +3588,12 @@ describe.skipIf(isWindows)("socket write while data is buffered natively", () => sock._write(finalChunk, "buffer", () => resolve()); // bytesWritten counts flushed plus still-buffered bytes. sent.bw = sock.bytesWritten; + if (phase === "loss") submitted(sent.a + sent.s); + // The test runner doesn't kill a concurrent test's children when it times out. + setTimeout(() => { + console.error("the final write did not drain"); + process.exit(5); + }, 60_000).unref(); flushed.then(() => { console.log(JSON.stringify(sent)); sock.end(); @@ -3264,7 +3622,7 @@ describe.skipIf(isWindows)("socket write while data is buffered natively", () => // "loss": the writev stops inside the old buffered data, the new chunk must be kept. // "dup": the writev consumes the old data plus a prefix of the new chunk, // that prefix must not be resent. - describe.each(["loss", "dup"] as const)("%s", phase => { + describe.concurrent.each(["loss", "dup"] as const)("%s", phase => { it("a partial writev keeps exactly the unsent suffix", async () => { using dir = tempDir("writev-remainder", { "server-fixture.mjs": serverFixture, @@ -3273,7 +3631,7 @@ describe.skipIf(isWindows)("socket write while data is buffered natively", () => await using server = Bun.spawn({ cmd: [bunExe(), "server-fixture.mjs"], - env: phase === "loss" ? { ...bunEnv, STALL_ON_ACCEPT: "1" } : bunEnv, + env: bunEnv, cwd: String(dir), stdout: "pipe", stderr: "pipe", @@ -3400,7 +3758,7 @@ describe.skipIf(!isWindows)("connect() error codes on Windows", () => { describe("net.Server.listen({ fd })", () => { // node's createServerHandle only accepts TCP / pipe descriptors and reports anything else as EINVAL; // the raw listen(2) failure for a datagram socket is EOPNOTSUPP. - it.skipIf(isWindows)("reports a datagram descriptor as EINVAL, like node", async () => { + it.concurrent.skipIf(isWindows)("reports a datagram descriptor as EINVAL, like node", async () => { await using proc = Bun.spawn({ cmd: [ bunExe(), @@ -3461,6 +3819,20 @@ describe.concurrent("uncaughtException from socket listeners", () => { }); }); + it("client 'connect' listener throw reaches uncaughtException and the socket stays connected", async () => { + expect(await runFixture("connect", { THROW_FROM: "connect" })).toEqual({ + stdout: JSON.stringify(["connect", "uncaught:connect-boom", "server-data:A", "close:false"]), + exitCode: 0, + }); + }); + + it("client 'ready' listener throw reaches uncaughtException and the socket stays connected", async () => { + expect(await runFixture("connect", { THROW_FROM: "ready" })).toEqual({ + stdout: JSON.stringify(["connect", "ready", "uncaught:ready-boom", "server-data:A", "close:false"]), + exitCode: 0, + }); + }); + // https://github.com/oven-sh/bun/issues/34064: the pg pool shape. it("an unhandled 'error' emitted on another emitter from a 'data' listener reaches uncaughtException", async () => { expect(await runFixture("foreign-emitter")).toEqual({ @@ -3604,3 +3976,566 @@ it.each(Object.entries({ setDefaultAutoSelectFamily, setDefaultAutoSelectFamilyA expect(thrown).toEqual(thrownBy(() => set(undefined))); }, ); + +it("setTimeout() fires while connecting with a write queued", async () => { + // A lookup that never answers keeps the socket connecting. + const socket = connect({ host: "stalled.invalid", port: 80, lookup: () => {} }); + try { + socket.setTimeout(1); + socket.write("hello"); + await once(socket, "timeout"); + expect(socket.connecting).toBe(true); + } finally { + socket.destroy(); + } +}); + +describe("setTimeout() with a queued write", () => { + // Returns a client whose last write is stuck behind a peer that isn't reading. + async function connectToPausedPeer() { + const accepted = Promise.withResolvers(); + const server = createServer({ pauseOnConnect: true }, accepted.resolve).listen(0, "127.0.0.1"); + await once(server, "listening"); + const socket = connect((server.address() as any).port, "127.0.0.1"); + await once(socket, "connect"); + const peer = await accepted.promise; + const chunk = Buffer.alloc(1 << 20); + while (socket.write(chunk)) {} + return { + socket, + peer, + [Symbol.dispose]() { + socket.destroy(); + peer.destroy(); + server.close(); + }, + }; + } + + it("fires when the peer stops reading", async () => { + using pair = await connectToPausedPeer(); + pair.socket.setTimeout(10); + await once(pair.socket, "timeout"); + expect(pair.socket.writableLength).toBeGreaterThan(0); + }); + + it("restarts the timer when the write completes", async () => { + using pair = await connectToPausedPeer(); + const { socket, peer } = pair; + let restarts = 0; + // @ts-expect-error + socket._unrefTimer = () => void restarts++; + peer.resume(); + await once(socket, "drain"); + expect(restarts).toBe(1); + }); +}); + +it("emits 'close' after the program replaced setImmediate", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + globalThis.setImmediate = require("node:timers").setImmediate = () => {}; + const net = require("node:net"); + const server = net.createServer(socket => socket.end()).listen(0, "127.0.0.1", () => { + const client = net.connect(server.address().port, "127.0.0.1").resume(); + client.on("close", () => console.log("close")); + }); + server.unref(); + `, + ], + 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: "close\n", stderr: "", exitCode: 0 }); +}); + +// https://github.com/oven-sh/bun/issues/44551 +describe("connect() replaced on the handle", () => { + it.each([ + ["connect", { host: "127.0.0.1", port: 1 }, ["127.0.0.1", 1]], + ["connect6", { host: "::1", port: 1 }, ["::1", 1]], + ["connect", { path: join(socket_domain, "replaced.sock") }, [join(socket_domain, "replaced.sock")]], + ])("%s %o takes over the connection", async (method, options, expected) => { + const socket = new Socket(); + try { + const { promise, resolve, reject } = Promise.withResolvers<{ thisValue: unknown; req: any; args: unknown[] }>(); + socket.on("error", reject); + socket.once("connectionAttempt", () => { + // @ts-expect-error + socket._handle[method] = function (req, ...args) { + resolve({ thisValue: this, req, args }); + }; + }); + socket.connect(options); + const { thisValue, req, args } = await promise; + // @ts-expect-error + expect(thisValue).toBe(socket._handle); + expect(args).toEqual(expected); + expect(socket.connecting).toBe(true); + + const connected = once(socket, "connect"); + // @ts-expect-error + req.oncomplete(0, socket._handle, req, true, true); + await connected; + expect(socket.connecting).toBe(false); + } finally { + socket.destroy(); + } + }); + + it("destroys the socket with the errno it returns", async () => { + const socket = new Socket(); + try { + socket.once("connectionAttempt", () => { + // @ts-expect-error + socket._handle.connect = () => -13; + }); + socket.connect({ host: "127.0.0.1", port: 1 }); + const [error] = await once(socket, "error"); + expect(error).toMatchObject({ code: "EACCES", syscall: "connect", address: "127.0.0.1", port: 1 }); + } finally { + socket.destroy(); + } + }); +}); + +describe("bytesWritten", () => { + it("counts bytes an http2 session writes through the socket", async () => { + const http2 = require("node:http2"); + const server = http2.createServer((req, res) => res.end("ok")).listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address(); + const socket = connect(port, "127.0.0.1"); + const client = http2.connect(`http://127.0.0.1:${port}`, { createConnection: () => socket }); + try { + await once(client.request({ ":path": "/" }).resume(), "close"); + const written = client.socket.bytesWritten; + expect(written).toBeGreaterThan(0); + // @ts-expect-error + expect([socket.bytesWritten, socket._bytesDispatched]).toEqual([written, written]); + } finally { + client.destroy(); + server.close(); + } + }); + + it("still reports the count after the peer resets the connection", async () => { + const closed = Promise.withResolvers(); + const server = createServer(socket => { + socket.on("error", () => {}); + socket.on("close", () => closed.resolve(socket)); + socket.write("hello"); + }).listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const client = connect((server.address() as any).port, "127.0.0.1"); + client.on("data", () => client.resetAndDestroy()); + expect((await closed.promise).bytesWritten).toBe(5); + } finally { + server.close(); + } + }); +}); + +describe("bytesRead", () => { + async function listen(server: Server) { + await once(server.listen(0, "127.0.0.1"), "listening"); + return (server.address() as any).port as number; + } + + it("restarts at 0 when the socket connects again", async () => { + const server = createServer(socket => socket.end("hello!")); + const port = await listen(server); + try { + const socket = new Socket(); + const counts: number[] = []; + for (let i = 0; i < 3; i++) { + socket.connect(port, "127.0.0.1").resume(); + await once(socket, "close"); + counts.push(socket.bytesRead); + } + expect(counts).toEqual([6, 6, 6]); + } finally { + server.close(); + } + }); + + it("counts the bytes an onread callback got, not the ones it declined", async () => { + const server = createServer(socket => socket.end("hello!")); + const port = await listen(server); + try { + const declined = Promise.withResolvers(); + let calls = 0; + const socket = connect({ + port, + host: "127.0.0.1", + // Returning false stops the reads, with "o!" left over. + onread: { buffer: Buffer.alloc(4), callback: () => (declined.resolve(), ++calls > 1) }, + }); + await declined.promise; + expect(socket.bytesRead).toBe(4); + socket.resume(); + await once(socket, "close"); + expect(socket.bytesRead).toBe(6); + } finally { + server.close(); + } + }); + + it("counts bytes an http2 session reads from the socket", async () => { + const http2 = require("node:http2"); + const server = http2.createServer((req, res) => res.end("ok")); + const accepted = once(server, "connection"); + const port = await listen(server); + const socket = connect(port, "127.0.0.1"); + const client = http2.connect(`http://127.0.0.1:${port}`, { createConnection: () => socket }); + try { + await once(client.request({ ":path": "/" }).resume(), "close"); + expect(socket.bytesRead).toBeGreaterThan(0); + expect(client.socket.bytesRead).toBe(socket.bytesRead); + expect((await accepted)[0].bytesRead).toBeGreaterThan(0); + } finally { + client.destroy(); + server.close(); + } + }); +}); + +describe.skipIf(isWindows)("connect() to a unix socket path with no listener", () => { + const path = join(socket_domain, "no-listener.sock"); + + it("stays connecting until the event loop reports the failure", async () => { + const socket = connect(path); + try { + expect({ connecting: socket.connecting, destroyed: socket.destroyed }).toEqual({ + connecting: true, + destroyed: false, + }); + const events: string[] = []; + socket.on("connectionAttemptFailed", () => events.push("connectionAttemptFailed")); + socket.on("error", (error: NodeJS.ErrnoException) => events.push(`error ${error.code}`)); + await new Promise(resolve => socket.on("close", resolve)); + expect(events).toEqual(["connectionAttemptFailed", "error ENOENT"]); + } finally { + socket.destroy(); + } + }); + + it("only emits 'close' when the socket is destroyed first", async () => { + const socket = connect(path); + const events: string[] = []; + socket.on("error", (error: NodeJS.ErrnoException) => events.push(`error ${error.code}`)); + socket.destroy(); + await once(socket, "close"); + await new Promise(resolve => setImmediate(resolve)); + expect(events).toEqual([]); + }); + + it("reports the latest connect() when the socket connects again before the failure", async () => { + await using server = createServer(peer => peer.end()).listen(join(socket_domain, "second-connect.sock")); + await once(server, "listening"); + const socket = connect(path); + try { + socket.connect(server.address() as string); + await once(socket, "connect"); + await once(socket, "close"); + } finally { + socket.destroy(); + } + }); + + it("fails an http request's write callback", async () => { + const req = request({ socketPath: path, method: "POST" }); + const failed = once(req, "error"); + const { promise, resolve } = Promise.withResolvers(); + req.write("hello", resolve); + expect(await promise).toMatchObject({ code: "ERR_SOCKET_CLOSED_BEFORE_CONNECTION" }); + expect((await failed)[0]).toMatchObject({ code: "ENOENT" }); + }); + + it("fails every request queued on an http.Agent once", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const http = require("node:http"); + const agent = new http.Agent({ maxSockets: 1 }); + for (let i = 0; i < 3; i++) { + http + .request({ socketPath: process.argv[1], agent, path: "/" + i }) + .on("error", error => console.log(i, error.code)) + .end(); + } + `, + path, + ], + env: bunEnv, + stdout: "pipe", + stderr: "ignore", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("0 ENOENT\n1 ENOENT\n2 ENOENT\n"); + expect(exitCode).toBe(0); + }); +}); + +it("onread doesn't replay bytes it declined on the previous connection after reconnecting", async () => { + let connections = 0; + await using server = createServer(peer => { + peer.on("error", () => {}); + peer.end(++connections === 1 ? "AAAABBBBCCCC" : "xxxxyyyy"); + }).listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as import("node:net").AddressInfo; + + const received = ["", ""]; + const declined = Promise.withResolvers(); + const socket = connect({ + port, + host: "127.0.0.1", + onread: { + buffer: Buffer.alloc(4), + callback(size, buffer) { + received[connections - 1] += buffer.toString("latin1", 0, size); + if (connections === 1) declined.resolve(); + return connections !== 1; + }, + }, + }); + try { + await declined.promise; + socket.destroy(); + await once(socket, "close"); + + socket.connect({ port, host: "127.0.0.1" }); + socket.resume(); + await once(socket, "close"); + expect(received).toEqual(["AAAA", "xxxxyyyy"]); + } finally { + socket.destroy(); + } +}); + +it.concurrent.each(["client", "accepted"])( + "a peer reset with a write in flight and no 'error' listener throws the read error (%s socket)", + async side => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const net = require("node:net"); + const events = []; + process.on("uncaughtException", err => events.push("uncaught " + err.message)); + function writer(socket) { + socket.on("end", () => events.push("end")); + socket.on("close", () => { + console.log(events.join(", ")); + process.exit(0); + }); + const chunk = Buffer.alloc(1 << 20); + while (socket.write(chunk, err => err && events.push("write " + err.code))) {} + } + function resetter(socket) { + socket.on("error", () => {}); + socket.once("data", () => socket.resetAndDestroy()); + } + const server = net.createServer(${side === "accepted" ? "writer" : "resetter"}).listen(0, "127.0.0.1", () => { + const client = net.connect(server.address().port, "127.0.0.1"); + client.on("connect", () => ${side === "accepted" ? "resetter" : "writer"}(client)); + }); + `, + ], + 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: "uncaught read ECONNRESET, write ECANCELED\n", + stderr: "", + exitCode: 0, + }); + }, +); + +describe("destroy()", () => { + // Returns a connected client and a server that never reads. + async function connectToPausedPeer() { + const peers: Socket[] = []; + const server = createServer({ pauseOnConnect: true }, peer => void peers.push(peer.on("error", () => {}))); + await once(server.listen(0, "127.0.0.1"), "listening"); + const port = (server.address() as any).port as number; + const socket = connect(port, "127.0.0.1"); + await once(socket, "connect"); + return { + socket, + port, + [Symbol.dispose]() { + socket.destroy(); + for (const peer of peers) peer.destroy(); + server.close(); + }, + }; + } + const code = (name: string, events: string[]) => (err?: any) => void events.push(`${name} ${err?.code}`); + // Writes until a write has to wait for the peer. One big write isn't enough: Winsock accepts a first send of any size. + function writeUntilQueued(stream: { write(chunk: Buffer, cb: (err?: any) => void): boolean }, events: string[] = []) { + const chunk = Buffer.alloc(1 << 20); + while (stream.write(chunk, err => err && events.push(`write ${err.code}`))) {} + } + + it("emits 'close' without 'end'", async () => { + using pair = await connectToPausedPeer(); + const events: string[] = []; + pair.socket.on("end", () => events.push("end")); + const finished = (async () => { + for await (const _ of pair.socket); + })(); + const closed = once(pair.socket, "close"); + pair.socket.destroy(); + await expect(finished).rejects.toMatchObject({ code: "ERR_STREAM_PREMATURE_CLOSE" }); + await closed; + expect(events).toEqual([]); + }); + + it("cancels the write in flight after 'error'", async () => { + using pair = await connectToPausedPeer(); + const events: string[] = []; + pair.socket.on("error", code("error", events)); + writeUntilQueued(pair.socket, events); + pair.socket.write("queued", code("queued", events)); + pair.socket.destroy(Object.assign(new Error("boom"), { code: "EBOOM" })); + // Not events.once(), which rejects on 'error'. + await new Promise(resolve => pair.socket.once("close", resolve)); + expect(events).toEqual(["error EBOOM", "write ECANCELED", "queued EBOOM"]); + }); + + it("an http upload cut by req.destroy(err) doesn't emit 'finish'", async () => { + using pair = await connectToPausedPeer(); + const req = require("node:http").request({ port: pair.port, host: "127.0.0.1", method: "POST" }); + const events: string[] = []; + req.on("finish", () => events.push("finish")); + req.on("error", code("error", events)); + await once(req, "socket"); + await once(req.socket, "connect"); + writeUntilQueued(req.socket); + req.end("body"); + req.destroy(Object.assign(new Error("boom"), { code: "EBOOM" })); + await new Promise(resolve => req.once("close", resolve)); + expect(events).toEqual(["error EBOOM"]); + }); + + it("doesn't fail a connect() made right after it with the canceled write", async () => { + using pair = await connectToPausedPeer(); + const events: string[] = []; + pair.socket.on("error", code("error", events)); + writeUntilQueued(pair.socket, events); + pair.socket.destroy(); + pair.socket.connect(pair.port, "127.0.0.1"); + await once(pair.socket, "connect"); + expect(events).toEqual(["write ECANCELED"]); + }); +}); + +describe("typeOfService", () => { + // [value before, value after setTypeOfService(value)], or what was thrown. + const setAndGet = (socket: Socket, value: number) => { + try { + return [socket.getTypeOfService(), socket.setTypeOfService(value).getTypeOfService()]; + } catch (e) { + const { code, syscall } = e as NodeJS.ErrnoException; + return `${code} (${syscall})`; + } + }; + + it("validates the option in the Socket constructor", () => { + const codeFor = (typeOfService: unknown) => { + try { + return new Socket({ typeOfService } as import("node:net").SocketConstructorOpts).getTypeOfService(); + } catch (e) { + return (e as NodeJS.ErrnoException).code; + } + }; + expect([0x10, undefined, 256, -1, 1.5, "16", null].map(codeFor)).toEqual([ + 0x10, + 0, + "ERR_OUT_OF_RANGE", + "ERR_OUT_OF_RANGE", + "ERR_OUT_OF_RANGE", + "ERR_INVALID_ARG_TYPE", + "ERR_INVALID_ARG_TYPE", + ]); + }); + + it("TLSSocket ignores the option", () => { + const socket = new TLSSocket(undefined, { typeOfService: 999 } as import("node:tls").TLSSocketOptions); + expect(socket.getTypeOfService()).toBe(0); + }); + + // Windows often ignores IP_TOS. + it.skipIf(isWindows)("applies the option to a TCP connection", async () => { + const server = createServer(socket => socket.on("error", () => {}).resume()); + await once(server.listen(0, "127.0.0.1"), "listening"); + const { port } = server.address() as import("node:net").AddressInfo; + const client = connect({ port, host: "127.0.0.1", typeOfService: 0x10 }); + try { + await once(client, "connect"); + expect(client.getTypeOfService() & 0xfc).toBe(0x10); + } finally { + client.destroy(); + server.close(); + } + }); + + it.skipIf(isWindows)("caches the value on a unix socket", async () => { + const accepted = Promise.withResolvers(); + const server = createServer(socket => + accepted.resolve( + setAndGet( + socket.on("error", () => {}), + 0x30, + ), + ), + ); + const path = join(socket_domain, "tos.sock"); + await once(server.listen(path), "listening"); + const client = connect(path); + try { + const connecting = setAndGet(client, 0x10); + await once(client, "connect"); + expect({ connecting, connected: setAndGet(client, 0x20), accepted: await accepted.promise }).toEqual({ + connecting: [0, 0x10], + connected: [0x10, 0x20], + accepted: [0, 0x30], + }); + } finally { + client.destroy(); + server.close(); + } + }); + + // undici sets the value before each request, and through an https proxy the request socket is TLS inside TLS. + it("caches the value on a TLS socket over a Duplex", async () => { + const [clientSide, serverSide] = duplexPair(); + const server = new TLSSocket(serverSide, { isServer: true, ...tlsCert }); + const client = tlsConnect({ socket: clientSide, ca: tlsCert.cert, servername: "localhost" }); + try { + const beforeHandshake = setAndGet(client, 1); + await once(client, "secureConnect"); + expect({ beforeHandshake, connected: setAndGet(client, 2) }).toEqual({ + beforeHandshake: [0, 1], + connected: [1, 2], + }); + } finally { + client.destroy(); + server.destroy(); + } + }); +}); diff --git a/test/js/node/net/node-ref-default-fixture.js b/test/js/node/net/node-ref-default-fixture.js index 12a43e41532e..979a88d264cb 100644 --- a/test/js/node/net/node-ref-default-fixture.js +++ b/test/js/node/net/node-ref-default-fixture.js @@ -2,14 +2,15 @@ const { connect } = require("tls"); const socket = connect( { - host: "www.example.com", - port: 443, + host: "127.0.0.1", + port: process.env.PORT, rejectUnauthorized: false, }, () => { socket.on("data", () => { + console.log("Received data."); process.exit(1); }); - socket.write("GET / HTTP/1.1\r\nHost: www.example.com\r\n\r\n\r\n"); + socket.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n\r\n"); }, ); diff --git a/test/js/node/net/node-unref-fixture.js b/test/js/node/net/node-unref-fixture.js index 92f9227f5f8f..04c50882b1ca 100644 --- a/test/js/node/net/node-unref-fixture.js +++ b/test/js/node/net/node-unref-fixture.js @@ -2,8 +2,8 @@ const { connect } = require("tls"); const socket = connect( { - host: "www.example.com", - port: 443, + host: "127.0.0.1", + port: process.env.PORT, rejectUnauthorized: false, }, () => { @@ -11,7 +11,7 @@ const socket = connect( console.error("Received data. FAIL"); process.exit(1); }); - socket.write("GET / HTTP/1.1\r\nHost: www.example.com\r\n\r\n\r\n"); + socket.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n\r\n"); }, ); socket.unref(); diff --git a/test/js/node/net/server.spec.ts b/test/js/node/net/server.spec.ts index 88a31eb9071e..55dc449b0326 100644 --- a/test/js/node/net/server.spec.ts +++ b/test/js/node/net/server.spec.ts @@ -157,6 +157,19 @@ describe("new net.Server()", () => { describe("server.address()", () => { let server: net.Server; + // Some tests check that address() reports the port passed to listen(). A fixed port + // can already be in use, so ask the OS for a free one. + async function getFreePort(host?: string): Promise { + const probe = net.createServer(); + await new Promise((resolve, reject) => { + probe.once("error", reject); + probe.listen({ port: 0, host }, resolve); + }); + const { port } = probe.address() as net.AddressInfo; + await new Promise(resolve => probe.close(() => resolve())); + return port; + } + beforeEach(() => { server = net.createServer(() => {}); }); @@ -191,29 +204,32 @@ describe("server.address()", () => { }); // it("when listening on a specified port, returns an AddressInfo object with the same port", async () => { + const port = await getFreePort(); const { promise, resolve } = Promise.withResolvers(); - server.listen(6543, resolve); + server.listen(port, resolve); await promise; - expect(server.address()).toEqual({ address: "::", port: 6543, family: "IPv6" }); + expect(server.address()).toEqual({ address: "::", port, family: "IPv6" }); }); // FIXME: hostname is not resolved it.skip("when server.listen(port, hostname), returns an AddressInfo object with the same port and hostname", async () => { + const port = await getFreePort("localhost"); const { promise, resolve } = Promise.withResolvers(); - server.listen(1234, "localhost", resolve); + server.listen(port, "localhost", resolve); await promise; expect(server.address()).toEqual({ address: "127.0.0.1", - port: 1234, + port, family: "IPv4", }); }); it("when listening on a specified host and port, returns an AddressInfo object with the same host and port", async () => { + const port = await getFreePort("127.0.0.1"); const { promise, resolve } = Promise.withResolvers(); - server.listen({ port: 1234, host: "127.0.0.1" }, resolve); + server.listen({ port, host: "127.0.0.1" }, resolve); await promise; - expect(server.address()).toEqual({ address: "127.0.0.1", port: 1234, family: "IPv4" }); + expect(server.address()).toEqual({ address: "127.0.0.1", port, family: "IPv4" }); }); }); // diff --git a/test/js/node/net/socketaddress.spec.ts b/test/js/node/net/socketaddress.spec.ts index 4c88cee7ee07..557110395c35 100644 --- a/test/js/node/net/socketaddress.spec.ts +++ b/test/js/node/net/socketaddress.spec.ts @@ -1,7 +1,7 @@ /** * @see https://nodejs.org/api/net.html#class-netsocketaddress */ -import { rss } from "harness"; +import { expectRssDeltaBelow, rss } from "harness"; import { SocketAddress, SocketAddressInitOptions } from "node:net"; let v4: SocketAddress; @@ -133,6 +133,42 @@ describe("SocketAddress constructor", () => { expect(after).toBeLessThanOrEqual(before * growthFactor); }); + + it("does not leak the address string on validation-error paths", async () => { + // Each case reads options.address into a native string and then throws + // from a later validator. The string is 512 KiB, so a leaked ref pins + // about 50 MiB per case over 100 iterations. + const code = /* js */ ` + const net = require("node:net"); + const big = Buffer.alloc(512 * 1024, "a").toString(); + const cases = { + // Options::from_js: a later option validator throws after the address was read + bad_family: i => new net.SocketAddress({ address: big + i, family: "bad!" }), + bad_port: i => new net.SocketAddress({ address: big + i, port: NaN }), + bad_flow_type: i => new net.SocketAddress({ address: big + i, family: "ipv6", flowlabel: "x" }), + bad_flow_range: i => new net.SocketAddress({ address: big + i, family: "ipv6", flowlabel: -1 }), + // init_js: pton rejects an invalid IP after the options were accepted + pton_reject: i => new net.SocketAddress({ address: big + i, family: "ipv4" }), + // init_from_addr_family: AF::from_js throws after the address was read + blocklist_bad_family: i => new net.BlockList().addAddress(big + i, "bad!"), + }; + for (const fn of Object.values(cases)) + for (let i = 0; i < 20; i++) try { fn(i); } catch {} + Bun.gc(true); + const out = {}; + for (const [name, fn] of Object.entries(cases)) { + const before = process.memoryUsage.rss(); + for (let i = 0; i < 100; i++) try { fn(i); } catch {} + Bun.gc(true); + out[name] = (process.memoryUsage.rss() - before) / 1024 / 1024; + } + console.log(JSON.stringify(out)); + console.log(JSON.stringify({ deltaMiB: Math.max(...Object.values(out)) })); + `; + + // Unfixed: about 50 MiB on every path. Fixed: allocator slack only. + await expectRssDeltaBelow(["--smol", "-e", code], { release: 20, debug: 30 }); + }); }); // describe("SocketAddress.isSocketAddress", () => { diff --git a/test/js/node/quic/bundled-root-probe-cert.pem b/test/js/node/quic/bundled-root-probe-cert.pem new file mode 100644 index 000000000000..558f72b828a7 --- /dev/null +++ b/test/js/node/quic/bundled-root-probe-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDKDCCAhCgAwIBAgIUY1bd/XRlBlGwoNczDoU3XT+1mBkwDQYJKoZIhvcNAQEL +BQAwTzELMAkGA1UEBhMCVVMxKTAnBgNVBAoMIEludGVybmV0IFNlY3VyaXR5IFJl +c2VhcmNoIEdyb3VwMRUwEwYDVQQDDAxJU1JHIFJvb3QgWDEwIBcNMjYwMTAxMDAw +MDAwWhgPMjEyNjAxMDEwMDAwMDBaMB0xGzAZBgNVBAMMEmJ1bmRsZWQtcm9vdC1w +cm9iZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANRWMgr7INOCcJPc +LEKE7QTfur1W4d2uUp4ot5DNQlbbJzNJ83Nf/TN8emNj7MpaJ7f3PccImpbG2Ibb +DGI4jxzdapY6/NWZ1YAOWHoR+QiWD4TtULolooMD7NpuaE++e67cnOiAEyexaXry +UJfO4/F15ACYTA22qOuHvgO0z5R3S6Vv/8jGPGjWretgq75pp7FKtqa557qom1ra +uOsHiXwH9tT6PWYN/1dBB9KOj2NGeniGJMV0GXaT6VnOoTYv+uG7oQyMDYiECr/v +EDYxsuj1w5tVSKfqV+ijn4kpGBP0WnbESAM6K37YQD9LqhR8814tJVSqZc5JaVeX +CVv03GsCAwEAAaMsMCowCQYDVR0TBAIwADAdBgNVHREEFjAUghJidW5kbGVkLXJv +b3QtcHJvYmUwDQYJKoZIhvcNAQELBQADggEBAHVVstj9fNLWSt4PjgMGM+mHngvX +CauyVx4bdvwxPTYaYG7Owd2bzLrc/7mojv2zVqArWSyl8Lr5ow5YXEg3Z+bKzdcN +vbLXyDNPqHy6S8S2WktluoneCkr5Y2ScpuN1y/GpxPe7xfhCedS8u+wAHqgTX1UE +fDuiCfOfHYnqBTnogclk90TqmmXM4qerwBdaIxtne6SwB3qgh6NpZWedX0DnvI29 +Tc1FzKh9rIoCwgiCtuAFbyc0Ro6QzEa6qBFo3T1C0Mfod36RxDnMnUe3mFH4hZZF +vWok0/3jNf0XL86xGVi3RdSuLnMiFVfMSb4zvqIRsImJvpV/xHNaqfdzoSc= +-----END CERTIFICATE----- diff --git a/test/js/node/quic/quic-sni.test.ts b/test/js/node/quic/quic-sni.test.ts index 46daf328a78d..d71cb469a026 100644 --- a/test/js/node/quic/quic-sni.test.ts +++ b/test/js/node/quic/quic-sni.test.ts @@ -5,6 +5,7 @@ import { createHash, createPrivateKey, X509Certificate } from "node:crypto"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { connect, listen, QuicEndpoint, type SessionOptions } from "node:quic"; +import { rootCertificates } from "node:tls"; // The vendored Node suite exercises `sni` / `setSNIContexts()` but cannot // observe which certificate was served (both fixtures are self-signed and the @@ -334,6 +335,102 @@ test("connect() with the default verifyPeer refuses an unverifiable certificate }); }); +// The default CA store is built from the environment once per process, so this runs in a child. +test("a context without `ca` verifies against the default CA store", async () => { + // bundled-root-probe-cert.pem names ISRG Root X1 as its issuer, but a throwaway key signed it: + // openssl req -x509 -newkey rsa:2048 -nodes -keyout fake.key -out fake.pem -days 2 \ + // -subj "/C=US/O=Internet Security Research Group/CN=ISRG Root X1" + // openssl req -new -key agent1-key.pem -subj "/CN=bundled-root-probe" -out leaf.csr + // openssl x509 -req -in leaf.csr -CA fake.pem -CAkey fake.key -CAcreateserial -sha256 -extfile ext.cnf \ + // -not_before 20260101000000Z -not_after 21260101000000Z -out bundled-root-probe-cert.pem + // ext.cnf: basicConstraints=CA:FALSE, subjectKeyIdentifier=none, authorityKeyIdentifier=none, + // subjectAltName=DNS:bundled-root-probe + // A store with the bundled roots finds the issuer and fails the signature. A store without them finds no issuer. + expect(rootCertificates.some(pem => new X509Certificate(pem).subject.includes("CN=ISRG Root X1"))).toBe(true); + + const script = ` + import { createPrivateKey } from "node:crypto"; + import { readFileSync } from "node:fs"; + import { join } from "node:path"; + import { connect, listen } from "node:quic"; + + const readKey = name => readFileSync(join(${JSON.stringify(keysDir)}, name)); + const identity = (agent, cert = readKey(agent + "-cert.pem")) => ({ + keys: [createPrivateKey(readKey(agent + "-key.pem"))], + certs: [cert], + }); + const server = await listen( + serverSession => { + serverSession.onerror = () => {}; + serverSession.opened.catch(() => {}); + serverSession.closed.catch(() => {}); + }, + { + sni: { + "*": identity("agent1"), + agent3: identity("agent3"), + "bundled-root-probe": identity("agent1", readFileSync(${JSON.stringify(join(import.meta.dir, "bundled-root-probe-cert.pem"))})), + }, + alpn: ["quic-test"], + }, + ); + + async function verdict(servername, { ca, crl } = {}) { + const session = await connect(server.address, { + alpn: "quic-test", + servername, + ca: ca && [readKey(ca)], + crl: crl && [readKey(crl)], + }); + session.onerror = () => {}; + const outcome = await session.opened.then(() => "verified", e => e.message.match(/\\[(\\w+)\\]$/)?.[1] ?? e.message); + await session.close(); + return outcome; + } + + console.log( + JSON.stringify({ + SSL_CERT_FILE: await verdict("agent1"), + NODE_EXTRA_CA_CERTS: await verdict("agent3"), + bundledRoot: await verdict("bundled-root-probe"), + caReplacesTheDefaults: await verdict("agent3", { ca: "ca1-cert.pem" }), + crl: await verdict("agent3", { crl: "ca2-crl-agent3.pem" }), + nextContextWithoutCrl: await verdict("agent3"), + }), + ); + await server.close(); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: { + ...bunEnv, + SSL_CERT_FILE: join(keysDir, "ca1-cert.pem"), + SSL_CERT_DIR: "", + NODE_EXTRA_CA_CERTS: join(keysDir, "ca2-cert.pem"), + NODE_USE_SYSTEM_CA: undefined, + }, + 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({ + SSL_CERT_FILE: "verified", + NODE_EXTRA_CA_CERTS: "verified", + bundledRoot: "CERT_SIGNATURE_FAILURE", + caReplacesTheDefaults: "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", + crl: "CERT_REVOKED", + nextContextWithoutCrl: "verified", + }), + stderr: expect.stringContaining( + "ExperimentalWarning: quic is an experimental feature and might change at any time", + ), + exitCode: 0, + }); +}); + // https://github.com/oven-sh/bun/issues/32234 test("a client verifies an Ed25519 server certificate", async () => { const fixtures = join(import.meta.dir, "..", "tls", "fixtures"); diff --git a/test/js/node/test/common/index.js b/test/js/node/test/common/index.js index 96a361fd05c0..cfd221c13a2d 100644 --- a/test/js/node/test/common/index.js +++ b/test/js/node/test/common/index.js @@ -1476,6 +1476,11 @@ function installBunExposeInternalsShim() { // tests exercising the > TIMEOUT_MAX clamp use the real threshold. exports: { kTimeout: Symbol.for("::buntimeout::"), TIMEOUT_MAX: 2 ** 31 - 1 }, })); + // BoringSSL has no OpenSSL security levels. Tests only read this to pick between OpenSSL expectations. + build.module("internal/crypto/util", () => ({ + loader: "object", + exports: { getOpenSSLSecLevel: () => 0 }, + })); build.module("internal/webstreams/util", () => ({ loader: "object", exports: { diff --git a/test/js/node/test/parallel/test-child-process-server-close.js b/test/js/node/test/parallel/test-child-process-server-close.js new file mode 100644 index 000000000000..65a4ebb87c1c --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-server-close.js @@ -0,0 +1,40 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { fork, spawn } = require('child_process'); +const net = require('net'); + +const tmpdir = require('../common/tmpdir'); + +// Run in a child process because the PIPE file descriptor stays open until +// Node.js completes, blocking the tmpdir and preventing cleanup. + +if (process.argv[2] !== 'child') { + // Parent + tmpdir.refresh(); + + // Run test + const child = fork(__filename, ['child'], { stdio: 'inherit' }); + child.on('exit', common.mustCall(function(code) { + assert.strictEqual(code, 0); + })); + + return; +} + +// Child +const server = net.createServer(common.mustCall((conn) => { + spawn(process.execPath, ['-v'], { + stdio: ['ignore', conn, 'ignore'] + }).on('close', common.mustCall(() => { + conn.end(); + })); +})).listen(common.PIPE, common.mustCall(() => { + const client = net.connect(common.PIPE, common.mustCall()); + client.once('data', () => { + client.end(() => { + server.close(); + }); + }); +})); diff --git a/test/js/node/test/parallel/test-cluster-send-handle-twice.js b/test/js/node/test/parallel/test-cluster-send-handle-twice.js new file mode 100644 index 000000000000..7064a9c7e18d --- /dev/null +++ b/test/js/node/test/parallel/test-cluster-send-handle-twice.js @@ -0,0 +1,59 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +// Testing to send an handle twice to the primary process. + +const common = require('../common'); +const assert = require('assert'); +const cluster = require('cluster'); +const net = require('net'); + +const workers = { + toStart: 1 +}; + +if (cluster.isPrimary) { + for (let i = 0; i < workers.toStart; ++i) { + const worker = cluster.fork(); + worker.on('exit', common.mustCall(function(code, signal) { + assert.strictEqual(code, 0, `Worker exited with an error code: ${code}`); + assert.strictEqual(signal, null, `Worker exited by a signal: ${signal}`); + })); + } +} else { + const server = net.createServer(common.mustCall((socket) => { + process.send('send-handle-1', socket); + process.send('send-handle-2', socket); + })); + + server.listen(0, common.mustCall(() => { + const client = net.connect({ + host: 'localhost', + port: server.address().port + }); + client.on('close', common.mustCall(() => { cluster.worker.disconnect(); })); + client.on('connect', () => { client.end(); }); + })).on('error', function(e) { + console.error(e); + assert.fail('server.listen failed'); + }); +} diff --git a/test/js/node/test/parallel/test-https-session-reuse-different-servername.js b/test/js/node/test/parallel/test-https-session-reuse-different-servername.js new file mode 100644 index 000000000000..138acd693501 --- /dev/null +++ b/test/js/node/test/parallel/test-https-session-reuse-different-servername.js @@ -0,0 +1,126 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const fixtures = require('../common/fixtures'); +const https = require('https'); +const tls = require('tls'); +const { once } = require('events'); + +function load(name) { + return fixtures.readKey(name); +} + +const contexts = { + agent1: tls.createSecureContext({ + key: load('agent1-key.pem'), + cert: load('agent1-cert.pem'), + }), + agent3: tls.createSecureContext({ + key: load('agent3-key.pem'), + cert: load('agent3-cert.pem'), + }), +}; + +function request(options) { + return new Promise((resolve, reject) => { + const req = https.get({ + ...options, + host: '127.0.0.1', + agent: false, + }, (res) => { + res.resume(); + res.once('end', () => resolve(res)); + }); + + req.once('error', reject); + }); +} + +async function requestAndCaptureSession(options) { + let sessionResolve; + const sessionPromise = new Promise((resolve) => { + sessionResolve = resolve; + }); + + const req = https.get({ + ...options, + host: '127.0.0.1', + agent: false, + }); + + req.on('socket', (socket) => { + socket.once('session', sessionResolve); + }); + + const res = await new Promise((resolve, reject) => { + req.once('response', resolve); + req.once('error', reject); + }); + + assert.strictEqual(res.socket.authorized, true); + assert.strictEqual(res.socket.isSessionReused(), false); + + res.resume(); + await once(res, 'end'); + + const session = await sessionPromise; + assert(session); + return session; +} + +const server = https.createServer({ + key: load('agent1-key.pem'), + cert: load('agent1-cert.pem'), + minVersion: 'TLSv1.2', + maxVersion: 'TLSv1.2', + SNICallback(servername, cb) { + cb(null, contexts[servername] || contexts.agent1); + }, +}, (req, res) => { + res.end('ok'); +}); + +(async function() { + server.listen(0); + await once(server, 'listening'); + + try { + const session = await requestAndCaptureSession({ + port: server.address().port, + servername: 'agent1', + rejectUnauthorized: true, + ca: [load('ca1-cert.pem')], + }); + + await assert.rejects( + request({ + port: server.address().port, + servername: 'agent3', + session, + rejectUnauthorized: true, + ca: [load('ca1-cert.pem')], + }), + { + code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + }, + ); + + await assert.rejects( + request({ + port: server.address().port, + servername: 'agent3', + rejectUnauthorized: true, + ca: [load('ca1-cert.pem')], + }), + { + code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + }, + ); + } finally { + server.close(); + await once(server, 'close'); + } +})().then(common.mustCall()); diff --git a/test/js/node/test/parallel/test-https-timeout-server-2.js b/test/js/node/test/parallel/test-https-timeout-server-2.js new file mode 100644 index 000000000000..4f947039bbe8 --- /dev/null +++ b/test/js/node/test/parallel/test-https-timeout-server-2.js @@ -0,0 +1,54 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const fixtures = require('../common/fixtures'); + +const assert = require('assert'); +const https = require('https'); +const tls = require('tls'); + +const options = { + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem') +}; + +const server = https.createServer(options, common.mustNotCall()); + +server.on('secureConnection', common.mustCall((cleartext) => { + const s = cleartext.setTimeout(50, function() { + cleartext.destroy(); + server.close(); + }); + assert.ok(s instanceof tls.TLSSocket); +})); + +server.listen(0, function() { + tls.connect({ + host: '127.0.0.1', + port: this.address().port, + rejectUnauthorized: false + }); +}); diff --git a/test/js/node/test/parallel/test-net-connect-memleak.js b/test/js/node/test/parallel/test-net-connect-memleak.js new file mode 100644 index 000000000000..de925f5d08c4 --- /dev/null +++ b/test/js/node/test/parallel/test-net-connect-memleak.js @@ -0,0 +1,58 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +// Flags: --expose-gc + +const common = require('../common'); +const { onGC } = require('../common/gc'); +const assert = require('assert'); +const net = require('net'); + +// Test that the implicit listener for an 'connect' event on net.Sockets is +// added using `once()`, i.e. can be gc'ed once that event has occurred. + +const server = net.createServer(common.mustCall()).listen(0); + +let collected = false; +const gcListener = { ongc() { collected = true; } }; + +{ + const gcObject = {}; + onGC(gcObject, gcListener); + + const sock = net.createConnection( + server.address().port, + common.mustCall(() => { + assert.strictEqual(gcObject, gcObject); // Keep reference alive + assert.strictEqual(collected, false); + setImmediate(done, sock); + })); +} + +function done(sock) { + globalThis.gc(); + setImmediate(common.mustCall(() => { + assert.strictEqual(collected, true); + sock.end(); + server.close(); + })); +} diff --git a/test/js/node/test/parallel/test-net-onread-static-buffer.js b/test/js/node/test/parallel/test-net-onread-static-buffer.js new file mode 100644 index 000000000000..7433ea4fe512 --- /dev/null +++ b/test/js/node/test/parallel/test-net-onread-static-buffer.js @@ -0,0 +1,186 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +const message = Buffer.from('hello world'); + +// Test typical usage +net.createServer(common.mustCall(function(socket) { + this.close(); + socket.end(message); +})).listen(0, common.mustCall(function() { + let received = 0; + const buffers = []; + const sockBuf = Buffer.alloc(8); + net.connect({ + port: this.address().port, + onread: { + buffer: sockBuf, + callback: common.mustCallAtLeast(function(nread, buf) { + assert.strictEqual(buf, sockBuf); + received += nread; + buffers.push(Buffer.from(buf.slice(0, nread))); + }) + } + }).on('data', common.mustNotCall()).on('end', common.mustCall(() => { + assert.strictEqual(received, message.length); + assert.deepStrictEqual(Buffer.concat(buffers), message); + })); +})); + +// Test Uint8Array support +net.createServer(common.mustCall(function(socket) { + this.close(); + socket.end(message); +})).listen(0, common.mustCall(function() { + let received = 0; + let incoming = new Uint8Array(0); + const sockBuf = new Uint8Array(8); + net.connect({ + port: this.address().port, + onread: { + buffer: sockBuf, + callback: common.mustCallAtLeast(function(nread, buf) { + assert.strictEqual(buf, sockBuf); + received += nread; + const newIncoming = new Uint8Array(incoming.length + nread); + newIncoming.set(incoming); + newIncoming.set(buf.slice(0, nread), incoming.length); + incoming = newIncoming; + }) + } + }).on('data', common.mustNotCall()).on('end', common.mustCall(() => { + assert.strictEqual(received, message.length); + assert.deepStrictEqual(incoming, new Uint8Array(message)); + })); +})); + +// Test Buffer callback usage +net.createServer(common.mustCall(function(socket) { + this.close(); + socket.end(message); +})).listen(0, common.mustCall(function() { + let received = 0; + const incoming = []; + const bufPool = [ Buffer.alloc(2), Buffer.alloc(2), Buffer.alloc(2) ]; + let bufPoolIdx = -1; + let bufPoolUsage = 0; + net.connect({ + port: this.address().port, + onread: { + buffer: () => { + ++bufPoolUsage; + bufPoolIdx = (bufPoolIdx + 1) % bufPool.length; + return bufPool[bufPoolIdx]; + }, + callback: common.mustCallAtLeast(function(nread, buf) { + assert.strictEqual(buf, bufPool[bufPoolIdx]); + received += nread; + incoming.push(Buffer.from(buf.slice(0, nread))); + }) + } + }).on('data', common.mustNotCall()).on('end', common.mustCall(() => { + assert.strictEqual(received, message.length); + assert.deepStrictEqual(Buffer.concat(incoming), message); + assert.strictEqual(bufPoolUsage, 7); + })); +})); + +// Test Uint8Array callback support +net.createServer(common.mustCall(function(socket) { + this.close(); + socket.end(message); +})).listen(0, common.mustCall(function() { + let received = 0; + let incoming = new Uint8Array(0); + const bufPool = [ new Uint8Array(2), new Uint8Array(2), new Uint8Array(2) ]; + let bufPoolIdx = -1; + let bufPoolUsage = 0; + net.connect({ + port: this.address().port, + onread: { + buffer: () => { + ++bufPoolUsage; + bufPoolIdx = (bufPoolIdx + 1) % bufPool.length; + return bufPool[bufPoolIdx]; + }, + callback: common.mustCallAtLeast(function(nread, buf) { + assert.strictEqual(buf, bufPool[bufPoolIdx]); + received += nread; + const newIncoming = new Uint8Array(incoming.length + nread); + newIncoming.set(incoming); + newIncoming.set(buf.slice(0, nread), incoming.length); + incoming = newIncoming; + }) + } + }).on('data', common.mustNotCall()).on('end', common.mustCall(() => { + assert.strictEqual(received, message.length); + assert.deepStrictEqual(incoming, new Uint8Array(message)); + assert.strictEqual(bufPoolUsage, 7); + })); +})); + +// Test explicit socket pause +net.createServer(common.mustCall(function(socket) { + this.close(); + socket.end(message); +})).listen(0, common.mustCall(function() { + let received = 0; + const buffers = []; + const sockBuf = Buffer.alloc(8); + let paused = false; + net.connect({ + port: this.address().port, + onread: { + buffer: sockBuf, + callback: common.mustCallAtLeast(function(nread, buf) { + assert.strictEqual(paused, false); + assert.strictEqual(buf, sockBuf); + received += nread; + buffers.push(Buffer.from(buf.slice(0, nread))); + paused = true; + this.pause(); + setTimeout(() => { + paused = false; + this.resume(); + }, 100); + }) + } + }).on('data', common.mustNotCall()).on('end', common.mustCall(() => { + assert.strictEqual(received, message.length); + assert.deepStrictEqual(Buffer.concat(buffers), message); + })); +})); + +// Test implicit socket pause +net.createServer(common.mustCall(function(socket) { + this.close(); + socket.end(message); +})).listen(0, common.mustCall(function() { + let received = 0; + const buffers = []; + const sockBuf = Buffer.alloc(8); + let paused = false; + net.connect({ + port: this.address().port, + onread: { + buffer: sockBuf, + callback: common.mustCallAtLeast(function(nread, buf) { + assert.strictEqual(paused, false); + assert.strictEqual(buf, sockBuf); + received += nread; + buffers.push(Buffer.from(buf.slice(0, nread))); + paused = true; + setTimeout(() => { + paused = false; + this.resume(); + }, 100); + return false; + }) + } + }).on('data', common.mustNotCall()).on('end', common.mustCall(() => { + assert.strictEqual(received, message.length); + assert.deepStrictEqual(Buffer.concat(buffers), message); + })); +})); diff --git a/test/js/node/test/parallel/test-tls-client-default-ciphers.js b/test/js/node/test/parallel/test-tls-client-default-ciphers.js new file mode 100644 index 000000000000..1596c8e981fb --- /dev/null +++ b/test/js/node/test/parallel/test-tls-client-default-ciphers.js @@ -0,0 +1,44 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); + +function Done() {} + +function test1() { + let ciphers = ''; + + tls.createSecureContext = function(options) { + ciphers = options.ciphers; + throw new Done(); + }; + + assert.throws(tls.connect, Done); + + assert.strictEqual(ciphers, tls.DEFAULT_CIPHERS); +} +test1(); diff --git a/test/js/node/test/parallel/test-tls-client-mindhsize.js b/test/js/node/test/parallel/test-tls-client-mindhsize.js new file mode 100644 index 000000000000..fa494c583a2f --- /dev/null +++ b/test/js/node/test/parallel/test-tls-client-mindhsize.js @@ -0,0 +1,130 @@ +// Flags: --expose-internals +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +// OpenSSL has a set of security levels which affect what algorithms +// are available by default. Different OpenSSL veresions have different +// default security levels and we use this value to adjust what a test +// expects based on the security level. You can read more in +// https://docs.openssl.org/1.1.1/man3/SSL_CTX_set_security_level/#default-callback-behaviour +const secLevel = require('internal/crypto/util').getOpenSSLSecLevel(); +const assert = require('assert'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); +const { hasOpenSSL } = require('../common/crypto'); + +const key = fixtures.readKey('agent2-key.pem'); +const cert = fixtures.readKey('agent2-cert.pem'); + +let nsuccess = 0; +let nerror = 0; + +function loadDHParam(n) { + return fixtures.readKey(`dh${n}.pem`); +} + +function test(size, err, next, minDHSizeOverride) { + const options = { + key: key, + cert: cert, + dhparam: loadDHParam(size), + ciphers: 'DHE-RSA-AES128-GCM-SHA256' + }; + + const server = tls.createServer(options, function(conn) { + conn.end(); + }); + + server.on('close', common.mustCall(function(isException) { + assert(!isException); + if (next) next(); + })); + + server.listen(0, common.mustCall(function() { + // Client set minimum DH parameter size to 2048 or 3072 bits + // so that it fails when it makes a connection to the tls + // server where is too small. This depends on the openssl + // security level + const minDHSize = minDHSizeOverride ?? ((secLevel > 1) ? 3072 : 2048); + const client = tls.connect({ + minDHSize: minDHSize, + port: this.address().port, + rejectUnauthorized: false, + maxVersion: 'TLSv1.2', + }, function() { + nsuccess++; + server.close(); + }); + if (err) { + client.on('error', common.mustCall((e) => { + nerror++; + assert.strictEqual(e.code, 'ERR_TLS_DH_PARAM_SIZE'); + server.close(); + })); + } + })); +} + +// A client connection fails with an error when a client has an +// 2048 bits minDHSize option and a server has 1024 bits dhparam +function testDHE1024() { + test(1024, true, testDHE2048(false, null)); +} + +// Test a client connection when a client has an +// 2048 bits minDHSize option +function testDHE2048(expect_to_fail, next) { + test(2048, expect_to_fail, next); +} + +// A client connection successes when a client has an +// 3072 bits minDHSize option and a server has 3072 bits dhparam +function testDHE3072() { + test(3072, false, null); +} + +if (!process.features.openssl_is_boringssl) { + if (hasOpenSSL(4, 0)) { + // OpenSSL 4.0 implements RFC 7919 FFDHE negotiation for TLS 1.2 and + // ignores the server-supplied dhparam in favor of FFDHE-2048. The 3072 + // success case is therefore replaced by a 2048 success case. + testDHE2048(true, () => test(2048, false, null, 2048)); + } else if (secLevel > 1) { + // Minimum size for OpenSSL security level 2 and above is 2048 by default + testDHE2048(true, testDHE3072); + } else { + testDHE1024(); + } + + assert.throws(() => test(512, true, common.mustNotCall()), + /DH parameter is less than 1024 bits/); +} else { + require('../common/boringssl').assertFiniteFieldDheUnsupported(); +} + +for (const minDHSize of [0, -1, -Infinity, NaN]) { + assert.throws(() => { + tls.connect({ minDHSize }); + }, { + code: 'ERR_OUT_OF_RANGE', + name: 'RangeError', + }); +} + +for (const minDHSize of [true, false, null, undefined, {}, [], '', '1']) { + assert.throws(() => { + tls.connect({ minDHSize }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + }); +} + +if (!process.features.openssl_is_boringssl) { + process.on('exit', function() { + assert.strictEqual(nsuccess, 1); + assert.strictEqual(nerror, 1); + }); +} diff --git a/test/js/node/test/parallel/test-tls-onread-static-buffer.js b/test/js/node/test/parallel/test-tls-onread-static-buffer.js new file mode 100644 index 000000000000..beb7084150b5 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-onread-static-buffer.js @@ -0,0 +1,206 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +const options = { + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem') +}; + +const smallMessage = Buffer.from('hello world'); +// Used to test .pause(), so needs to be larger than the internal buffer +const largeMessage = Buffer.alloc(64 * 1024).fill('hello world'); + +// Test typical usage +tls.createServer(options, common.mustCall(function(socket) { + this.close(); + socket.end(smallMessage); +})).listen(0, common.mustCall(function() { + let received = 0; + const buffers = []; + const sockBuf = Buffer.alloc(8); + tls.connect({ + port: this.address().port, + rejectUnauthorized: false, + onread: { + buffer: sockBuf, + callback: common.mustCallAtLeast((nread, buf) => { + assert.strictEqual(buf, sockBuf); + received += nread; + buffers.push(Buffer.from(buf.slice(0, nread))); + }) + } + }).on('data', common.mustNotCall()).on('end', common.mustCall(() => { + assert.strictEqual(received, smallMessage.length); + assert.deepStrictEqual(Buffer.concat(buffers), smallMessage); + })); +})); + +// Test Uint8Array support +tls.createServer(options, common.mustCall(function(socket) { + this.close(); + socket.end(smallMessage); +})).listen(0, common.mustCall(function() { + let received = 0; + let incoming = new Uint8Array(0); + const sockBuf = new Uint8Array(8); + tls.connect({ + port: this.address().port, + rejectUnauthorized: false, + onread: { + buffer: sockBuf, + callback: common.mustCallAtLeast((nread, buf) => { + assert.strictEqual(buf, sockBuf); + received += nread; + const newIncoming = new Uint8Array(incoming.length + nread); + newIncoming.set(incoming); + newIncoming.set(buf.slice(0, nread), incoming.length); + incoming = newIncoming; + }) + } + }).on('data', common.mustNotCall()).on('end', common.mustCall(() => { + assert.strictEqual(received, smallMessage.length); + assert.deepStrictEqual(incoming, new Uint8Array(smallMessage)); + })); +})); + +// Test Buffer callback usage +tls.createServer(options, common.mustCall(function(socket) { + this.close(); + socket.end(smallMessage); +})).listen(0, common.mustCall(function() { + let received = 0; + const incoming = []; + const bufPool = [ Buffer.alloc(2), Buffer.alloc(2), Buffer.alloc(2) ]; + let bufPoolIdx = -1; + let bufPoolUsage = 0; + tls.connect({ + port: this.address().port, + rejectUnauthorized: false, + onread: { + buffer: () => { + ++bufPoolUsage; + bufPoolIdx = (bufPoolIdx + 1) % bufPool.length; + return bufPool[bufPoolIdx]; + }, + callback: common.mustCallAtLeast((nread, buf) => { + assert.strictEqual(buf, bufPool[bufPoolIdx]); + received += nread; + incoming.push(Buffer.from(buf.slice(0, nread))); + }) + } + }).on('data', common.mustNotCall()).on('end', common.mustCall(() => { + assert.strictEqual(received, smallMessage.length); + assert.deepStrictEqual(Buffer.concat(incoming), smallMessage); + assert.strictEqual(bufPoolUsage, 7); + })); +})); + +// Test Uint8Array callback support +tls.createServer(options, common.mustCall(function(socket) { + this.close(); + socket.end(smallMessage); +})).listen(0, common.mustCall(function() { + let received = 0; + let incoming = new Uint8Array(0); + const bufPool = [ new Uint8Array(2), new Uint8Array(2), new Uint8Array(2) ]; + let bufPoolIdx = -1; + let bufPoolUsage = 0; + tls.connect({ + port: this.address().port, + rejectUnauthorized: false, + onread: { + buffer: () => { + ++bufPoolUsage; + bufPoolIdx = (bufPoolIdx + 1) % bufPool.length; + return bufPool[bufPoolIdx]; + }, + callback: common.mustCallAtLeast((nread, buf) => { + assert.strictEqual(buf, bufPool[bufPoolIdx]); + received += nread; + const newIncoming = new Uint8Array(incoming.length + nread); + newIncoming.set(incoming); + newIncoming.set(buf.slice(0, nread), incoming.length); + incoming = newIncoming; + }), + } + }).on('data', common.mustNotCall()).on('end', common.mustCall(() => { + assert.strictEqual(received, smallMessage.length); + assert.deepStrictEqual(incoming, new Uint8Array(smallMessage)); + assert.strictEqual(bufPoolUsage, 7); + })); +})); + +// Test explicit socket pause +tls.createServer(options, common.mustCall(function(socket) { + this.close(); + // Need larger message here to observe the pause + socket.end(largeMessage); +})).listen(0, common.mustCall(function() { + let received = 0; + const buffers = []; + const sockBuf = Buffer.alloc(64); + let pauseScheduled = false; + const client = tls.connect({ + port: this.address().port, + rejectUnauthorized: false, + onread: { + buffer: sockBuf, + callback: common.mustCallAtLeast((nread, buf) => { + assert.strictEqual(buf, sockBuf); + received += nread; + buffers.push(Buffer.from(buf.slice(0, nread))); + if (!pauseScheduled) { + pauseScheduled = true; + client.pause(); + setTimeout(() => { + client.resume(); + }, 100); + } + }), + } + }).on('data', common.mustNotCall()).on('end', common.mustCall(() => { + assert.strictEqual(received, largeMessage.length); + assert.deepStrictEqual(Buffer.concat(buffers), largeMessage); + })); +})); + +// Test implicit socket pause +tls.createServer(options, common.mustCall(function(socket) { + this.close(); + // Need larger message here to observe the pause + socket.end(largeMessage); +})).listen(0, common.mustCall(function() { + let received = 0; + const buffers = []; + const sockBuf = Buffer.alloc(64); + let pauseScheduled = false; + const client = tls.connect({ + port: this.address().port, + rejectUnauthorized: false, + onread: { + buffer: sockBuf, + callback: common.mustCallAtLeast((nread, buf) => { + assert.strictEqual(buf, sockBuf); + received += nread; + buffers.push(Buffer.from(buf.slice(0, nread))); + if (!pauseScheduled) { + pauseScheduled = true; + setTimeout(() => { + client.resume(); + }, 100); + return false; + } + return true; + }), + } + }).on('data', common.mustNotCall()).on('end', common.mustCall(() => { + assert.strictEqual(received, largeMessage.length); + assert.deepStrictEqual(Buffer.concat(buffers), largeMessage); + })); +})); diff --git a/test/js/node/test/parallel/test-tls-server-parent-constructor-options.js b/test/js/node/test/parallel/test-tls-server-parent-constructor-options.js new file mode 100644 index 000000000000..f8b34e8b0cab --- /dev/null +++ b/test/js/node/test/parallel/test-tls-server-parent-constructor-options.js @@ -0,0 +1,68 @@ +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +// Test that `tls.Server` constructor options are passed to the parent +// constructor. + +const assert = require('assert'); +const fixtures = require('../common/fixtures'); +const tls = require('tls'); + +const options = { + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem'), +}; + +{ + const server = tls.createServer(options, common.mustCall((socket) => { + assert.strictEqual(socket.allowHalfOpen, false); + assert.strictEqual(socket.isPaused(), false); + })); + + assert.strictEqual(server.allowHalfOpen, false); + assert.strictEqual(server.pauseOnConnect, false); + + server.listen(0, common.mustCall(() => { + const socket = tls.connect({ + port: server.address().port, + rejectUnauthorized: false + }, common.mustCall(() => { + socket.end(); + })); + + socket.on('close', () => { + server.close(); + }); + })); +} + +{ + const server = tls.createServer({ + allowHalfOpen: true, + pauseOnConnect: true, + ...options + }, common.mustCall((socket) => { + assert.strictEqual(socket.allowHalfOpen, true); + assert.strictEqual(socket.isPaused(), true); + socket.on('end', socket.end); + })); + + assert.strictEqual(server.allowHalfOpen, true); + assert.strictEqual(server.pauseOnConnect, true); + + server.listen(0, common.mustCall(() => { + const socket = tls.connect({ + port: server.address().port, + rejectUnauthorized: false + }, common.mustCall(() => { + socket.end(); + })); + + socket.on('close', () => { + server.close(); + }); + })); +} diff --git a/test/js/node/test/parallel/test-tls-server-setkeycert.js b/test/js/node/test/parallel/test-tls-server-setkeycert.js new file mode 100644 index 000000000000..6d9f619a1be8 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-server-setkeycert.js @@ -0,0 +1,56 @@ +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { X509Certificate } = require('crypto'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +const altKeyCert = { + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem'), + minVersion: 'TLSv1.2', +}; + +const altKeyCertVals = [ + altKeyCert, + tls.createSecureContext(altKeyCert), +]; + +(function next() { + if (!altKeyCertVals.length) + return; + const altKeyCertVal = altKeyCertVals.shift(); + const options = { + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem'), + minVersion: 'TLSv1.3', + ALPNCallback: common.mustCall(function({ servername, protocols }) { + this.setKeyCert(altKeyCertVal); + assert.deepStrictEqual(protocols, ['acme-tls/1']); + return protocols[0]; + }), + }; + + tls.createServer(options, (s) => s.end()).listen(0, common.mustCall(function() { + this.on('connection', common.mustCall((socket) => this.close())); + + tls.connect({ + port: this.address().port, + rejectUnauthorized: false, + ALPNProtocols: ['acme-tls/1'], + }, common.mustCall(function() { + assert.strictEqual(this.getProtocol(), 'TLSv1.3'); + const altCert = new X509Certificate(altKeyCert.cert); + assert.strictEqual( + this.getPeerX509Certificate().raw.equals(altCert.raw), + true + ); + this.end(); + next(); + })); + })); +})(); diff --git a/test/js/node/test/parallel/test-tls-set-default-ca-certificates-append-fetch.mjs b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-append-fetch.mjs new file mode 100644 index 000000000000..ca8436b22b0a --- /dev/null +++ b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-append-fetch.mjs @@ -0,0 +1,54 @@ +// Flags: --no-use-system-ca + + +// This tests appending certificates to existing defaults should work correctly +// with fetch. + +import * as common from '../common/index.mjs'; +import { once } from 'node:events'; +import * as fixtures from '../common/fixtures.mjs'; +import assert from 'node:assert'; + +if (!common.hasCrypto) common.skip('missing crypto'); + +const { includesCert } = await import('../common/tls.js'); +const { default: https } = await import('node:https'); +const { default: tls } = await import('node:tls'); + +const bundledCerts = tls.getCACertificates('bundled'); +const fixtureCert = fixtures.readKey('fake-startcom-root-cert.pem'); +if (includesCert(bundledCerts, fixtureCert)) { + common.skip('fake-startcom-root-cert is already in bundled certificates, skipping test'); +} + +// Test HTTPS connection fails with bundled CA, succeeds after adding custom CA +const server = https.createServer({ + cert: fixtures.readKey('agent8-cert.pem'), + key: fixtures.readKey('agent8-key.pem'), +}, common.mustCall((req, res) => { + res.writeHead(200); + res.end('hello world'); +}, 1)); +server.listen(0); +await once(server, 'listening'); +const url = `https://localhost:${server.address().port}/hello-world`; + +// First attempt should fail without custom CA. +await assert.rejects( + fetch(url), + (err) => { + assert.strictEqual(err.cause.code, 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'); + return true; + }, +); + +// Now enable custom CA certificate. +tls.setDefaultCACertificates([fixtureCert]); + +// Second attempt should succeed. +const response = await fetch(url); +assert.strictEqual(response.status, 200); +const text = await response.text(); +assert.strictEqual(text, 'hello world'); + +server.close(); diff --git a/test/js/node/test/parallel/test-tls-set-default-ca-certificates-reset-fetch.mjs b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-reset-fetch.mjs new file mode 100644 index 000000000000..479b415d4c51 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-reset-fetch.mjs @@ -0,0 +1,47 @@ +// Flags: --no-use-system-ca + + +// This tests appending certificates to existing defaults should work correctly +// with fetch. + +import * as common from '../common/index.mjs'; +import { once } from 'node:events'; +import * as fixtures from '../common/fixtures.mjs'; +import assert from 'node:assert'; + +if (!common.hasCrypto) common.skip('missing crypto'); + +const { default: https } = await import('node:https'); +const { default: tls } = await import('node:tls'); + +// Test HTTPS connection fails with bundled CA, succeeds after adding custom CA. +const server = https.createServer({ + cert: fixtures.readKey('agent8-cert.pem'), + key: fixtures.readKey('agent8-key.pem'), +}, common.mustCall((req, res) => { + res.writeHead(200); + res.end('hello world'); +}, 1)); +server.listen(0); +await once(server, 'listening'); + +const fixturesCert = fixtures.readKey('fake-startcom-root-cert.pem'); +tls.setDefaultCACertificates([fixturesCert]); +// First, verify connection works with custom CA. +const response1 = await fetch(`https://localhost:${server.address().port}/custom-ca-test`); +assert.strictEqual(response1.status, 200); +const text1 = await response1.text(); +assert.strictEqual(text1, 'hello world'); + +// Now set empty CA store - connection should fail. +tls.setDefaultCACertificates([]); +// Use IP address to skip session cache. +await assert.rejects( + fetch(`https://127.0.0.1:${server.address().port}/empty-ca-test`), + (err) => { + assert.strictEqual(err.cause.code, 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'); + return true; + }, +); + +server.close(); diff --git a/test/js/node/test/parallel/test-tls-socket-snicallback-without-server.js b/test/js/node/test/parallel/test-tls-socket-snicallback-without-server.js new file mode 100644 index 000000000000..e278b4975ad5 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-socket-snicallback-without-server.js @@ -0,0 +1,23 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); +const { duplexPair } = require('stream'); + +const [ clientSide, serverSide ] = duplexPair(); +new tls.TLSSocket(serverSide, { + isServer: true, + SNICallback: common.mustCall((servername, cb) => { + assert.strictEqual(servername, 'www.google.com'); + }) +}); + +// Captured traffic from browser's request to https://www.google.com +const sslHello = fixtures.readSync('google_ssl_hello.bin'); + +clientSide.write(sslHello); diff --git a/test/js/node/test/parallel/test-tls-ticket-12.js b/test/js/node/test/parallel/test-tls-ticket-12.js new file mode 100644 index 000000000000..600c571a03be --- /dev/null +++ b/test/js/node/test/parallel/test-tls-ticket-12.js @@ -0,0 +1,12 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +// Run test-tls-ticket.js with TLS1.2 + +const tls = require('tls'); + +tls.DEFAULT_MAX_VERSION = 'TLSv1.2'; + +require('./test-tls-ticket.js'); diff --git a/test/js/node/test/parallel/test-tls-writewrap-leak.js b/test/js/node/test/parallel/test-tls-writewrap-leak.js new file mode 100644 index 000000000000..7035764cba53 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-writewrap-leak.js @@ -0,0 +1,24 @@ +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const net = require('net'); +const tls = require('tls'); + +const server = net.createServer(common.mustCall((c) => { + c.destroy(); +})).listen(0, common.mustCall(() => { + const c = tls.connect({ port: server.address().port }); + c.on('error', () => { + // Otherwise `.write()` callback won't be invoked. + c._undestroy(); + }); + + c.write('hello', common.mustCall((err) => { + assert.strictEqual(err.code, 'ECANCELED'); + server.close(); + })); +})); diff --git a/test/js/node/tls/default-ca-native-clients.test.ts b/test/js/node/tls/default-ca-native-clients.test.ts new file mode 100644 index 000000000000..280616cbeaf0 --- /dev/null +++ b/test/js/node/tls/default-ca-native-clients.test.ts @@ -0,0 +1,171 @@ +// fetch and WebSocket are built natively, so tls.setDefaultCACertificates() has to reach them as it does node:tls. +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tls as serverCert, tempDir } from "harness"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const fixture = (name: string) => readFileSync(join(import.meta.dir, "fixtures", name), "utf8"); +const otherCA = fixture("ca1-cert.pem"); + +const prelude = /* js */ ` + import tls from "node:tls"; + const { good, other, key, clientCert, clientKey } = JSON.parse(process.env.CERTS); + const server = Bun.serve({ + port: 0, + tls: { cert: good, key }, + fetch(req, srv) { + return srv.upgrade(req) ? undefined : new Response("ok"); + }, + websocket: { message() {} }, + }); + const viaFetch = (opts, url = server.url) => + fetch(url, opts).then(r => r.text()).then(() => "OK", e => e.code); + const viaWs = opts => { + const { promise, resolve } = Promise.withResolvers(); + const ws = new WebSocket(server.url.href.replace("https", "wss"), opts); + ws.onopen = () => { ws.close(); resolve("OK"); }; + ws.onerror = () => resolve("REFUSED"); + return promise; + }; + const report = async () => ({ fetch: await viaFetch(), ws: await viaWs() }); +`; + +async function run(body: string, extraEnv?: { SSL_CERT_FILE: "good" }) { + using dir = tempDir("default-ca-native-clients", { + "run.mjs": prelude + body + "\nprocess.exit(0);", + "good.pem": serverCert.cert, + }); + const { SSL_CERT_FILE, SSL_CERT_DIR, NODE_EXTRA_CA_CERTS, ...env } = bunEnv as Record; + await using proc = Bun.spawn({ + cmd: [bunExe(), "run.mjs"], + env: { + ...env, + ...(extraEnv && { SSL_CERT_FILE: join(String(dir), "good.pem") }), + CERTS: JSON.stringify({ + good: serverCert.cert, + other: otherCA, + key: serverCert.key, + clientCert: fixture("agent1-cert.pem"), + clientKey: fixture("agent1-key.pem"), + }), + }, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + return JSON.parse(stdout); +} + +const refused = { fetch: "DEPTH_ZERO_SELF_SIGNED_CERT", ws: "REFUSED" }; + +test.concurrent("the default list is trusted, and not before it is set", async () => { + const result = await run(` + const before = await report(); + tls.setDefaultCACertificates([good]); + console.log(JSON.stringify({ before, after: await report() })); + `); + expect(result).toEqual({ before: refused, after: { fetch: "OK", ws: "OK" } }); +}); + +test.concurrent("replacing the list refuses new connections", async () => { + const result = await run(` + tls.setDefaultCACertificates([good]); + const first = await report(); + tls.setDefaultCACertificates([other]); + console.log(JSON.stringify({ first, second: await report() })); + `); + expect(result).toEqual({ first: { fetch: "OK", ws: "OK" }, second: refused }); +}); + +test.concurrent("an empty list trusts nothing", async () => { + const result = await run( + ` + tls.setDefaultCACertificates([]); + const viaTls = () => { + const { promise, resolve } = Promise.withResolvers(); + const socket = tls.connect({ port: server.port, host: "localhost", ca: undefined }); + socket.on("secureConnect", () => { socket.destroy(); resolve("OK"); }); + socket.on("error", e => resolve(e.code)); + return promise; + }; + console.log(JSON.stringify({ ...(await report()), tls: await viaTls() })); + `, + { SSL_CERT_FILE: "good" }, + ); + expect(result).toEqual({ + fetch: "DEPTH_ZERO_SELF_SIGNED_CERT", + ws: "REFUSED", + tls: "DEPTH_ZERO_SELF_SIGNED_CERT", + }); +}); + +test.concurrent("an empty default also covers an empty ca beside other options", async () => { + const result = await run( + ` + tls.setDefaultCACertificates([]); + const opts = { tls: { ca: [], serverName: "localhost" } }; + console.log(JSON.stringify({ fetch: await viaFetch(opts), ws: await viaWs(opts) })); + `, + { SSL_CERT_FILE: "good" }, + ); + expect(result).toEqual({ fetch: "DEPTH_ZERO_SELF_SIGNED_CERT", ws: "REFUSED" }); +}); + +test.concurrent("a certificate failure carries its code on cause", async () => { + const result = await run(` + const error = await fetch(server.url).catch(e => e); + console.log(JSON.stringify({ code: error.code, cause: error.cause?.code })); + `); + expect(result).toEqual({ code: "DEPTH_ZERO_SELF_SIGNED_CERT", cause: "DEPTH_ZERO_SELF_SIGNED_CERT" }); +}); + +test.concurrent("a native server checks client certificates against the default list", async () => { + const result = await run(` + const mtls = () => { + const strict = Bun.serve({ + port: 0, + tls: { cert: good, key, requestCert: true, rejectUnauthorized: true }, + fetch: () => new Response("ok"), + }); + return viaFetch({ tls: { ca: [good], cert: clientCert, key: clientKey } }, strict.url); + }; + tls.setDefaultCACertificates([other]); + const issuerListed = await mtls(); + tls.setDefaultCACertificates([good]); + console.log(JSON.stringify({ issuerListed, issuerAbsent: (await mtls()) === "OK" ? "OK" : "REFUSED" })); + `); + expect(result).toEqual({ issuerListed: "OK", issuerAbsent: "REFUSED" }); +}); + +test.concurrent("an explicit ca wins over the default list", async () => { + const result = await run(` + tls.setDefaultCACertificates([other]); + const opts = { tls: { ca: [good] } }; + console.log(JSON.stringify({ fetch: await viaFetch(opts), ws: await viaWs(opts) })); + `); + expect(result).toEqual({ fetch: "OK", ws: "OK" }); +}); + +test.concurrent("TLS options without ca still use the default list", async () => { + const result = await run(` + tls.setDefaultCACertificates([good]); + const opts = { tls: { serverName: "localhost" } }; + console.log(JSON.stringify({ fetch: await viaFetch(opts), ws: await viaWs(opts) })); + `); + expect(result).toEqual({ fetch: "OK", ws: "OK" }); +}); + +test.concurrent("a worker keeps its own default", async () => { + const result = await run(` + tls.setDefaultCACertificates([good]); + const worker = new Worker(URL.createObjectURL(new Blob([\` + fetch(\${JSON.stringify(server.url.href)}).then(() => "OK", e => e.code).then(postMessage); + \`]))); + const { promise, resolve } = Promise.withResolvers(); + worker.onmessage = e => resolve(e.data); + console.log(JSON.stringify({ main: await viaFetch(), worker: await promise })); + `); + expect(result).toEqual({ main: "OK", worker: "DEPTH_ZERO_SELF_SIGNED_CERT" }); +}); diff --git a/test/js/node/tls/fixtures/ip-name-constraints/dual-stack-ca.pem b/test/js/node/tls/fixtures/ip-name-constraints/dual-stack-ca.pem new file mode 100644 index 000000000000..1f50dcfee4cb --- /dev/null +++ b/test/js/node/tls/fixtures/ip-name-constraints/dual-stack-ca.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB6jCCAY+gAwIBAgIUXo7iV2H5DVxmUcvFBnieLFxP8J4wCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWSVAgY29uc3RyYWludHMgdGVzdCBDQTAeFw0yNjA5MTEwMDM5 +MzlaFw00NjA5MDYwMDM5MzlaMCExHzAdBgNVBAMMFklQIGNvbnN0cmFpbnRzIHRl +c3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARccdlo/qKBopS+ALrPvLxp +Ei1LRynPpTqRJPRbY0I5rTIDLmbzwcfltcCMenW2nSLj71B4yYTVRjCSqQcTb2JA +o4GkMIGhMB0GA1UdDgQWBBQlFBFItCT/7tt1DP+3Nw7x9JfWXDAfBgNVHSMEGDAW +gBQlFBFItCT/7tt1DP+3Nw7x9JfWXDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB +/wQEAwIBBjA+BgNVHR4BAf8ENDAyoDAwCocIAAAAAAAAAAAwIocgAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwCgYIKoZIzj0EAwIDSQAwRgIhAMVIhrB1 +DZMXyVL43KA0mdH0QwykX1PSrJfDEBzgBk/yAiEAjg+0D+oCMHF381YO1UAov5il +FOZ+Ovoup7OqtsUppn8= +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/ip-name-constraints/ipv4-any-ca.pem b/test/js/node/tls/fixtures/ip-name-constraints/ipv4-any-ca.pem new file mode 100644 index 000000000000..08ec9467407c --- /dev/null +++ b/test/js/node/tls/fixtures/ip-name-constraints/ipv4-any-ca.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBwzCCAWmgAwIBAgIUerup1lrS7jHepHbextMnF6iswSkwCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWSVAgY29uc3RyYWludHMgdGVzdCBDQTAeFw0yNjA5MTEwMDM5 +MzlaFw00NjA5MDYwMDM5MzlaMCExHzAdBgNVBAMMFklQIGNvbnN0cmFpbnRzIHRl +c3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARccdlo/qKBopS+ALrPvLxp +Ei1LRynPpTqRJPRbY0I5rTIDLmbzwcfltcCMenW2nSLj71B4yYTVRjCSqQcTb2JA +o38wfTAdBgNVHQ4EFgQUJRQRSLQk/+7bdQz/tzcO8fSX1lwwHwYDVR0jBBgwFoAU +JRQRSLQk/+7bdQz/tzcO8fSX1lwwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8E +BAMCAQYwGgYDVR0eAQH/BBAwDqAMMAqHCAAAAAAAAAAAMAoGCCqGSM49BAMCA0gA +MEUCIAGCRjqJuf+k5fSOcYd6+jpDeR7vSpFsuwx1goue0Dm+AiEA0OrFV/QesTc7 +TblhE+poaxBr6ZSUtvarYlEzoy86Wlk= +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/ip-name-constraints/ipv4-cert.pem b/test/js/node/tls/fixtures/ip-name-constraints/ipv4-cert.pem new file mode 100644 index 000000000000..c3bd39b0918c --- /dev/null +++ b/test/js/node/tls/fixtures/ip-name-constraints/ipv4-cert.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBtDCCAVugAwIBAgIBATAKBggqhkjOPQQDAjAhMR8wHQYDVQQDDBZJUCBjb25z +dHJhaW50cyB0ZXN0IENBMB4XDTI2MDkxMTAwMzkzOVoXDTQ2MDkwNjAwMzkzOVow +IzEhMB8GA1UEAwwYSVAgY29uc3RyYWludHMgdGVzdCBsZWFmMFkwEwYHKoZIzj0C +AQYIKoZIzj0DAQcDQgAEhW/DHPN/HRvDYUoGfV+zRNO/ReBmOgz3ZvwVuq2jRMpO +GYJQAApve+pICxoUMbe6W8YbddYWKOyp/3DzNVLO+KOBgTB/MBoGA1UdEQQTMBGC +CWxvY2FsaG9zdIcEfwAAATAMBgNVHRMBAf8EAjAAMBMGA1UdJQQMMAoGCCsGAQUF +BwMBMB0GA1UdDgQWBBQOBm6D9W0vLxjPbbuFQFn3DdnaeDAfBgNVHSMEGDAWgBQl +FBFItCT/7tt1DP+3Nw7x9JfWXDAKBggqhkjOPQQDAgNHADBEAiAQGcTwwBYkmf2g +3o1j1woFrmb0XzjGG2refrMs3qUnbwIgaYxRezBV5mszg0F5sQiH+kAQP+Jew5PM +pJmaEsmpVPg= +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/ip-name-constraints/ipv4-excluded-ca.pem b/test/js/node/tls/fixtures/ip-name-constraints/ipv4-excluded-ca.pem new file mode 100644 index 000000000000..76995f0d2edd --- /dev/null +++ b/test/js/node/tls/fixtures/ip-name-constraints/ipv4-excluded-ca.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBwjCCAWmgAwIBAgIUG8pFL+/6E4bfAXWY6y9dtK2djC0wCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWSVAgY29uc3RyYWludHMgdGVzdCBDQTAeFw0yNjA5MTEwMDM5 +MzlaFw00NjA5MDYwMDM5MzlaMCExHzAdBgNVBAMMFklQIGNvbnN0cmFpbnRzIHRl +c3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARccdlo/qKBopS+ALrPvLxp +Ei1LRynPpTqRJPRbY0I5rTIDLmbzwcfltcCMenW2nSLj71B4yYTVRjCSqQcTb2JA +o38wfTAdBgNVHQ4EFgQUJRQRSLQk/+7bdQz/tzcO8fSX1lwwHwYDVR0jBBgwFoAU +JRQRSLQk/+7bdQz/tzcO8fSX1lwwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8E +BAMCAQYwGgYDVR0eAQH/BBAwDqEMMAqHCH8AAAH/////MAoGCCqGSM49BAMCA0cA +MEQCIHILqbR9ir7SQGc++FpggUPuqtGC5tIz9ANOxP393EhoAiBb+00dzm2363ir +j8mNqz4SCS40NeOo0qzQGnTeFprkWw== +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/ip-name-constraints/ipv4-excluded-other-ca.pem b/test/js/node/tls/fixtures/ip-name-constraints/ipv4-excluded-other-ca.pem new file mode 100644 index 000000000000..54225d4a8706 --- /dev/null +++ b/test/js/node/tls/fixtures/ip-name-constraints/ipv4-excluded-other-ca.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBxDCCAWmgAwIBAgIUKv30XQYn9rMfIDBwi1drBI59WxIwCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWSVAgY29uc3RyYWludHMgdGVzdCBDQTAeFw0yNjA5MTEwMDM5 +MzlaFw00NjA5MDYwMDM5MzlaMCExHzAdBgNVBAMMFklQIGNvbnN0cmFpbnRzIHRl +c3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARccdlo/qKBopS+ALrPvLxp +Ei1LRynPpTqRJPRbY0I5rTIDLmbzwcfltcCMenW2nSLj71B4yYTVRjCSqQcTb2JA +o38wfTAdBgNVHQ4EFgQUJRQRSLQk/+7bdQz/tzcO8fSX1lwwHwYDVR0jBBgwFoAU +JRQRSLQk/+7bdQz/tzcO8fSX1lwwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8E +BAMCAQYwGgYDVR0eAQH/BBAwDqEMMAqHCMAAAgD///8AMAoGCCqGSM49BAMCA0kA +MEYCIQCtUu3f2KvCowYXn79ly3DZWP4U3W9v+piK2jOzqh8UCwIhANGH7evxltQ5 +1hRvdgW+J68ie6zrLBNeBlWkMrchUG2R +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/ip-name-constraints/ipv4-outside-ca.pem b/test/js/node/tls/fixtures/ip-name-constraints/ipv4-outside-ca.pem new file mode 100644 index 000000000000..0a1fa3ff2a09 --- /dev/null +++ b/test/js/node/tls/fixtures/ip-name-constraints/ipv4-outside-ca.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBwjCCAWmgAwIBAgIUHw5anuDPJXC8l4ZFhObcInb1So4wCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWSVAgY29uc3RyYWludHMgdGVzdCBDQTAeFw0yNjA5MTEwMDM5 +MzlaFw00NjA5MDYwMDM5MzlaMCExHzAdBgNVBAMMFklQIGNvbnN0cmFpbnRzIHRl +c3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARccdlo/qKBopS+ALrPvLxp +Ei1LRynPpTqRJPRbY0I5rTIDLmbzwcfltcCMenW2nSLj71B4yYTVRjCSqQcTb2JA +o38wfTAdBgNVHQ4EFgQUJRQRSLQk/+7bdQz/tzcO8fSX1lwwHwYDVR0jBBgwFoAU +JRQRSLQk/+7bdQz/tzcO8fSX1lwwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8E +BAMCAQYwGgYDVR0eAQH/BBAwDqAMMAqHCMAAAgD///8AMAoGCCqGSM49BAMCA0cA +MEQCIGePiRY20d31DFoIs0kQkmEkGkm0JBmYH8ReScMK4NYeAiB8wn31MNPX3liz +43CQc4aDW7qS0+Uv9Gk+NxlXMFq6tw== +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/ip-name-constraints/ipv4-subnet-ca.pem b/test/js/node/tls/fixtures/ip-name-constraints/ipv4-subnet-ca.pem new file mode 100644 index 000000000000..580dedfedf51 --- /dev/null +++ b/test/js/node/tls/fixtures/ip-name-constraints/ipv4-subnet-ca.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBwzCCAWmgAwIBAgIUFdaC9ZV3m9yaxbpxpwy/Q1a86qkwCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWSVAgY29uc3RyYWludHMgdGVzdCBDQTAeFw0yNjA5MTEwMDM5 +MzlaFw00NjA5MDYwMDM5MzlaMCExHzAdBgNVBAMMFklQIGNvbnN0cmFpbnRzIHRl +c3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARccdlo/qKBopS+ALrPvLxp +Ei1LRynPpTqRJPRbY0I5rTIDLmbzwcfltcCMenW2nSLj71B4yYTVRjCSqQcTb2JA +o38wfTAdBgNVHQ4EFgQUJRQRSLQk/+7bdQz/tzcO8fSX1lwwHwYDVR0jBBgwFoAU +JRQRSLQk/+7bdQz/tzcO8fSX1lwwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8E +BAMCAQYwGgYDVR0eAQH/BBAwDqAMMAqHCH8AAAD///8AMAoGCCqGSM49BAMCA0gA +MEUCIAWwG/m6HjhrSNP/4O/C8xdxg4q7EZ3c20OQP9fnRXS8AiEA+/zplIosN6xt +kpoIyPHWdwvjbBvCFVpSisPMo1YrUmA= +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/ip-name-constraints/ipv6-any-ca.pem b/test/js/node/tls/fixtures/ip-name-constraints/ipv6-any-ca.pem new file mode 100644 index 000000000000..2d7e9e83c853 --- /dev/null +++ b/test/js/node/tls/fixtures/ip-name-constraints/ipv6-any-ca.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB3TCCAYOgAwIBAgIUL94W+uNW4U5pvCs9rAjYW7UAjlIwCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWSVAgY29uc3RyYWludHMgdGVzdCBDQTAeFw0yNjA5MTEwMDM5 +MzlaFw00NjA5MDYwMDM5MzlaMCExHzAdBgNVBAMMFklQIGNvbnN0cmFpbnRzIHRl +c3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARccdlo/qKBopS+ALrPvLxp +Ei1LRynPpTqRJPRbY0I5rTIDLmbzwcfltcCMenW2nSLj71B4yYTVRjCSqQcTb2JA +o4GYMIGVMB0GA1UdDgQWBBQlFBFItCT/7tt1DP+3Nw7x9JfWXDAfBgNVHSMEGDAW +gBQlFBFItCT/7tt1DP+3Nw7x9JfWXDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB +/wQEAwIBBjAyBgNVHR4BAf8EKDAmoCQwIocgAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAwCgYIKoZIzj0EAwIDSAAwRQIgMT4Y+P2wPy+nyhtGAZlS79ZX +usu2OgMHIrL9WYRpiAcCIQDlrps10vEVsYOY6ETLBIdTv+QamMuyhsEuJgTAtiU4 +ZA== +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/ip-name-constraints/ipv6-cert.pem b/test/js/node/tls/fixtures/ip-name-constraints/ipv6-cert.pem new file mode 100644 index 000000000000..0c55a58c58ac --- /dev/null +++ b/test/js/node/tls/fixtures/ip-name-constraints/ipv6-cert.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBwzCCAWigAwIBAgIBAjAKBggqhkjOPQQDAjAhMR8wHQYDVQQDDBZJUCBjb25z +dHJhaW50cyB0ZXN0IENBMB4XDTI2MDkxMTAwMzkzOVoXDTQ2MDkwNjAwMzkzOVow +IzEhMB8GA1UEAwwYSVAgY29uc3RyYWludHMgdGVzdCBsZWFmMFkwEwYHKoZIzj0C +AQYIKoZIzj0DAQcDQgAEhW/DHPN/HRvDYUoGfV+zRNO/ReBmOgz3ZvwVuq2jRMpO +GYJQAApve+pICxoUMbe6W8YbddYWKOyp/3DzNVLO+KOBjjCBizAmBgNVHREEHzAd +gglsb2NhbGhvc3SHEAAAAAAAAAAAAAAAAAAAAAEwDAYDVR0TAQH/BAIwADATBgNV +HSUEDDAKBggrBgEFBQcDATAdBgNVHQ4EFgQUDgZug/VtLy8Yz227hUBZ9w3Z2ngw +HwYDVR0jBBgwFoAUJRQRSLQk/+7bdQz/tzcO8fSX1lwwCgYIKoZIzj0EAwIDSQAw +RgIhAIlBtlovPjYL+sOhIfTLfxfVuwmGdTGZg6/xICW4xBddAiEA8y/2llCTzR50 +saLqV/CSthy2NCrJGSseioxIVn9213g= +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/ip-name-constraints/ipv6-excluded-ca.pem b/test/js/node/tls/fixtures/ip-name-constraints/ipv6-excluded-ca.pem new file mode 100644 index 000000000000..f86cb3173e5f --- /dev/null +++ b/test/js/node/tls/fixtures/ip-name-constraints/ipv6-excluded-ca.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIB3DCCAYOgAwIBAgIUNUPyqPxEbzItAB3rQHYbvAe/0qwwCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWSVAgY29uc3RyYWludHMgdGVzdCBDQTAeFw0yNjA5MTEwMDM5 +MzlaFw00NjA5MDYwMDM5MzlaMCExHzAdBgNVBAMMFklQIGNvbnN0cmFpbnRzIHRl +c3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARccdlo/qKBopS+ALrPvLxp +Ei1LRynPpTqRJPRbY0I5rTIDLmbzwcfltcCMenW2nSLj71B4yYTVRjCSqQcTb2JA +o4GYMIGVMB0GA1UdDgQWBBQlFBFItCT/7tt1DP+3Nw7x9JfWXDAfBgNVHSMEGDAW +gBQlFBFItCT/7tt1DP+3Nw7x9JfWXDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB +/wQEAwIBBjAyBgNVHR4BAf8EKDAmoSQwIocgAAAAAAAAAAAAAAAAAAAAAf////// +//////////////8wCgYIKoZIzj0EAwIDRwAwRAIgRYGmRGtT4EiQHezWdcnrH0Mh +lhULbteR99QZNXQ+uMgCIAEREmlH+hQ16Ra5al3cnaMQXqv6KJ/rPznHHB8bWpTG +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/ip-name-constraints/ipv6-excluded-other-ca.pem b/test/js/node/tls/fixtures/ip-name-constraints/ipv6-excluded-other-ca.pem new file mode 100644 index 000000000000..4e1a410ebdcb --- /dev/null +++ b/test/js/node/tls/fixtures/ip-name-constraints/ipv6-excluded-other-ca.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIB3DCCAYOgAwIBAgIUMKYaTy3s8tAGL3k+BsuqE8jQwUkwCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWSVAgY29uc3RyYWludHMgdGVzdCBDQTAeFw0yNjA5MTEwMDM5 +MzlaFw00NjA5MDYwMDM5MzlaMCExHzAdBgNVBAMMFklQIGNvbnN0cmFpbnRzIHRl +c3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARccdlo/qKBopS+ALrPvLxp +Ei1LRynPpTqRJPRbY0I5rTIDLmbzwcfltcCMenW2nSLj71B4yYTVRjCSqQcTb2JA +o4GYMIGVMB0GA1UdDgQWBBQlFBFItCT/7tt1DP+3Nw7x9JfWXDAfBgNVHSMEGDAW +gBQlFBFItCT/7tt1DP+3Nw7x9JfWXDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB +/wQEAwIBBjAyBgNVHR4BAf8EKDAmoSQwIocgIAENuAAAAAAAAAAAAAAAAP////8A +AAAAAAAAAAAAAAAwCgYIKoZIzj0EAwIDRwAwRAIgJiKy94RWhp2fvoE4KWmgyvXe +1GePIvzRnGO0a6nJLTUCIEQ4t1PUCBfZ7LZ9m9LqS049fojA9aqk/AZQDK3mrHyZ +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/ip-name-constraints/ipv6-outside-ca.pem b/test/js/node/tls/fixtures/ip-name-constraints/ipv6-outside-ca.pem new file mode 100644 index 000000000000..a8636fa0abb4 --- /dev/null +++ b/test/js/node/tls/fixtures/ip-name-constraints/ipv6-outside-ca.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB3jCCAYOgAwIBAgIUerVcJzQleqlmG3qkge5N0kfgWBkwCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWSVAgY29uc3RyYWludHMgdGVzdCBDQTAeFw0yNjA5MTEwMDM5 +MzlaFw00NjA5MDYwMDM5MzlaMCExHzAdBgNVBAMMFklQIGNvbnN0cmFpbnRzIHRl +c3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARccdlo/qKBopS+ALrPvLxp +Ei1LRynPpTqRJPRbY0I5rTIDLmbzwcfltcCMenW2nSLj71B4yYTVRjCSqQcTb2JA +o4GYMIGVMB0GA1UdDgQWBBQlFBFItCT/7tt1DP+3Nw7x9JfWXDAfBgNVHSMEGDAW +gBQlFBFItCT/7tt1DP+3Nw7x9JfWXDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB +/wQEAwIBBjAyBgNVHR4BAf8EKDAmoCQwIocgIAENuAAAAAAAAAAAAAAAAP////8A +AAAAAAAAAAAAAAAwCgYIKoZIzj0EAwIDSQAwRgIhAPUQavoYVK1ztIN7mawPVLl9 +uwCudwpwTBMBkdubVcr4AiEAvEVNtdHJHLpPl0VynRwWxV+EcJKzOOMs48hDhs6I +HT4= +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/ip-name-constraints/ipv6-subnet-ca.pem b/test/js/node/tls/fixtures/ip-name-constraints/ipv6-subnet-ca.pem new file mode 100644 index 000000000000..e70b855cc94d --- /dev/null +++ b/test/js/node/tls/fixtures/ip-name-constraints/ipv6-subnet-ca.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIB3DCCAYOgAwIBAgIUN163I/4L9I+iERt92ohBeLITTjcwCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWSVAgY29uc3RyYWludHMgdGVzdCBDQTAeFw0yNjA5MTEwMDM5 +MzlaFw00NjA5MDYwMDM5MzlaMCExHzAdBgNVBAMMFklQIGNvbnN0cmFpbnRzIHRl +c3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARccdlo/qKBopS+ALrPvLxp +Ei1LRynPpTqRJPRbY0I5rTIDLmbzwcfltcCMenW2nSLj71B4yYTVRjCSqQcTb2JA +o4GYMIGVMB0GA1UdDgQWBBQlFBFItCT/7tt1DP+3Nw7x9JfWXDAfBgNVHSMEGDAW +gBQlFBFItCT/7tt1DP+3Nw7x9JfWXDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB +/wQEAwIBBjAyBgNVHR4BAf8EKDAmoCQwIocgAAAAAAAAAAAAAAAAAAAAAP////// +////AAAAAAAAAAAwCgYIKoZIzj0EAwIDRwAwRAIgE1pxPkIjUORgxJQkG4eUtZMN +j3Xztqe14uISEdpvGk4CIH4dnkyZ7eB/x4EyqxKYe/wH96oPCuLyfkCQP/kttno0 +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/ip-name-constraints/leaf-key.pem b/test/js/node/tls/fixtures/ip-name-constraints/leaf-key.pem new file mode 100644 index 000000000000..d224e47b6e9d --- /dev/null +++ b/test/js/node/tls/fixtures/ip-name-constraints/leaf-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgsxQQ8nSrTU89wKmu +/lWj2QPL6cTqivzlMdECygIcg2ehRANCAASFb8Mc838dG8NhSgZ9X7NE079F4GY6 +DPdm/BW6raNEyk4ZglAACm976kgLGhQxt7pbxht11hYo7Kn/cPM1Us74 +-----END PRIVATE KEY----- diff --git a/test/js/node/tls/node-tls-cert.test.ts b/test/js/node/tls/node-tls-cert.test.ts index 047543795fd7..3ab1569322c0 100644 --- a/test/js/node/tls/node-tls-cert.test.ts +++ b/test/js/node/tls/node-tls-cert.test.ts @@ -993,3 +993,57 @@ describe("expired or not-yet-valid CA in the trust set", () => { expect(() => tls.createSecureContext({ ca: [F.root, pem] })).toThrow(); }); }); + +describe("CA with an iPAddress name constraint", () => { + // Every *-ca.pem is the same CA name and key with a different nameConstraints extension, so each one anchors both leaves. + const read = (name: string) => readFileSync(join(import.meta.dir, "fixtures", "ip-name-constraints", name), "utf8"); + const permitted = ["permitted subtree violation", "PERMITTED_VIOLATION"] as const; + const excluded = ["excluded subtree violation", "EXCLUDED_VIOLATION"] as const; + + it.each([ + ["ipv4-any", "ipv4", undefined], + ["ipv4-subnet", "ipv4", undefined], + ["ipv4-outside", "ipv4", permitted], + ["ipv4-excluded", "ipv4", excluded], + ["ipv4-excluded-other", "ipv4", undefined], + ["ipv6-any", "ipv6", undefined], + ["ipv6-subnet", "ipv6", undefined], + ["ipv6-outside", "ipv6", permitted], + ["ipv6-excluded", "ipv6", excluded], + ["ipv6-excluded-other", "ipv6", undefined], + ["dual-stack", "ipv4", undefined], + ["dual-stack", "ipv6", undefined], + ["ipv4-any", "ipv6", permitted], + ["ipv6-any", "ipv4", permitted], + ] as const)("%s CA, leaf with an %s address", async (authority, family, violation) => { + const ca = read(`${authority}-ca.pem`); + using server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + tls: { key: read("leaf-key.pem"), cert: read(`${family}-cert.pem`) }, + fetch: () => new Response("verified"), + }); + + // Both leaves also name DNS:localhost, so only the chain check can fail. + const socket = tls.connect({ host: "127.0.0.1", port: server.port, servername: "localhost", ca }); + try { + const connected = once(socket, "secureConnect"); + if (violation) { + await expect(connected).rejects.toMatchObject({ code: "UNSPECIFIED", message: violation[0] }); + } else { + await connected; + expect(socket.authorized).toBe(true); + } + } finally { + socket.destroy(); + } + + if (family !== "ipv4") return; + const response = fetch(`https://127.0.0.1:${server.port}/`, { keepalive: false, tls: { ca } }); + if (violation) { + await expect(response).rejects.toMatchObject({ code: violation[1] }); + } else { + expect(await (await response).text()).toBe("verified"); + } + }); +}); diff --git a/test/js/node/tls/node-tls-client-wrap-fixture.mjs b/test/js/node/tls/node-tls-client-wrap-fixture.mjs index a2683adc49f8..a0146be73c2c 100644 --- a/test/js/node/tls/node-tls-client-wrap-fixture.mjs +++ b/test/js/node/tls/node-tls-client-wrap-fixture.mjs @@ -303,7 +303,8 @@ async function serverWithSession() { export async function session() { const { server, port, session, connected } = await serverWithSession(); const host = "127.0.0.1"; - const options = { session, rejectUnauthorized: false }; + const sessionEnvelopePrefix = Buffer.from("\0nodejs:tls:session:1\0"); + const options = { host, session, rejectUnauthorized: false }; // The listener is attached in the turn that makes the socket: a wrap starts its handshake at once. async function reused(socket, event, prepare) { try { @@ -317,6 +318,7 @@ export async function session() { } try { return { + boundSession: session.subarray(0, sessionEnvelopePrefix.length).equals(sessionEnvelopePrefix), "tls.connect({ port, session })": await reused(tls.connect({ port, host, ...options }), "secureConnect"), "tls.connect({ socket, session })": await reused( tls.connect({ socket: await connected(), ...options }), diff --git a/test/js/node/tls/node-tls-connect-hostname-verification.test.ts b/test/js/node/tls/node-tls-connect-hostname-verification.test.ts index 2f0219de2e3f..181e603c645f 100644 --- a/test/js/node/tls/node-tls-connect-hostname-verification.test.ts +++ b/test/js/node/tls/node-tls-connect-hostname-verification.test.ts @@ -605,3 +605,33 @@ describe("Bun.connect TLS hostname verification", () => { } }); }); + +// undici passes `servername: null` when the origin's host is an IP address. +describe("tls.connect({ servername: null }) to an IP address sends no SNI", () => { + for (const wrapsSocket of [false, true]) { + test(wrapsSocket ? "over options.socket" : "over its own connection", async () => { + const seen = Promise.withResolvers(); + const server = tls.createServer(ipSanCert, socket => { + seen.resolve(socket.servername); + socket.end(); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const options = { host: "127.0.0.1", port: (server.address() as AddressInfo).port }; + const raw = wrapsSocket ? net.connect(options) : undefined; + try { + if (raw) await once(raw, "connect"); + const socket = tls.connect({ ...options, socket: raw, ca: ipSanCert.cert, servername: null as any }); + await once(socket, "secureConnect"); + assert.deepStrictEqual( + { server: await seen.promise, client: socket.servername, authorized: socket.authorized }, + { server: false, client: false, authorized: true }, + ); + socket.destroy(); + } finally { + raw?.destroy(); + server.close(); + } + }); + } +}); diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 74fec40b94ea..700e2bea6298 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -176,6 +176,21 @@ it("should thow ECONNRESET if FIN is received before handshake", async () => { expect(error).toBeDefined(); expect((error as NodeJS.ErrnoException).code as string).toBe("ECONNRESET"); }); +it("reports ECONNRESET when the peer closes during the handshake with a write pending", async () => { + await using server = net.createServer(c => { + c.on("error", () => {}); + c.once("data", () => c.end()); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const socket = tls.connect({ port: (server.address() as AddressInfo).port, host: "127.0.0.1" }); + const events: string[] = []; + socket.on("end", () => events.push("end")); + socket.on("error", (err: NodeJS.ErrnoException) => events.push(`error ${err.code}`)); + socket.write("hello", (err?: NodeJS.ErrnoException | null) => events.push(`write ${err?.code}`)); + await new Promise(resolve => socket.once("close", resolve)); + // As in Node. + expect(events).toEqual(["end", "error ECONNRESET", "write ECANCELED"]); +}); it.each(["socket", "port"])( "write() and end() made before secureConnect call back when the connection is reset ({ %s })", async form => { @@ -2749,6 +2764,64 @@ it("verifies against the CAs of its context when the TLS socket rides on a Duple } }); +it("tls.connect() and https.request() build their context with a replaced tls.createSecureContext", async () => { + const fixtures = join(import.meta.dir, "fixtures"); + const ca1 = readFileSync(join(fixtures, "ca1-cert.pem"), "utf8"); + const server = https.createServer( + { + key: readFileSync(join(fixtures, "agent1-key.pem"), "utf8"), + cert: readFileSync(join(fixtures, "agent1-cert.pem"), "utf8"), + }, + (req, res) => res.end("hi"), + ); + await once(server.listen(0, "127.0.0.1"), "listening"); + const target = { port: (server.address() as AddressInfo).port, host: "127.0.0.1", servername: "agent1" }; + + function connectOutcome(options: tls.ConnectionOptions = {}): Promise { + const { promise, resolve } = Promise.withResolvers(); + const client = tls.connect({ ...target, ...options }, () => resolve(`authorized=${client.authorized}`)); + client.on("error", err => resolve((err as NodeJS.ErrnoException).code!)); + return promise.finally(() => client.destroy()); + } + function requestOutcome(): Promise { + const { promise, resolve } = Promise.withResolvers(); + const req = https.get({ ...target, agent: false }, res => { + res.resume(); + res.on("end", () => resolve(`status=${res.statusCode}`)); + }); + req.on("error", err => resolve((err as NodeJS.ErrnoException).code!)); + return promise; + } + + // This is how win-ca, mac-ca and syswide-cas add their certificates to every client connection. + const createSecureContext = tls.createSecureContext; + const seenCiphers: unknown[] = []; + const addsCA = (options?: tls.SecureContextOptions) => { + seenCiphers.push(options?.ciphers); + const context = createSecureContext(options); + context.context.addCACert(ca1); + return context; + }; + try { + const before = await connectOutcome(); + tls.createSecureContext = addsCA; + const replaced = { connect: await connectOutcome(), request: await requestOutcome() }; + const calls = seenCiphers.length; + const ownContext = await connectOutcome({ secureContext: createSecureContext() }); + expect({ before, replaced, calls, seenCiphers, ownContext, callsAfterOwnContext: seenCiphers.length }).toEqual({ + before: "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + replaced: { connect: "authorized=true", request: "status=200" }, + calls: 2, + seenCiphers: [tls.DEFAULT_CIPHERS, tls.DEFAULT_CIPHERS], + ownContext: "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + callsAfterOwnContext: 2, + }); + } finally { + tls.createSecureContext = createSecureContext; + server.close(); + } +}); + describe("a TLS server wrap over a Duplex transport whose peer ends the handshake with close_notify", () => { // The peer keeps the transport open, so only the alert says that no handshake will come. BoringSSL reads the alert // as the peer's close at every point of the handshake. OpenSSL refuses one ahead of the ClientHello and reads one @@ -5175,10 +5248,12 @@ describe("new tls.TLSSocket(socket) on the client side", () => { }); it.skipIf(skip)("a session that is set before the handshake starts is resumed", async () => { - expect(await session()).toEqual({ + const { boundSession, ...reused } = await session(); + // Newer Node releases wrap getSession() in a host-bound envelope that the TLSSocket constructor doesn't unwrap. + expect(reused).toEqual({ "tls.connect({ port, session })": true, "tls.connect({ socket, session })": true, - "new TLSSocket(socket, { session })": true, + "new TLSSocket(socket, { session })": !(_runtime === "node" && boundSession), "tls.connect({ port }), then setSession()": true, }); }); @@ -5241,6 +5316,8 @@ describe("negotiated parameters", () => { const x25519 = { type: "ECDH", name: "X25519", size: 253 }; const p256 = { type: "ECDH", name: "prime256v1", size: 256 }; const p384 = { type: "ECDH", name: "secp384r1", size: 384 }; + const x25519mlkem768 = { type: "TLSGroup", name: "X25519MLKEM768", size: undefined }; + const mlkem1024 = { type: "TLSGroup", name: "MLKEM1024", size: undefined }; // The client holds an EC certificate: its long-term key is not the ephemeral key. describe.each(["fd", "Duplex"] as const)("getEphemeralKeyInfo() over a %s", engine => { @@ -5251,11 +5328,12 @@ describe("negotiated parameters", () => { ["TLSv1.2", { ciphers: "AES128-SHA" }, none, none], ["TLSv1.3", { ecdhCurve: "X25519" }, x25519, x25519], ["TLSv1.3", { ecdhCurve: "secp384r1" }, p384, p384], - // X25519MLKEM768: not a key type Node maps. - ["TLSv1.3", {}, none, none], + ["TLSv1.3", {}, x25519mlkem768, x25519mlkem768], + ["TLSv1.3", { ecdhCurve: "MLKEM1024" }, mlkem1024, mlkem1024], ] as const)("%s %j", async (maxVersion, options, full, resumed) => { using server = await listen({ maxVersion, ...options }); - const clientOptions = { ...ecClientCert, ciphers: (options as tls.TlsOptions).ciphers }; + const { ciphers, ecdhCurve } = options as tls.TlsOptions; + const clientOptions = { ...ecClientCert, ciphers, ecdhCurve }; using first = await connectTo(server.port, engine, clientOptions); expect(first.client.getProtocol()).toBe(maxVersion); expect(first.client.isSessionReused()).toBe(false); @@ -6205,6 +6283,11 @@ describe.concurrent("a write that is the first to observe the peer's reset fails while (!fs.existsSync(process.env.RESET_DONE_FILE) && Date.now() < deadline) { Atomics.wait(cell, 0, 0, 5); } + // The kernel can apply the RST after the peer's close() returns. _handle.remotePort calls getpeername(), + // which fails on POSIX once the socket is reset. + while (process.platform !== "win32" && socket._handle.remotePort !== undefined && Date.now() < deadline) { + Atomics.wait(cell, 0, 0, 1); + } socket.write("ping", function onWritten(err) { write = err ? { code: err.code, syscall: err.syscall } : "ok"; }); diff --git a/test/js/node/tls/node-tls-context.test.ts b/test/js/node/tls/node-tls-context.test.ts index ac31c5eb5377..63fea961fbff 100644 --- a/test/js/node/tls/node-tls-context.test.ts +++ b/test/js/node/tls/node-tls-context.test.ts @@ -10,7 +10,7 @@ import { readFileSync } from "node:fs"; import http2 from "node:http2"; import net, { AddressInfo } from "node:net"; import { join } from "node:path"; -import { Duplex } from "node:stream"; +import { Duplex, duplexPair } from "node:stream"; import tls from "node:tls"; function loadPEM(filename: string) { @@ -750,15 +750,28 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("ALPN after SNI selected a Secure return listening(server, stack); }; const addContext = (server: tls.Server) => server.addContext("alpn.sni.test", selected); + // A server-side TLSSocket over an accepted net.Socket. `prepare` runs once its TLS engine exists, before the ClientHello. + const wrapped = + (options: object, prepare?: (socket: tls.TLSSocket) => void): Start => + (alpn, onSecure, stack) => + listening( + net.createServer(raw => { + const socket = new tls.TLSSocket(raw, { + isServer: true, + secureContext: tls.createSecureContext(defaults), + ...options, + ...alpn, + }); + socket.on("error", () => {}); + socket.on("secure", () => onSecure(socket)); + if (prepare) setImmediate(prepare, socket); + }), + stack, + ); const paths: [string, Start][] = [ ["a synchronous SNICallback", accepted({ SNICallback: sync })], ["an asynchronous SNICallback", accepted({ SNICallback: deferred })], ["addContext()", accepted({}, addContext)], - // Bun only: in Node a user SNICallback replaces the addContext() entries. - [ - "addContext() behind an SNICallback that selects nothing", - accepted({ SNICallback: (_: string, cb: SNICb) => void setImmediate(cb, null, null) }, addContext), - ], [ "addContext() on an injected socket", (alpn, onSecure, stack) => { @@ -771,22 +784,19 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("ALPN after SNI selected a Secure ); }, ], + ["the SNICallback of a server-side TLSSocket", wrapped({ SNICallback: sync })], [ - "the SNICallback of a server-side TLSSocket", - (alpn, onSecure, stack) => - listening( - net.createServer(raw => { - const socket = new tls.TLSSocket(raw, { - isServer: true, - secureContext: tls.createSecureContext(defaults), - SNICallback: sync, - ...alpn, - }); - socket.on("error", () => {}); - socket.on("secure", () => onSecure(socket)); - }), - stack, - ), + "setKeyCert() in the SNICallback of a server-side TLSSocket", + wrapped({ + SNICallback(this: tls.TLSSocket, _: string, cb: SNICb) { + this.setKeyCert(tls.createSecureContext(selected)); + cb(null); + }, + }), + ], + [ + "setKeyCert() on a server-side TLSSocket before the handshake", + wrapped({}, socket => socket.setKeyCert(tls.createSecureContext(selected))), ], ]; @@ -1057,13 +1067,21 @@ describe("SNI matching is case-insensitive", () => { describe.each(["TLSv1.2", "TLSv1.3"] as const)("%s", version => { const inAnyCase = ["strict.example.com", "STRICT.EXAMPLE.COM", "sTrIcT.eXaMpLe.CoM"]; const selectsNothing = (_: string, cb: Function) => cb(null, null); + // The verdicts on a client certificate from ca1 (agent1) and on one from ca5 (ec10). + const underTheEntry = ["refused", "accepted"]; + const underTheDefault = ["accepted", "refused"]; it.each([ - ["in any case", inAnyCase, undefined], + ["a context's client CA applies to its name in any case", inAnyCase, undefined, underTheEntry], // Bun only: Node serves the default context for a name with the root dot. - ["with the root dot", ["strict.example.com.", "STRICT.EXAMPLE.COM."], undefined], - // Bun only: in Node a user SNICallback replaces the addContext() entries. - ["behind an SNICallback that selects nothing", inAnyCase, selectsNothing], - ])("a context's client CA applies to its name %s", async (_, spellings, SNICallback) => { + [ + "a context's client CA applies to its name with the root dot", + ["strict.example.com.", "STRICT.EXAMPLE.COM."], + undefined, + underTheEntry, + ], + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1270 + ["the default client CA applies when an SNICallback selects nothing", inAnyCase, selectsNothing, underTheDefault], + ] as const)("%s", async (_, spellings, SNICallback, verdictsPerName) => { const server = tls.createServer({ key: agent2Key, cert: agent2Cert, @@ -1073,7 +1091,7 @@ describe("SNI matching is case-insensitive", () => { SNICallback, } as any); server.addContext("Strict.Example.com", { key: agent1Key, cert: agent1Cert, ca: [loadPEM("ca5-cert.pem")] }); - // agent1 chains to ca1, which only the default context trusts; ec10 chains to ca5. + // agent1 chains to ca1, which only the default context trusts; ec10 chains to ca5, which only the entry trusts. const ec10 = [loadPEM("ec10-key.pem"), loadPEM("ec10-cert.pem")] as const; async function verdict(servername: string, key: string, cert: string) { const judged = Promise.race([ @@ -1104,7 +1122,7 @@ describe("SNI matching is case-insensitive", () => { for (const name of spellings) { verdicts.push([name, await verdict(name, agent1Key, agent1Cert), await verdict(name, ...ec10)]); } - expect(verdicts).toEqual(spellings.map(name => [name, "refused", "accepted"])); + expect(verdicts).toEqual(spellings.map(name => [name, ...verdictsPerName])); expect(await verdict("other.example.com", agent1Key, agent1Cert)).toBe("accepted"); } finally { server.close(); @@ -1288,6 +1306,363 @@ describe("server certificate chain built from `ca`", () => { }); }); +describe.each(["TLSv1.2", "TLSv1.3"] as const)("setKeyCert() in ALPNCallback (%s)", version => { + // ca1 issued agent1 and ca2 issued agent3. agent2 is self-signed. + const clients = { + agent1: { key: agent1Key, cert: agent1Cert }, + agent3: { key: agent3Key, cert: agent3Cert }, + }; + const trustedBy = { agent1: "a", agent3: "b" } as const; + const servedFor = { a: "agent2", b: "agent3" } as const; + type Offer = { servername: string | false; protocols: string[] }; + type Seen = { alpn: string | false; authorized: boolean; error: string | null; resumed: boolean }; + + // The default context serves agent2 and trusts ca1. Protocol "b" selects a context that serves agent3 and trusts ca2. + function createAlpnServer(options: tls.TlsOptions = {}) { + const contextB = tls.createSecureContext({ key: agent3Key, cert: agent3Cert, ca: [ca2] }); + const server = tls.createServer({ + key: agent2Key, + cert: agent2Cert, + ca: [ca1], + requestCert: true, + rejectUnauthorized: false, + minVersion: version, + maxVersion: version, + ALPNCallback(this: tls.TLSSocket, { protocols }: Offer) { + if (protocols[0] === "b") this.setKeyCert(contextB); + return protocols[0]; + }, + ...options, + }); + server.on("secureConnection", socket => { + const seen: Seen = { + alpn: socket.alpnProtocol as string | false, + authorized: socket.authorized, + error: (socket.authorizationError as unknown as string | undefined) ?? null, + resumed: socket.isSessionReused(), + }; + socket.on("error", () => {}); + socket.end(JSON.stringify(seen)); + }); + return server; + } + + // "listener" and "adopted" run the TLS engine in usockets, "duplex" runs it over a stream. + type Transport = Disposable & { open(options: tls.ConnectionOptions): tls.TLSSocket }; + const transports = { + async listener(server: tls.Server): Promise { + await once(server.listen(0, "127.0.0.1"), "listening"); + const { port } = server.address() as AddressInfo; + return { + open: options => tls.connect({ host: "127.0.0.1", port, ...options }), + [Symbol.dispose]: () => void server.close(), + }; + }, + async adopted(server: tls.Server): Promise { + const raw = net.createServer(socket => server.emit("connection", socket)); + await once(raw.listen(0, "127.0.0.1"), "listening"); + const { port } = raw.address() as AddressInfo; + return { + open: options => tls.connect({ host: "127.0.0.1", port, ...options }), + [Symbol.dispose]: () => void raw.close(), + }; + }, + async duplex(server: tls.Server): Promise { + return { + open: options => { + const [clientSide, serverSide] = duplexPair(); + server.emit("connection", serverSide); + return tls.connect({ socket: clientSide, ...options }); + }, + [Symbol.dispose]() {}, + }; + }, + }; + + /** Resolves to the certificate the server presented, what the server reported, and the session it issued. */ + function exchange(socket: tls.TLSSocket) { + const { promise, resolve, reject } = Promise.withResolvers<{ served: string; seen: Seen; session?: Buffer }>(); + let served = ""; + let body = ""; + let session: Buffer | undefined; + socket.on("secureConnect", () => (served = socket.getPeerCertificate().subject.CN)); + socket.on("session", s => (session = s)); + socket.on("data", chunk => (body += chunk)); + socket.on("error", reject); + socket.on("close", () => reject(new Error("closed before the server ended the stream"))); + socket.on("end", () => { + socket.destroy(); + resolve({ served, seen: JSON.parse(body), session }); + }); + return promise; + } + + describe.each(["listener", "adopted", "duplex"] as const)("%s", kind => { + it.each(["agent1", "agent3"] as const)("serves and verifies %s under the selected context", async name => { + using transport = await transports[kind](createAlpnServer()); + const offer = async (alpn: "a" | "b", session?: Buffer) => { + const options = { ...clients[name], rejectUnauthorized: false, ALPNProtocols: [alpn], session }; + const { served, seen, session: issued } = await exchange(transport.open(options)); + return { outcome: { served, seen }, issued }; + }; + const expected = (alpn: "a" | "b", resumed: boolean) => ({ + served: servedFor[alpn], + seen: + trustedBy[name] === alpn + ? { alpn, authorized: true, error: null, resumed } + : { alpn, authorized: false, error: "UNABLE_TO_VERIFY_LEAF_SIGNATURE", resumed }, + }); + const [fullA, fullB] = [await offer("a"), await offer("b")]; + expect([fullA.outcome, fullB.outcome]).toEqual([expected("a", false), expected("b", false)]); + // A session only resumes under the context that issued it, so no verdict carries over to the other CA. + expect([ + (await offer("a", fullA.issued)).outcome, + (await offer("b", fullA.issued)).outcome, + (await offer("a", fullB.issued)).outcome, + (await offer("b", fullB.issued)).outcome, + ]).toEqual([expected("a", true), expected("b", false), expected("a", false), expected("b", true)]); + }); + }); + + it("serves an ECDSA certificate from a server whose own certificate is RSA", async () => { + const context = tls.createSecureContext({ key: loadPEM("ec10-key.pem"), cert: loadPEM("ec10-cert.pem") }); + const server = createAlpnServer({ + requestCert: false, + ALPNCallback(this: tls.TLSSocket, { protocols }: Offer) { + this.setKeyCert(context); + return protocols[0]; + }, + }); + using transport = await transports.listener(server); + const { served } = await exchange(transport.open({ rejectUnauthorized: false, ALPNProtocols: ["a"] })); + expect(served).toBe("agent10.example.com"); + }); + + it.each(["server", "context"] as const)( + "completes a lone leaf from the server's `ca` only (`ca` on the %s)", + async where => { + // agent6-cert.pem is the agent6 leaf followed by the ca3 intermediate that signed it. ca1 issued ca3. + const [agent6Leaf, ca3Cert] = agent6Cert.split(/(?=-----BEGIN CERTIFICATE-----)/); + const context = tls.createSecureContext({ + key: agent6Key, + cert: agent6Leaf, + ca: where === "context" ? [ca3Cert] : undefined, + }); + const server = createAlpnServer({ + requestCert: false, + ca: where === "server" ? [ca3Cert] : undefined, + ALPNCallback(this: tls.TLSSocket, { protocols }: Offer) { + this.setKeyCert(context); + return protocols[0]; + }, + }); + using transport = await transports.listener(server); + // The client only trusts the root, so the chain verifies only if the server sent the intermediate. + const client = transport.open({ + ca: [ca1], + rejectUnauthorized: false, + checkServerIdentity: () => undefined, + ALPNProtocols: ["a"], + }); + const { served } = await exchange(client); + expect({ served, error: client.authorizationError ?? null }).toEqual({ + served: "Ádám Lippai", + error: where === "server" ? null : "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + }); + }, + ); + + it("sends the chain of a context made from a pfx", async () => { + // agent6.pfx holds agent6, the ca3 intermediate that signed it and the ca1 root. + const pfx = readFileSync(join(import.meta.dir, "..", "test", "fixtures", "keys", "agent6.pfx")); + const context = tls.createSecureContext({ pfx, passphrase: "sample" }); + const server = createAlpnServer({ + requestCert: false, + ALPNCallback(this: tls.TLSSocket, { protocols }: Offer) { + this.setKeyCert(context); + return protocols[0]; + }, + }); + using transport = await transports.listener(server); + const client = transport.open({ ca: [ca1], checkServerIdentity: () => undefined, ALPNProtocols: ["a"] }); + const { served } = await exchange(client); + expect({ served, authorized: client.authorized }).toEqual({ served: "Ádám Lippai", authorized: true }); + }); + + it("does nothing on a client, as in Node", async () => { + using transport = await transports.listener(createAlpnServer()); + const client = transport.open({ ...clients.agent1, rejectUnauthorized: false, ALPNProtocols: ["a"] }); + client.on("connect", () => client.setKeyCert(tls.createSecureContext(clients.agent3))); + // The default context trusts ca1, which issued agent1 and not agent3. + expect((await exchange(client)).seen.authorized).toBe(true); + }); + + it("throws for a context without a certificate and keeps the server's", async () => { + const thrown: string[] = []; + const server = createAlpnServer({ + requestCert: false, + ALPNCallback(this: tls.TLSSocket, { protocols }: Offer) { + try { + this.setKeyCert(tls.createSecureContext({ ca: [ca2] })); + } catch (e) { + thrown.push((e as Error).message); + } + return protocols[0]; + }, + }); + using transport = await transports.listener(server); + const { served } = await exchange(transport.open({ rejectUnauthorized: false, ALPNProtocols: ["a"] })); + expect({ thrown, served }).toEqual({ thrown: ["SetKeyCert"], served: "agent2" }); + }); + + it("verifies the client against the new CAs when called after the certificate is sent", async () => { + const contextB = tls.createSecureContext({ key: agent3Key, cert: agent3Cert, ca: [ca2] }); + const server = createAlpnServer({ + ALPNCallback(this: tls.TLSSocket, { protocols }: Offer) { + // A macrotask later the server's first flight is out and the client's certificate isn't in yet. + if (protocols[0] === "late") setImmediate(() => this.setKeyCert(contextB)); + return protocols[0]; + }, + }); + using transport = await transports.listener(server); + const options = { ...clients.agent3, rejectUnauthorized: false }; + const { served, seen, session } = await exchange(transport.open({ ...options, ALPNProtocols: ["late"] })); + expect({ served, seen }).toEqual({ + served: "agent2", + seen: { alpn: "late", authorized: true, error: null, resumed: false }, + }); + // ca2's verdict doesn't carry over to a connection that stays on the default context, which trusts ca1 only. + expect((await exchange(transport.open({ ...options, ALPNProtocols: ["a"], session }))).seen).toEqual({ + alpn: "a", + authorized: false, + error: "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + resumed: false, + }); + }); + + // OpenSSL runs ALPNCallback before SNICallback on TLS 1.3 and after it on TLS 1.2, and the last context wins. + const alpnFirst = version === "TLSv1.3"; + + describe.each(["listener", "adopted", "duplex"] as const)("with SNI, %s", kind => { + it.each(["a synchronous SNICallback", "an asynchronous SNICallback", "addContext()"] as const)( + "the later of ALPNCallback and %s picks the context", + async how => { + const named = { key: agent1Key, cert: agent1Cert, ca: [ca1] }; + const server = createAlpnServer({ + SNICallback: + how === "addContext()" + ? undefined + : (_, callback) => + how === "a synchronous SNICallback" + ? callback(null, tls.createSecureContext(named)) + : void setImmediate(callback, null, tls.createSecureContext(named)), + }); + if (how === "addContext()") server.addContext("named.test", named); + using transport = await transports[kind](server); + const offer = async (alpn: string, servername: string) => { + const options = { ...clients.agent1, rejectUnauthorized: false, ALPNProtocols: [alpn], servername }; + const { served, seen } = await exchange(transport.open(options)); + return { served, authorized: seen.authorized }; + }; + expect(await offer("a", "named.test")).toEqual({ served: "agent1", authorized: true }); + expect(await offer("b", "named.test")).toEqual( + alpnFirst ? { served: "agent1", authorized: true } : { served: "agent3", authorized: false }, + ); + if (how === "addContext()") { + expect(await offer("b", "other.test")).toEqual({ served: "agent3", authorized: false }); + } + }, + ); + + it("runs the two callbacks in Node's order, and no SNICallback after a refused protocol", async () => { + const calls: string[] = []; + const server = createAlpnServer({ + requestCert: false, + SNICallback(_, callback) { + calls.push("SNICallback"); + setImmediate(callback, null, null); + }, + ALPNCallback({ protocols }: Offer) { + calls.push("ALPNCallback"); + return protocols[0] === "refused" ? undefined : protocols[0]; + }, + }); + using transport = await transports[kind](server); + const options = { rejectUnauthorized: false, servername: "named.test" }; + await exchange(transport.open({ ...options, ALPNProtocols: ["a"] })); + expect(calls.splice(0)).toEqual(alpnFirst ? ["ALPNCallback", "SNICallback"] : ["SNICallback", "ALPNCallback"]); + + const refused = once(server, "tlsClientError"); + const client = transport.open({ ...options, ALPNProtocols: ["refused"] }); + const [error] = await once(client, "error"); + expect([error.code, (await refused)[0].code]).toEqual([ + "ERR_SSL_TLSV1_ALERT_NO_APPLICATION_PROTOCOL", + "ERR_SSL_NO_APPLICATION_PROTOCOL", + ]); + expect(calls).toEqual(alpnFirst ? ["ALPNCallback"] : ["SNICallback", "ALPNCallback"]); + }); + }); + + it("an SNICallback that answers with the server's own context undoes an earlier setKeyCert()", async () => { + const server = createAlpnServer({ + SNICallback: (_, callback) => callback(null, (server as any)._sharedCreds), + }); + using transport = await transports.listener(server); + const options = { rejectUnauthorized: false, ALPNProtocols: ["b"], servername: "named.test" }; + const outcomes: unknown[] = []; + for (const name of ["agent1", "agent3"] as const) { + const { served, seen } = await exchange(transport.open({ ...clients[name], ...options })); + outcomes.push({ served, authorized: seen.authorized }); + } + expect(outcomes).toEqual( + alpnFirst + ? [ + { served: "agent2", authorized: true }, + { served: "agent2", authorized: false }, + ] + : [ + { served: "agent3", authorized: false }, + { served: "agent3", authorized: true }, + ], + ); + }); + + it("keeps the server's ciphers and sigalgs, as in Node", async () => { + const [setting, ofServer, ofContext, code] = + version === "TLSv1.2" + ? ([ + "ciphers", + "ECDHE-RSA-AES256-GCM-SHA384", + "ECDHE-RSA-AES128-GCM-SHA256", + "ERR_SSL_NO_SHARED_CIPHER", + ] as const) + : ([ + "sigalgs", + "rsa_pss_rsae_sha512", + "rsa_pss_rsae_sha256", + "ERR_SSL_NO_COMMON_SIGNATURE_ALGORITHMS", + ] as const); + const context = tls.createSecureContext({ key: agent3Key, cert: agent3Cert, [setting]: ofContext }); + const server = createAlpnServer({ + requestCert: false, + [setting]: ofServer, + ALPNCallback(this: tls.TLSSocket, { protocols }: Offer) { + this.setKeyCert(context); + return protocols[0]; + }, + }); + using transport = await transports.listener(server); + const options = { rejectUnauthorized: false, ALPNProtocols: ["a"] }; + expect((await exchange(transport.open({ ...options, [setting]: ofServer }))).served).toBe("agent3"); + + const refused = once(server, "tlsClientError"); + const client = transport.open({ ...options, [setting]: ofContext }); + client.on("error", () => {}); + expect((await refused)[0].code).toBe(code); + client.destroy(); + }); +}); + it("accepts every BoringSSL named group (and alias) as ecdhCurve", () => { // Mirrors vendor/boringssl/ssl/ssl_key_share.cc kNamedGroups at the pinned // commit. This pins the set the public API accepts; it cannot detect a NEW diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 68615368f87f..e757a81d6b18 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -8,7 +8,7 @@ import https from "https"; import net, { AddressInfo } from "net"; import { createTest } from "node-harness"; import { once } from "node:events"; -import { Duplex } from "node:stream"; +import { Duplex, duplexPair } from "node:stream"; import { tmpdir } from "os"; import { join } from "path"; import type { PeerCertificate } from "tls"; @@ -760,6 +760,41 @@ describe.each(["TLSv1.3", "TLSv1.2"])("an ALPNCallback over a Duplex can drop it }); }); +describe("an SNICallback over a Duplex can drop its connection", () => { + it.concurrent.each([ + ["emit", "TLSv1.3", 'server.emit("connection", duplex)'], + ["emit", "TLSv1.2", 'server.emit("connection", duplex)'], + ["staged", "TLSv1.3", "new TLSSocket(duplex, { isServer: true }) after the ClientHello"], + ])("%s, %s: %s", async (door, maxVersion) => { + await using proc = Bun.spawn({ + cmd: [bunExe(), join(import.meta.dir, "tls-sni-callback-over-duplex-fixture.mjs"), door, maxVersion], + env: { ...bunEnv, TLS_KEY: cert1.key, TLS_CERT: cert1.cert }, + 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({ + "answer": ["client secureConnect"], + // The first answer wins. Node destroys the socket instead: "Callback called multiple times". + "answer twice": ["client secureConnect"], + "answer, then fail": ["client secureConnect"], + "destroy(), then answer": ["client error ECONNRESET"], + "destroy(err), then answer": ["server error boom", "client error ECONNRESET"], + "destroy() the transport, then answer": ["client error ECONNRESET"], + "destroy() the client, then answer": [], + "throw": ["server error boom", "client error ECONNRESET"], + "answer later": ["client secureConnect"], + "answer later, twice": ["client secureConnect"], + "destroy(), then answer later": ["client error ECONNRESET"], + "destroy() later, then answer": ["client error ECONNRESET"], + "destroy() the client, then answer later": [], + "end(), then answer later": ["client error ECONNRESET"], + }); + expect(exitCode).toBe(0); + }); +}); + it("writing to the socket from inside SNICallback or ALPNCallback delivers the data after the handshake", async () => { // Same callbacks as above: they run from inside the native read that is // processing the ClientHello. A write issued there has to be held until that @@ -1165,18 +1200,47 @@ it("SNICallback errors abort the handshake and surface as tlsClientError", async } }); -it("SNICallback returning no context falls through to the default context", async () => { - const server: Server = createServer({ ...COMMON_CERT, SNICallback: (_name, cb) => cb(null, null as any) }, socket => { - socket.end(); - }); - server.on("tlsClientError", err => { - throw err; +// Node only rejects an object that isn't a SecureContext. +it.each([null, false, 0, 5, "x", { context: 5 }])( + "SNICallback answering %j keeps the default context", + async answer => { + const server: Server = createServer( + { ...COMMON_CERT, SNICallback: (_name, cb) => cb(null, answer as any) }, + socket => { + socket.end(); + }, + ); + server.on("tlsClientError", err => { + throw err; + }); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + const client = connect({ port, host: "127.0.0.1", servername: "a.example.com", rejectUnauthorized: false }); + await once(client, "secureConnect"); + client.end(); + server.close(); + await once(server, "close"); + }, +); + +it("calls SNICallback with the connection's TLSSocket as `this`", async () => { + const receiver = Promise.withResolvers(); + const server: Server = createServer({ + ...COMMON_CERT, + SNICallback(_name, cb) { + receiver.resolve(this); + cb(null); + }, }); - server.listen(0); + const secureConnection = once(server, "secureConnection"); + server.listen(0, "127.0.0.1"); await once(server, "listening"); const port = (server.address() as AddressInfo).port; - const client = connect({ port, host: "127.0.0.1", servername: "a.example.com", rejectUnauthorized: false }); - await once(client, "secureConnect"); + const client = connect({ port, host: "127.0.0.1", servername: "localhost", ca: COMMON_CERT.cert }); + const [[socket]] = await Promise.all([secureConnection, once(client, "secureConnect")]); + expect(await receiver.promise).toBe(socket); + expect(socket.server).toBe(server); client.end(); server.close(); await once(server, "close"); @@ -1403,6 +1467,53 @@ it("destroying the connection while an asynchronous SNICallback is pending does expect(true).toBe(true); }); +describe("bytes behind the ClientHello while an SNICallback is pending", () => { + // The first flight of `openssl s_client -tls1_3 -servername tenant.test -sess_in -early_data ` with + // another server's ticket: a ClientHello that offers early data, a ChangeCipherSpec and one record of early data. + const earlyDataFlight = Buffer.from( + "FgMBARoBAAEWAwMp/weGICAnVv6gna1l+hYJJDOR/uveVHfyZMyACByW+SDukwa2UAhxzUMX/p4+BpXCVKPwDpOLt3RL0hiC" + + "VjNvfgACEwEBAADLAAAAEAAOAAALdGVuYW50LnRlc3QACwAEAwABAgAKAAQAAgAdACMAAAAWAAAAFwAAAA0ABAACCAQAKwAD" + + "AgMEAC0AAgEBADMAJgAkAB0AIG764hLgXSrMocOZIRBBSk9FIlSy0FEMKRF8e1CcLPR7ABsABQQAAQADACoAAAApAEsAJgAg" + + "rv5EtHqOrOlVIMYna7UDgQ+pTU5eLNBffIHaxG8v5bJA3WbjACEgAN9iwNXMohPDEizBBbsalVq2bV786wKfvZM5Dqa0nc8U" + + "AwMAAQEXAwMAFquXcyETB0c8UhVSieBFIYMrt1pVWyA=", + "base64", + ); + + /** Writes `flight`, answers the SNICallback once it's asked, and resolves to what the server does next. */ + async function answerTo(flight: Buffer) { + const asked = Promise.withResolvers<(err: null) => void>(); + const server: Server = createServer({ ...COMMON_CERT, SNICallback: (_, cb) => asked.resolve(cb) }); + server.on("tlsClientError", () => {}); + await once(server.listen(0, "127.0.0.1"), "listening"); + const raw = net.connect((server.address() as AddressInfo).port, "127.0.0.1"); + try { + const answered = Promise.withResolvers(); + let received = Buffer.alloc(0); + raw.on("data", chunk => { + received = Buffer.concat([received, chunk]); + if (received.length >= 6) answered.resolve(received[0] === 0x16 && received[5] === 0x02 ? "ServerHello" : "?"); + }); + raw.on("error", () => {}); + raw.on("close", () => answered.resolve("closed")); + raw.write(flight); + (await asked.promise)(null); + return await answered.promise; + } finally { + raw.destroy(); + server.close(); + } + } + + it("skips a client's early data after the answer", async () => { + expect(await answerTo(earlyDataFlight)).toBe("ServerHello"); + }); + + it("closes a client that sends more than BoringSSL would skip", async () => { + const record = Buffer.concat([Buffer.from([0x17, 0x03, 0x03, 0x40, 0x00]), Buffer.alloc(16384)]); + expect(await answerTo(Buffer.concat([earlyDataFlight, record]))).toBe("closed"); + }); +}); + it("SNICallback accepts a raw native context (Node's context.context || context)", async () => { // cb(null, secureContext.context) - passing the unwrapped native context - // must select it, same as passing the wrapper. @@ -1644,7 +1755,24 @@ describe("setSecureContext() on a listening server", () => { } }); - it("leaves addContext() entries, SNICallback and ALPN negotiation in place", async () => { + it("leaves addContext() entries and ALPN negotiation in place", async () => { + const server: Server = createServer({ ...agent1, ALPNProtocols: ["h2", "http/1.1"] }); + try { + const { port, address } = await listen(server, "localhost"); + const base = { port, host: address, ALPNProtocols: ["h2"] }; + // An entry under the bind hostname is the caller's like any other: the + // swap replaces the default context only. + server.addContext("localhost", { ...agent2 }); + + server.setSecureContext({ ...agent3 }); + expect(await handshake(base)).toEqual({ cn: "agent3", alpn: "h2" }); + expect(await handshake({ ...base, servername: "localhost" })).toMatchObject({ cn: "agent2" }); + } finally { + server.close(); + } + }); + + it("leaves the SNICallback and ALPN negotiation in place", async () => { let sniCalls = 0; const viaCallback = tls.createSecureContext({ ...agent2 }); const server: Server = createServer({ @@ -1658,13 +1786,10 @@ describe("setSecureContext() on a listening server", () => { try { const { port, address } = await listen(server, "localhost"); const base = { port, host: address, ALPNProtocols: ["h2"] }; - // An entry under the bind hostname is the caller's like any other: the - // swap replaces the default context only. - server.addContext("localhost", { ...agent2 }); server.setSecureContext({ ...agent3 }); expect(await handshake(base)).toEqual({ cn: "agent3", alpn: "h2" }); - expect(await handshake({ ...base, servername: "localhost" })).toMatchObject({ cn: "agent2" }); + expect(await handshake({ ...base, servername: "localhost" })).toMatchObject({ cn: "agent3" }); expect(await handshake({ ...base, servername: "callback.example" })).toMatchObject({ cn: "agent2" }); expect(sniCalls).toBe(2); } finally { @@ -2030,17 +2155,13 @@ it("an asynchronous SNICallback resolving cb(null, null) falls back like the syn await once(server, "close"); }); -it("an asynchronous SNICallback resolving cb(null, null) still honors addContext entries", async () => { - // The async-null fallback must consult the static SNI tree with the - // servername, not just fall to the default context: addContext's cert is - // the one the client must receive. +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1270 +it.each([ + ["a synchronous", (cb: Function) => cb(null, null)], + ["an asynchronous", (cb: Function) => void setImmediate(cb, null, null)], +])("%s SNICallback that selects nothing serves the default context, not an addContext() entry", async (_, answer) => { const altCert = { key: rawKey, cert: cert }; - const server: Server = createServer({ - ...COMMON_CERT, - SNICallback: (_name, cb) => { - setTimeout(() => cb(null, null as any), 30); - }, - }); + const server: Server = createServer({ ...COMMON_CERT, SNICallback: (_name, cb) => answer(cb) }); server.addContext("alt.example.com", altCert); server.on("secureConnection", socket => socket.end()); server.on("tlsClientError", err => { @@ -2051,7 +2172,7 @@ it("an asynchronous SNICallback resolving cb(null, null) still honors addContext const port = (server.address() as AddressInfo).port; const client = connect({ port, host: "127.0.0.1", servername: "alt.example.com", rejectUnauthorized: false }); await once(client, "secureConnect"); - const expectedCert = new crypto.X509Certificate(cert); + const expectedCert = new crypto.X509Certificate(COMMON_CERT.cert); expect(client.getPeerCertificate().fingerprint256).toBe(expectedCert.fingerprint256); client.end(); await once(client, "close"); @@ -3436,6 +3557,13 @@ describe.each(["tls", "net"])("%s server socket whose peer resets the connection // connection (RST) instead of ending it. Node reports that as a read // error and never emits 'end'. allowHalfOpen keeps the accepted socket open // after an 'end', so a reset misreported as an orderly end strands it. + // + // A reset discards whatever the peer's kernel hasn't sent yet, so the chunk has to fit in + // the receive window. Linux can start a connection with a 64 KiB window, and 64 KiB of data + // is larger than that as TLS records. 32 KiB is sent at once, is queued ahead of the reset + // and keeps the window open. macOS drops an RST that arrives at a zero window, so filling + // the buffers would strand the socket there. + const chunk = Buffer.alloc(32 * 1024, "r"); async function acceptPausedSocketAndFill() { const accepted = Promise.withResolvers(); const onConnection = (socket: net.Socket) => { @@ -3503,10 +3631,6 @@ describe.each(["tls", "net"])("%s server socket whose peer resets the connection server.close(); }, }; - // One chunk that fits: it is queued ahead of the reset and the receive window - // stays open. macOS drops an RST that arrives at a zero window, so filling - // the buffers would strand the socket there. - const chunk = Buffer.alloc(64 * 1024, "r"); const written = peer.write(chunk); if (written !== chunk.length) { t[Symbol.dispose](); @@ -3523,7 +3647,7 @@ describe.each(["tls", "net"])("%s server socket whose peer resets the connection // The data queued ahead of the reset was read off the socket before it closed // (kept in the paused stream's buffer), not discarded with the fd. Windows // discards the receive queue on a reset. - if (!isWindows) expect(t.socket.bytesRead).toBe(64 * 1024); + if (!isWindows) expect(t.socket.bytesRead).toBe(chunk.length); }); it("delivers the data queued ahead of the reset and then reports ECONNRESET, not 'end'", async () => { @@ -4062,8 +4186,7 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("server names after close() (%s)" return promise; } - // Bun only: in Node a user SNICallback replaces the addContext() entries, so this is agent2 there. - it("an SNICallback that is pending at close() and then selects nothing falls back to addContext()", async () => { + it("an SNICallback that is pending at close() and then selects nothing serves the default context", async () => { const pending = Promise.withResolvers(); const server: Server = createServer({ ...agent(2), SNICallback: (_, cb) => pending.resolve(cb) }); server.addContext("a.example", agent(3)); @@ -4072,7 +4195,7 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("server names after close() (%s)" const cb = await pending.promise; server.close(); cb(null, null); - expect(await served).toBe("agent3"); + expect(await served).toBe("agent2"); }); it("close() from inside the SNICallback does not end it for the other connections", async () => { @@ -4255,6 +4378,96 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("addContext() on connections the }); }); +// A Duplex has no fd to adopt, so these handshakes run on the stream TLS engine. +describe.each(["TLSv1.2", "TLSv1.3"] as const)("server names on a TLS socket over a Duplex (%s)", version => { + const fixture = (name: string) => readFileSync(join(import.meta.dir, "fixtures", name), "utf8"); + const agent = (n: number) => ({ key: fixture(`agent${n}-key.pem`), cert: fixture(`agent${n}-cert.pem`) }); + + function connectTo(servername: string, wrap: (serverSide: Duplex) => void) { + const [clientSide, serverSide] = duplexPair(); + wrap(serverSide); + return connect({ + socket: clientSide, + servername, + rejectUnauthorized: false, + minVersion: version, + maxVersion: version, + }).on("close", () => serverSide.destroy()); + } + + async function servedCN(servername: string, wrap: (serverSide: Duplex) => void) { + const client = connectTo(servername, wrap); + try { + await once(client, "secureConnect"); + return (client.getPeerCertificate() as PeerCertificate).subject.CN; + } finally { + client.destroy(); + } + } + + it("new TLSSocket(duplex, { isServer, SNICallback }) serves the context the callback selects", async () => { + const asked: string[] = []; + const served = await servedCN("b.example", serverSide => { + new TLSSocket(serverSide, { + isServer: true, + ...agent(1), + SNICallback(servername, callback) { + asked.push(servername); + callback(null, tls.createSecureContext(agent(2))); + }, + }).on("error", () => {}); + }); + expect({ asked, served }).toEqual({ asked: ["b.example"], served: "agent2" }); + }); + + it("server.emit('connection', duplex) waits for an asynchronous SNICallback", async () => { + const tlsServer = createServer({ + ...agent(1), + SNICallback: (_servername, callback) => void setImmediate(callback, null, tls.createSecureContext(agent(2))), + }); + expect(await servedCN("b.example", serverSide => tlsServer.emit("connection", serverSide))).toBe("agent2"); + }); + + it("server.emit('connection', duplex) applies addContext() entries", async () => { + const tlsServer = createServer(agent(1)); + tlsServer.addContext("b.example", agent(2)); + const wrap = (serverSide: Duplex) => tlsServer.emit("connection", serverSide); + expect([await servedCN("b.example", wrap), await servedCN("other.example", wrap)]).toEqual(["agent2", "agent1"]); + }); + + it("bounds what it buffers while the SNICallback is pending", async () => { + const asked = Promise.withResolvers(); + const tlsServer = createServer({ ...agent(1), SNICallback: () => asked.resolve() }); + const clientError = once(tlsServer, "tlsClientError"); + let transport: Duplex; + const client = connectTo("b.example", serverSide => { + transport = serverSide; + tlsServer.emit("connection", serverSide); + }); + client.on("error", () => {}); + try { + await asked.promise; + transport!.push(Buffer.alloc(16385)); + expect((await clientError)[0]).toMatchObject({ code: "ECONNRESET" }); + } finally { + client.destroy(); + } + }); + + it("reports an SNICallback error as 'tlsClientError'", async () => { + const failure = new Error("no context for this name"); + const tlsServer = createServer({ ...agent(1), SNICallback: (_servername, callback) => callback(failure) }); + const clientError = once(tlsServer, "tlsClientError"); + const client = connectTo("b.example", serverSide => tlsServer.emit("connection", serverSide)); + client.on("error", () => {}); + try { + expect((await clientError)[0]).toBe(failure); + } finally { + client.destroy(); + } + }); +}); + it("addContext() with a NUL in the name registers nothing under the part before it", async () => { const fixture = (name: string) => readFileSync(join(import.meta.dir, "fixtures", name), "utf8"); const agent = (n: number) => ({ key: fixture(`agent${n}-key.pem`), cert: fixture(`agent${n}-cert.pem`) }); @@ -5164,6 +5377,88 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("accepted and injected connection }); // The kernel rejects a send() to a peer that is gone. That ends the writes, not the reads. +describe("session ticket keys", () => { + it.each(["TLSv1.2", "TLSv1.3"] as const)( + "servers with the same keys resume each other's sessions (%s)", + async version => { + const serve = (ticketKeys?: Buffer) => + createServer({ ...COMMON_CERT, ticketKeys }, socket => socket.on("error", () => {}).end("x")).listen( + 0, + "127.0.0.1", + ); + const keys = crypto.randomBytes(48); + const [one, other] = [serve(keys), serve(keys)]; + async function resumes(server: Server, session?: Buffer) { + const { port } = server.address() as AddressInfo; + const client = connect({ + port, + host: "127.0.0.1", + ca: COMMON_CERT.cert, + minVersion: version, + maxVersion: version, + session, + }); + let issued: Buffer | undefined; + client.on("session", s => (issued = s)); + client.resume(); + await once(client, "secureConnect"); + const reused = client.isSessionReused(); + await once(client, "close"); + return { reused, issued }; + } + try { + await Promise.all([once(one, "listening"), once(other, "listening")]); + const { issued } = await resumes(one); + expect((await resumes(other, issued)).reused).toBe(true); + + other.setTicketKeys(crypto.randomBytes(48)); + expect((await resumes(other, issued)).reused).toBe(false); + + other.setTicketKeys(one.getTicketKeys()); + expect((await resumes(other, issued)).reused).toBe(true); + } finally { + one.close(); + other.close(); + } + }, + ); + + it("getTicketKeys() returns a copy of the keys in use", () => { + const keys = crypto.randomBytes(48); + const server: Server = createServer({ ...COMMON_CERT, ticketKeys: keys }); + server.getTicketKeys().fill(0); + expect(server.getTicketKeys()).toEqual(keys); + + const replacement = new DataView(crypto.randomBytes(48).buffer); + server.setTicketKeys(replacement as unknown as Buffer); + expect(server.getTicketKeys()).toEqual(Buffer.from(replacement.buffer)); + + // As in Node, a new context starts from the `ticketKeys` option again. + server.setSecureContext(COMMON_CERT); + expect(server.getTicketKeys()).toEqual(keys); + + const generated = createServer(COMMON_CERT).getTicketKeys(); + expect(generated).toBeInstanceOf(Buffer); + expect(generated).toHaveLength(48); + expect(generated).not.toEqual(createServer(COMMON_CERT).getTicketKeys()); + }); + + it("setTicketKeys() rejects anything but 48 bytes", () => { + const server: Server = createServer(COMMON_CERT); + const before = server.getTicketKeys(); + expect(() => server.setTicketKeys(Buffer.alloc(47))).toThrow( + expect.objectContaining({ + code: "ERR_INTERNAL_ASSERTION", + message: expect.stringMatching(/^Session ticket keys must be a 48-byte buffer\n/), + }), + ); + expect(() => server.setTicketKeys(new ArrayBuffer(48) as unknown as Buffer)).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + expect(server.getTicketKeys()).toEqual(before); + }); +}); + describe("a send that the kernel rejects", () => { const turns = async (count: number) => { for (let i = 0; i < count; i++) await new Promise(setImmediate); diff --git a/test/js/node/tls/node-tls-upgrade.test.ts b/test/js/node/tls/node-tls-upgrade.test.ts index e41a1cdc8629..3d6a3187494a 100644 --- a/test/js/node/tls/node-tls-upgrade.test.ts +++ b/test/js/node/tls/node-tls-upgrade.test.ts @@ -1,7 +1,8 @@ import { expect, test } from "bun:test"; import { spawn } from "child_process"; -import { once } from "events"; +import { EventEmitter, once } from "events"; import { bunEnv, bunExe, tls as certs } from "harness"; +import http from "http"; import net from "net"; import { Duplex } from "stream"; import tls from "tls"; @@ -454,3 +455,263 @@ test("_parent is the wrapped net.Socket", async () => { closeAll(server, sockets); } }); + +test("a STARTTLS upgrade doesn't reset the wrapped socket's bytesRead and bytesWritten", async () => { + const server = net.createServer(socket => { + socket.on("error", () => {}); + socket.once("data", () => { + socket.write("GO", () => { + const tlsSocket = new tls.TLSSocket(socket, { isServer: true, ...certs }); + tlsSocket.on("error", () => {}); + tlsSocket.on("data", data => tlsSocket.end("echo:" + data)); + }); + }); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + try { + const raw = net.connect((server.address() as net.AddressInfo).port, "127.0.0.1"); + raw.write("STARTTLS"); + await once(raw, "data"); + const tlsSocket = tls.connect({ socket: raw, ca: certs.cert, servername: "localhost" }); + expect([raw.bytesRead, raw.bytesWritten]).toEqual([2, 8]); + tlsSocket.on("secureConnect", () => tlsSocket.write("hi")); + tlsSocket.resume(); + await once(tlsSocket, "close"); + expect([tlsSocket.bytesRead, tlsSocket.bytesWritten]).toEqual([7, 2]); + // The wrapped socket also counts the TLS records it read. + expect(raw.bytesRead).toBeGreaterThan(2 + 7); + expect(raw.bytesWritten).toBeGreaterThanOrEqual(8); + } finally { + server.close(); + } +}); + +// Sends CONNECT to an http server and resolves once the answer's head arrived. Bytes after the head stay readable. +async function openTunnel(proxy: http.Server) { + const raw = net.connect((proxy.address() as net.AddressInfo).port, "127.0.0.1"); + raw.write("CONNECT example.test:443 HTTP/1.1\r\nHost: example.test:443\r\n\r\n"); + const { promise, resolve, reject } = Promise.withResolvers(); + let head = Buffer.alloc(0); + raw.on("error", reject); + raw.on("data", function readAnswer(chunk: Buffer) { + head = Buffer.concat([head, chunk]); + const end = head.indexOf("\r\n\r\n"); + if (end === -1) return; + raw.removeListener("data", readAnswer); + raw.pause(); + raw.unshift(head.subarray(end + 4)); + resolve(raw); + }); + return promise; +} + +test("a server-side TLSSocket over an http 'connect' socket isn't connecting after the handshake", async () => { + const atSecure = Promise.withResolvers(); + const proxy = http.createServer(); + proxy.on("connect", (_request, socket) => { + socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + const secure = wrapAndEcho(socket); + secure.once("secure", () => atSecure.resolve({ connecting: secure.connecting, readyState: secure.readyState })); + secure.once("close", () => atSecure.reject(new Error("closed before 'secure'"))); + }); + await once(proxy.listen(0, "127.0.0.1"), "listening"); + const raw = await openTunnel(proxy); + try { + expect(await pingOverTLS(raw)).toBe("ping"); + expect(await atSecure.promise).toEqual({ connecting: false, readyState: "open" }); + } finally { + raw.destroy(); + proxy.closeAllConnections(); + proxy.close(); + } +}); + +test("tls.connect({ socket }) completes the handshake over an http server's 'connect' socket", async () => { + const echoed = Promise.withResolvers(); + const proxy = http.createServer(); + proxy.on("connect", (_request, socket) => { + socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + pingOverTLS(socket).then(echoed.resolve, echoed.reject); + }); + await once(proxy.listen(0, "127.0.0.1"), "listening"); + const raw = await openTunnel(proxy); + try { + wrapAndEcho(raw); + expect(await echoed.promise).toBe("ping"); + } finally { + raw.destroy(); + proxy.closeAllConnections(); + proxy.close(); + } +}); + +// Like a Duplex from another streams implementation: it has a stream's methods but isn't a stream.Duplex. +class EmitterDuplex extends EventEmitter { + #socket: net.Socket; + constructor(socket: net.Socket) { + super(); + this.#socket = socket; + for (const event of ["data", "end", "close", "error"]) socket.on(event, (...args) => this.emit(event, ...args)); + } + pause() { + this.#socket.pause(); + return this; + } + resume() { + this.#socket.resume(); + return this; + } + cork() {} + uncork() {} + write(chunk: Buffer, ...rest: any[]) { + return this.#socket.write(chunk, ...rest); + } + end(...args: any[]) { + this.#socket.end(...args); + return this; + } + destroy(err?: Error) { + this.#socket.destroy(err); + return this; + } +} + +test.each([ + ["an EventEmitter with a stream's methods", (socket: net.Socket) => new EmitterDuplex(socket)], + // Only a net.Socket's handle has an fd the TLS layer can adopt. + ["a Duplex with its own _handle", (socket: net.Socket) => Object.assign(duplexOver(socket), { _handle: {} })], +])("wraps %s on both sides", async (_name, streamOver) => { + const sockets: net.Socket[] = []; + const server = net.createServer(accepted => { + sockets.push(accepted); + wrapAndEcho(streamOver(accepted) as unknown as Duplex); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const raw = net.connect((server.address() as net.AddressInfo).port, "127.0.0.1"); + sockets.push(raw); + try { + await once(raw, "connect"); + expect(await pingOverTLS(streamOver(raw) as unknown as Duplex)).toBe("ping"); + } finally { + closeAll(server, sockets); + } +}); + +test("throws a TypeError for a socket that isn't a stream", () => { + const server = tls.createServer(certs); + const values = [{}, [], 42, "text", () => {}, new EventEmitter(), { pause: 1 }]; + const errorNames = (wrap: (value: any) => unknown) => + values.map(value => { + try { + wrap(value); + } catch (err) { + return (err as Error).name; + } + }); + const everyTime = values.map(() => "TypeError"); + expect(errorNames(value => new tls.TLSSocket(value, { isServer: true, ...certs }))).toEqual(everyTime); + expect(errorNames(value => server.emit("connection", value))).toEqual(everyTime); + expect(errorNames(value => tls.connect({ socket: value }))).toEqual(everyTime); +}); + +test("tls.Server starts a TLS session inside its own TLSSocket when that socket is emitted again", async () => { + let sessions = 0; + const server = tls.createServer(certs, socket => { + socket.on("error", () => {}); + if (++sessions === 1) server.emit("connection", socket); + else socket.on("data", chunk => socket.write(chunk)); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const { port } = server.address() as net.AddressInfo; + const outer = tls.connect({ port, host: "127.0.0.1", ca: certs.cert }); + try { + await once(outer, "secureConnect"); + expect(await pingOverTLS(outer)).toBe("ping"); + } finally { + outer.destroy(); + server.close(); + } +}); + +test.each(["tls.connect({ socket })", "new TLSSocket(socket, { isServer: true })"])( + "%s sends a pre-handshake write once over a stream that emits 'drain' in write()", + async writer => { + const drainsInWrite = (raw: net.Socket) => { + const duplex = duplexOver(raw); + const write = duplex.write; + duplex.write = function (...args: any[]) { + const result = write.apply(this, args); + this.emit("drain"); + return result; + }; + return duplex; + }; + const payload = Buffer.alloc(256 * 1024, "a"); + const received = Promise.withResolvers(); + const countBytes = (socket: tls.TLSSocket) => { + let bytes = 0; + socket.on("data", chunk => (bytes += chunk.length)); + socket.on("end", () => received.resolve(bytes)); + socket.on("error", received.reject); + }; + const sockets: net.Socket[] = []; + const server = net.createServer(accepted => { + sockets.push(accepted); + const isWriter = writer !== "tls.connect({ socket })"; + const secure = new tls.TLSSocket(isWriter ? drainsInWrite(accepted) : accepted, { isServer: true, ...certs }); + if (isWriter) secure.on("error", received.reject).end(payload); + else countBytes(secure); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const raw = net.connect((server.address() as net.AddressInfo).port, "127.0.0.1"); + sockets.push(raw); + try { + await once(raw, "connect"); + const isWriter = writer === "tls.connect({ socket })"; + const client = tls.connect({ socket: isWriter ? drainsInWrite(raw) : raw, ca: certs.cert }); + if (isWriter) client.on("error", received.reject).end(payload); + else countBytes(client); + expect(await received.promise).toBe(payload.length); + } finally { + closeAll(server, sockets); + } + }, +); + +test("sends a write made before the wrapped net.Socket connects once", async () => { + const backlog = Buffer.alloc(64 * 1024, "x"); + const received = Promise.withResolvers(); + const sockets: net.Socket[] = []; + const peer = net.createServer(accepted => { + sockets.push(accepted); + let skipped = 0; + accepted.on("data", function skipBacklog(chunk: Buffer) { + skipped += chunk.length; + if (skipped < backlog.length) return; + accepted.removeListener("data", skipBacklog); + accepted.pause(); + accepted.unshift(chunk.subarray(chunk.length - (skipped - backlog.length))); + let text = ""; + tls + .connect({ socket: accepted, ca: certs.cert }) + .on("data", data => (text += data)) + .on("end", () => received.resolve(text)) + .on("error", received.reject); + }); + }); + await once(peer.listen(0, "127.0.0.1"), "listening"); + const raw = net.connect((peer.address() as net.AddressInfo).port, "127.0.0.1"); + sockets.push(raw); + try { + raw.write(backlog); + const secure = new tls.TLSSocket(raw, { isServer: true, ...certs }); + secure.on("error", received.reject); + secure.end("hello"); + expect({ received: await received.promise, bytesWritten: secure.bytesWritten }).toEqual({ + received: "hello", + bytesWritten: 5, + }); + } finally { + closeAll(peer, sockets); + } +}); diff --git a/test/js/node/tls/renegotiation.test.ts b/test/js/node/tls/renegotiation.test.ts index c1e2c7c7da34..6630f929ed51 100644 --- a/test/js/node/tls/renegotiation.test.ts +++ b/test/js/node/tls/renegotiation.test.ts @@ -645,26 +645,40 @@ it.concurrent.each([ ); // After its own close_notify the client cannot answer the request. That is the end of the client's own close, not an -// error, and no second 'secureConnect'. -it.concurrent.each(["TCP", "a Duplex"])( - "a request for a renegotiation after the client's own end() is not an 'error' over %s", - async transport => { - const { socket, raw } = connectOver(transport, afterEndPort, { ca: tls.cert }); +// error, and no second 'secureConnect'. With an untrusted chain the session keeps its handshake's verify error, which +// must not be reported as the result of a second handshake. +it.concurrent.each([ + { transport: "TCP", trusted: true }, + { transport: "a Duplex", trusted: true }, + { transport: "TCP", trusted: false }, + { transport: "a Duplex", trusted: false }, +] as const)( + "a request for a renegotiation after the client's own end() is not an 'error' over $transport (trusted chain: $trusted)", + async ({ transport, trusted }) => { + const { socket, raw } = connectOver( + transport, + afterEndPort, + trusted ? { ca: tls.cert } : { rejectUnauthorized: false }, + ); const events: string[] = []; const closed = Promise.withResolvers(); socket.on("secureConnect", () => { - events.push(`secureConnect authorized=${socket.authorized}`); + events.push(`secureConnect authorized=${socket.authorized} authError=${socket.authorizationError}`); socket.end("go"); }); socket.on("error", (err: NodeJS.ErrnoException) => events.push(`error ${err.code}: ${err.message}`)); socket.on("close", (hadError: boolean) => { - events.push(`close hadError=${hadError}`); + events.push(`close hadError=${hadError} authError=${socket.authorizationError}`); closed.resolve(); }); socket.resume(); try { await closed.promise; - expect(events).toEqual(["secureConnect authorized=true", "close hadError=false"]); + const authError = trusted ? null : "DEPTH_ZERO_SELF_SIGNED_CERT"; + expect(events).toEqual([ + `secureConnect authorized=${trusted} authError=${authError}`, + `close hadError=false authError=${authError}`, + ]); } finally { socket.destroy(); raw?.destroy(); @@ -1397,7 +1411,8 @@ it.concurrent( }, ); -it.concurrent("a write made in the middle of a renegotiation leaves when it ends, over a Duplex", async () => { +const duplexes = ["a Duplex", "a Duplex that emits 'drain' in its write()"] as const; +it.concurrent.each(duplexes)("a write made in the middle of a renegotiation leaves once, over %s", async duplexKind => { const tcp = netConnect(quiet.staysOpen, "127.0.0.1"); tcp.on("error", () => {}); const APPLICATION_DATA = 23; @@ -1408,6 +1423,7 @@ it.concurrent("a write made in the middle of a renegotiation leaves when it ends const duplex = new Duplex({ read() {}, write(chunk: Buffer, _encoding: string, callback: () => void) { + if (duplexKind !== "a Duplex") this.emit("drain"); const forward = () => void tcp.write(chunk, callback); if (!established) return forward(); if (sent.push(chunk[0]) === 1) clientHello.resolve(forward); @@ -1425,7 +1441,10 @@ it.concurrent("a write made in the middle of a renegotiation leaves when it ends socket.write("parked", err => (err ? written.reject(err) : written.resolve())); forward(); await Promise.race([written.promise, failed.promise]); - expect(sent.at(-1)).toBe(APPLICATION_DATA); + expect({ last: sent.at(-1), applicationData: sent.filter(type => type === APPLICATION_DATA).length }).toEqual({ + last: APPLICATION_DATA, + applicationData: 1, + }); } finally { socket.destroy(); tcp.destroy(); diff --git a/test/js/node/tls/test-use-system-ca.test.ts b/test/js/node/tls/test-use-system-ca.test.ts index c56acd91bb46..fd3e81771aa0 100644 --- a/test/js/node/tls/test-use-system-ca.test.ts +++ b/test/js/node/tls/test-use-system-ca.test.ts @@ -322,10 +322,11 @@ describe.skipIf(isWindows)("default store and OpenSSL's default paths", () => { const keys = join(import.meta.dir, "../test/fixtures/keys"); const ca1 = readFileSync(join(keys, "ca1-cert.pem"), "utf8"); - async function connectWith(env: Record) { + async function connectWith(env: Record, flags: string[] = []) { await using proc = spawn({ cmd: [ bunExe(), + ...flags, "-e", `const tls = require("tls"), fs = require("fs"); const server = tls.createServer({ key: fs.readFileSync(${JSON.stringify(join(keys, "agent1-key.pem"))}), cert: fs.readFileSync(${JSON.stringify(join(keys, "agent1-cert.pem"))}) }, s => s.end()); @@ -367,6 +368,11 @@ describe.skipIf(isWindows)("default store and OpenSSL's default paths", () => { expect(await connectWith({ SSL_CERT_FILE: join(String(dir), `${file}.pem`) })).toBe(expected); }); + test.each(["SSL_CERT_FILE", "NODE_EXTRA_CA_CERTS"])("--use-openssl-ca trusts a private CA from %s", async source => { + using dir = tempDir("openssl-ca-file", { "ca.pem": ca1 }); + expect(await connectWith({ [source]: join(String(dir), "ca.pem") }, ["--use-openssl-ca"])).toBe("authorized"); + }); + // `openssl x509 -subject_hash -in ca1-cert.pem` is 468820ba; the hash-dir lookup only opens . names. test.each([ ["468820ba.0", "authorized"], @@ -376,3 +382,156 @@ describe.skipIf(isWindows)("default store and OpenSSL's default paths", () => { expect(await connectWith({ SSL_CERT_DIR: String(dir) })).toBe(expected); }); }); + +describe.concurrent("--use-openssl-ca", () => { + test("native verification excludes bundled roots unless explicitly selected", async () => { + const root = rootCertificates.find(pem => new X509Certificate(pem).subject.includes("DigiCert Global Root G2")); + expect(root).toBeDefined(); + using dir = tempDir("openssl-bundled-root", { "root.pem": root! }); + for (const [flags, source, expected] of [ + [[], undefined, "ERR_SSL_KEY_USAGE_BIT_INCORRECT"], + [["--use-openssl-ca"], undefined, "DEPTH_ZERO_SELF_SIGNED_CERT"], + [["--use-openssl-ca"], "SSL_CERT_FILE", "ERR_SSL_KEY_USAGE_BIT_INCORRECT"], + [["--use-openssl-ca"], "NODE_EXTRA_CA_CERTS", "ERR_SSL_KEY_USAGE_BIT_INCORRECT"], + ] as const) { + await using proc = spawn({ + cmd: [ + bunExe(), + ...flags, + "-e", + ` + const net = require("node:net"); + const tls = require("node:tls"); + const { X509Certificate } = require("node:crypto"); + const cert = new X509Certificate(require("node:fs").readFileSync(${JSON.stringify(join(String(dir), "root.pem"))})).raw; + const u24 = n => Buffer.from([n >> 16, n >> 8, n]); + const handshake = (type, body) => Buffer.concat([Buffer.from([type]), u24(body.length), body]); + const hello = handshake(2, Buffer.concat([Buffer.from([3, 3]), Buffer.alloc(32, 1), Buffer.from([0, 0, 0x9c, 0])])); + const certificates = handshake(11, Buffer.concat([u24(cert.length + 3), u24(cert.length), cert])); + const flight = Buffer.concat([hello, certificates, handshake(14, Buffer.alloc(0))]); + let peer; + const server = net.createServer(socket => { + peer = socket; + socket.on("error", () => {}); + let input = Buffer.alloc(0); + const onData = chunk => { + input = Buffer.concat([input, chunk]); + if (input.length < 5 || input.length < 5 + input.readUInt16BE(3)) return; + socket.removeListener("data", onData); + socket.write(Buffer.concat([Buffer.from([22, 3, 3, flight.length >> 8, flight.length & 255]), flight])); + }; + socket.on("data", onData); + }); + server.on("error", error => { console.error(error); process.exit(1); }); + server.listen(0, "127.0.0.1", () => { + const client = tls.connect({ + host: "127.0.0.1", port: server.address().port, + minVersion: "TLSv1.2", maxVersion: "TLSv1.2", + ciphers: "AES128-GCM-SHA256", checkServerIdentity: () => undefined, + }); + client.on("error", error => console.log(error.code)); + client.on("close", () => { peer?.destroy(); server.close(); }); + }); + `, + ], + env: { + ...bunEnv, + NODE_USE_SYSTEM_CA: undefined, + NODE_EXTRA_CA_CERTS: undefined, + SSL_CERT_FILE: "", + SSL_CERT_DIR: "", + ...(source ? { [source]: join(String(dir), "root.pem") } : {}), + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // BoringSSL checks RSA key usage after chain verification, so this control needs no CA private key. + expect(stdout.trim()).toBe(expected); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + } + }); + + test.each([undefined, "0"])("excludes bundled and system roots (NODE_USE_SYSTEM_CA=%s)", async systemCA => { + await using proc = spawn({ + cmd: [ + bunExe(), + "--use-openssl-ca", + "-e", + ` + const assert = require("node:assert/strict"); + const tls = require("node:tls"); + assert.ok(tls.rootCertificates.length > 0); + assert.deepEqual(tls.getCACertificates("bundled"), tls.rootCertificates); + assert.equal(tls.getCACertificates("default").length, 0); + console.log("OK"); + `, + ], + env: { + ...bunEnv, + NODE_USE_SYSTEM_CA: systemCA, + NODE_EXTRA_CA_CERTS: undefined, + SSL_CERT_FILE: "", + SSL_CERT_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: "OK\n", stderr: "", exitCode: 0 }); + }); + + test.each([ + ["--use-openssl-ca", "-e", 'console.log("executed")'], + ["--use-openssl-ca", "install"], + ["install", "--use-openssl-ca"], + ])("rejects NODE_USE_SYSTEM_CA=1 before executing %j", async (...args) => { + using dir = tempDir("openssl-ca-conflict", { "package.json": "{}" }); + await using proc = spawn({ + cmd: [bunExe(), ...args], + cwd: String(dir), + env: { ...bunEnv, NODE_USE_SYSTEM_CA: "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: "", + stderr: "error: either --use-openssl-ca or --use-system-ca can be used, not both\n", + exitCode: 1, + }); + }); + + test("keeps NODE_EXTRA_CA_CERTS additive", async () => { + const ca = readFileSync(join(import.meta.dir, "../test/fixtures/keys/ca1-cert.pem"), "utf8"); + using dir = tempDir("openssl-extra-ca", { "ca.pem": ca }); + await using proc = spawn({ + cmd: [ + bunExe(), + "--use-openssl-ca", + "-e", + ` + const assert = require("node:assert/strict"); + const tls = require("node:tls"); + const extra = tls.getCACertificates("extra"); + assert.equal(extra.length, 1); + assert.equal(tls.getCACertificates("default").length, 1); + assert.deepEqual(tls.getCACertificates("default"), extra); + console.log("OK"); + `, + ], + env: { + ...bunEnv, + NODE_EXTRA_CA_CERTS: join(String(dir), "ca.pem"), + NODE_USE_SYSTEM_CA: undefined, + SSL_CERT_FILE: "", + SSL_CERT_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: "OK\n", stderr: "", exitCode: 0 }); + }); +}); diff --git a/test/js/node/tls/tls-sni-callback-over-duplex-fixture.mjs b/test/js/node/tls/tls-sni-callback-over-duplex-fixture.mjs new file mode 100644 index 000000000000..c1fd032dfc11 --- /dev/null +++ b/test/js/node/tls/tls-sni-callback-over-duplex-fixture.mjs @@ -0,0 +1,106 @@ +// Each action is something an SNICallback can do to its own connection while the stream TLS engine +// has the handshake suspended. Prints one JSON report per run. Runs on Bun and on Node. +import { duplexPair } from "node:stream"; +import tls from "node:tls"; + +const [door, maxVersion] = process.argv.slice(2); +const { TLS_KEY: key, TLS_CERT: cert } = process.env; +const context = tls.createSecureContext({ key, cert }); + +const actions = { + // Synchronously, inside the callback. + "answer"(cb) { + cb(null, context); + }, + "answer twice"(cb) { + cb(null, context); + cb(null, context); + }, + "answer, then fail"(cb) { + cb(null, context); + cb(new Error("boom")); + }, + "destroy(), then answer"(cb) { + this.destroy(); + cb(null, context); + }, + "destroy(err), then answer"(cb) { + this.destroy(new Error("boom")); + cb(null, context); + }, + "destroy() the transport, then answer"(cb, current) { + current.transport.destroy(); + cb(null, context); + }, + "destroy() the client, then answer"(cb, current) { + current.client.destroy(); + cb(null, context); + }, + "throw"() { + throw new Error("boom"); + }, + // While the answer is pending. + "answer later"(cb) { + setImmediate(cb, null, context); + }, + "answer later, twice"(cb) { + setImmediate(() => { + cb(null, context); + cb(null, context); + }); + }, + "destroy(), then answer later"(cb) { + this.destroy(); + setImmediate(cb, null, context); + }, + "destroy() later, then answer"(cb) { + setImmediate(() => { + this.destroy(); + cb(null, context); + }); + }, + "destroy() the client, then answer later"(cb, current) { + current.client.destroy(); + setImmediate(cb, null, context); + }, + "end(), then answer later"(cb) { + this.end(); + setImmediate(cb, null, context); + }, +}; + +let current; +const options = { + key, + cert, + maxVersion, + SNICallback(_name, cb) { + return current.action.call(this, cb, current); + }, +}; +// Each case gets its own server, so a late error is attributed to the case that caused it. +// Node also reports a bare destroy() to 'tlsClientError' as ECONNRESET. +const errorsTo = events => err => err.code === "ECONNRESET" || events.push(`server error ${err.message}`); + +const report = {}; +for (const [name, action] of Object.entries(actions)) { + const [transport, clientTransport] = duplexPair(); + const events = (report[name] = []); + current = { action, transport, events }; + const connect = () => { + const client = tls.connect({ socket: clientTransport, servername: "name.test", rejectUnauthorized: false }); + client.on("error", err => events.push(`client error ${err.code}`)); + client.on("secureConnect", () => { + events.push("client secureConnect"); + client.destroy(); + }); + current.client = client; + }; + // "staged": the ClientHello is already buffered when the server's engine starts. + if (door === "staged") connect(); + if (door === "emit") tls.createServer(options).on("tlsClientError", errorsTo(events)).emit("connection", transport); + else new tls.TLSSocket(transport, { isServer: true, ...options }).on("error", errorsTo(events)); + if (door !== "staged") connect(); + await new Promise(resolve => current.client.on("close", resolve)); +} +console.log(JSON.stringify(report)); diff --git a/test/js/node/tls/tls-syscall-fault.test.ts b/test/js/node/tls/tls-syscall-fault.test.ts index c669cca0a038..06c3d391a487 100644 --- a/test/js/node/tls/tls-syscall-fault.test.ts +++ b/test/js/node/tls/tls-syscall-fault.test.ts @@ -221,7 +221,7 @@ describe.skipIf(skip)("node:tls under injected syscall faults", () => { expect(received).toBe("a,b,c,d"); }); - // The write was reported as done with its tail still in userspace, so only the close can carry the error. + // The write stays pending while its tail waits in user space, so it fails when the kernel rejects the tail. test.each([ ["closes with the error", false], ["first delivers what the peer sent before", true], @@ -240,7 +240,7 @@ describe.skipIf(skip)("node:tls under injected syscall faults", () => { await client.closed; expect({ received: received().toString(), events: client.events, codes: client.errors.map(e => e.code) }).toEqual( peerWroteFirst - ? { received: "late", events: CLEAN_CLOSE, codes: [] } + ? { received: "late", events: ["end", "error", "close(hadError=true)"], codes: ["ECONNRESET"] } : { received: "", events: ["error", "close(hadError=true)"], codes: ["EPIPE"] }, ); }); @@ -349,7 +349,7 @@ describe.skipIf(skip)("node:tls under injected syscall faults", () => { }, ); - // The write was reported as done, so no pending write carries the error past the peer's close_notify. + // The write is still pending at the peer's close_notify, and its error comes after 'end'. test("send → EPIPE for ciphertext of a half-open socket is an error behind the peer's end", async () => { await using ending = tls.createServer({ key: certs.key, cert: certs.cert, allowHalfOpen: true }); await once(ending.listen(0, "127.0.0.1"), "listening"); @@ -372,7 +372,7 @@ describe.skipIf(skip)("node:tls under injected syscall faults", () => { }); }); - // A destroy() drops what the stream still holds for its reader, so a close at the peer's end must not come with one. + // A destroy() drops what the stream still holds for its reader, so the failed write's error must wait for 'end'. test("send → ECONNRESET for ciphertext leaves a reader that resumes later all that the peer sent before its end", async () => { using p = await connectedTLSPair(); const seen = observe(p.client); @@ -394,7 +394,7 @@ describe.skipIf(skip)("node:tls under injected syscall faults", () => { expect({ held, received: received().length, events: seen.events }).toEqual({ held: 70_000, received: 70_000, - events: CLEAN_CLOSE, + events: ["end", "error", "close(hadError=true)"], }); }); @@ -614,6 +614,94 @@ describe.skipIf(skip)("node:tls under injected syscall faults", () => { expect({ events: serverSide.events, errors: serverSide.errors }).toEqual({ events: CLEAN_CLOSE, errors: [] }); }); + // A short send of the last TLS batch leaves sealed records in user space until the next writable event. + test("send → short write of the last TLS batch holds write(cb) until the kernel has every record", async () => { + const payloadLen = 64 * 1024; + const server = tls.createServer({ key: certs.key, cert: certs.cert }); + let received = 0; + const closed = Promise.withResolvers(); + server.on("secureConnection", s => { + s.on("error", () => {}); + s.on("data", c => (received += c.length)); + s.on("close", () => closed.resolve()); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const port = (server.address() as import("node:net").AddressInfo).port; + try { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + /* js */ ` + const tls = require("node:tls"); + const { socketFaultInjection: fault } = require("bun:internal-for-testing"); + const socket = tls.connect({ port: ${port}, host: "127.0.0.1", ca: ${JSON.stringify(certs.cert)} }, () => { + fault.set({ syscall: "send", action: "short", bytes: 4096, repeat: -1 }); + socket.write(Buffer.alloc(${payloadLen}, 97), () => process.exit(0)); + }); + socket.on("error", err => { console.error(err); process.exit(1); }); + `, + ], + env: { ...bunEnv, BUN_DEBUG_QUIET_LOGS: "1" }, + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + await closed.promise; + expect({ received, stderr, exitCode }).toEqual({ received: payloadLen, stderr: "", exitCode: 0 }); + } finally { + server.close(); + } + }); + + // After the peer's FIN a socket stops holding the loop once its write completes, so the process can exit by itself. + test("send → short write of the last TLS batch: a server that answers the peer's FIN and exits delivers every byte", async () => { + const payloadLen = 64 * 1024; + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + /* js */ ` + const tls = require("node:tls"); + const { socketFaultInjection: fault } = require("bun:internal-for-testing"); + const server = tls.createServer({ key: ${JSON.stringify(certs.key)}, cert: ${JSON.stringify(certs.cert)}, allowHalfOpen: true }, socket => { + server.close(); + socket.resume(); + socket.on("end", () => { + fault.set({ syscall: "send", action: "short", bytes: 4096, repeat: -1 }); + socket.write(Buffer.alloc(${payloadLen}, 99)); + socket.end(); + }); + }); + server.listen(0, "127.0.0.1", () => console.log(server.address().port)); + `, + ], + env: { ...bunEnv, BUN_DEBUG_QUIET_LOGS: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const reader = proc.stdout.getReader(); + let firstLine = ""; + while (!firstLine.includes("\n")) { + const { value, done } = await reader.read(); + if (done) break; + firstLine += new TextDecoder().decode(value); + } + reader.releaseLock(); + const client = tls.connect( + { port: parseInt(firstLine), host: "127.0.0.1", ca: certs.cert, allowHalfOpen: true }, + () => client.end("hello"), + ); + let received = 0; + const closed = Promise.withResolvers(); + client.on("data", c => (received += c.length)); + client.on("error", closed.reject); + client.on("close", () => closed.resolve()); + await closed.promise; + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect({ received, stderr, exitCode }).toEqual({ received: payloadLen, stderr: "", exitCode: 0 }); + }); + test("recv → 0 (peer closed) on established session emits 'end' without 'error'", async () => { using p = await connectedTLSPair(); const client = observe(p.client); diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index 654d54aac7c3..d6daba7cfe2a 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -363,6 +363,51 @@ describe("execArgv option", async () => { // TODO(@190n) get our handling of non-string array elements in line with Node's }); +test("node:net autoSelectFamily defaults are per thread", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const net = require("node:net"); + const { once } = require("node:events"); + const { Worker } = require("node:worker_threads"); + const read = "[net.getDefaultAutoSelectFamily(), net.getDefaultAutoSelectFamilyAttemptTimeout()]"; + const set = "net.setDefaultAutoSelectFamily(false); net.setDefaultAutoSelectFamilyAttemptTimeout(1234);"; + async function inWorker(code, options) { + const worker = new Worker( + 'const net = require("node:net");' + code + 'require("node:worker_threads").parentPort.postMessage(' + read + ")", + { eval: true, ...options }, + ); + return (await once(worker, "message"))[0]; + } + (async () => { + const workerThatSets = await inWorker(set); + const mainAfterWorkerSet = eval(read); + const workerWithFlag = await inWorker("", { execArgv: ["--no-network-family-autoselection"] }); + const mainAfterWorkerFlag = eval(read); + eval(set); + const workerAfterMainSet = await inWorker(""); + console.log(JSON.stringify({ workerThatSets, mainAfterWorkerSet, workerWithFlag, mainAfterWorkerFlag, workerAfterMainSet })); + })(); + `, + ], + 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({ + workerThatSets: [false, 1234], + mainAfterWorkerSet: [true, 500], + workerWithFlag: [false, 500], + mainAfterWorkerFlag: [true, 500], + workerAfterMainSet: [true, 500], + }); + expect(exitCode).toBe(0); +}); + test("eval does not leak source code", async () => { const proc = Bun.spawn({ cmd: [bunExe(), "eval-source-leak-fixture.js"], diff --git a/test/js/sql/sql-default-ca.test.ts b/test/js/sql/sql-default-ca.test.ts new file mode 100644 index 000000000000..b56528c6b131 --- /dev/null +++ b/test/js/sql/sql-default-ca.test.ts @@ -0,0 +1,67 @@ +// tls: true and tls: {} carry no options of their own, so tls.setDefaultCACertificates() has to reach them directly. +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tls as serverCert, tempDir } from "harness"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const otherCA = readFileSync(join(import.meta.dir, "../node/tls/fixtures/ca1-cert.pem"), "utf8"); + +// A Postgres server up to TLS: it answers the SSLRequest and reports whether the client's startup message arrived. +const fixture = /* js */ ` + import tls from "node:tls"; + import { SQL } from "bun"; + const { cert, key, list, option } = JSON.parse(process.env.CASE); + tls.setDefaultCACertificates(list); + const started = Promise.withResolvers(); + using listener = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + data(raw) { + if (raw.data) return; + raw.data = true; + raw.write("S"); + raw.upgradeTLS({ + isServer: true, + tls: { cert, key }, + socket: { data: () => started.resolve("STARTED"), close() {}, error() {} }, + }); + }, + close() {}, + error() {}, + }, + }); + const sql = new SQL({ + url: "postgres://user@localhost:" + listener.port + "/db?sslmode=verify-full", + tls: option, + max: 1, + }); + const refused = sql\`select 1\`.then(() => "OK", e => "REFUSED"); + console.log(await Promise.race([started.promise, refused])); + process.exit(0); +`; + +test.concurrent.each([ + ["true trusts the default list", true, [serverCert.cert], "STARTED", false], + ["{} trusts the default list", {}, [serverCert.cert], "STARTED", false], + ["true refuses a certificate outside it", true, [otherCA], "REFUSED", false], + ["true trusts nothing with an empty list", true, [], "REFUSED", false], + ["true refuses what only the runtime store trusts", true, [otherCA], "REFUSED", true], + ["{} refuses what only the runtime store trusts", {}, [otherCA], "REFUSED", true], + ["true with an empty list refuses what the runtime store trusts", true, [], "REFUSED", true], +])("tls: %s", async (_name, option, list, expected, runtimeTrusts) => { + using dir = tempDir("sql-default-ca", { "run.mjs": fixture, "trusted.pem": serverCert.cert }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "run.mjs"], + env: { + ...bunEnv, + ...(runtimeTrusts && { SSL_CERT_FILE: join(String(dir), "trusted.pem") }), + CASE: JSON.stringify({ cert: serverCert.cert, key: serverCert.key, list, option }), + }, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr }).toEqual({ stdout: expected, stderr: "" }); + expect(exitCode).toBe(0); +}); diff --git a/test/js/web/workers/worker-terminate-lifetime.test.ts b/test/js/web/workers/worker-terminate-lifetime.test.ts index 92c79f6db66d..2f55b8c73181 100644 --- a/test/js/web/workers/worker-terminate-lifetime.test.ts +++ b/test/js/web/workers/worker-terminate-lifetime.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isDebug, isWindows, tempDir, tls } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, isMacOS, isWindows, tempDir, tls } from "harness"; import { join } from "path"; // Worker VM startup/teardown is much slower under debug and/or ASAN; these @@ -1305,3 +1305,88 @@ test( }, timeout, ); + +// On macOS every event loop allocates a mach port as its cross-thread wakeup channel. The child +// counts its own receive rights with mach_port_names across Worker create/terminate cycles. +test.skipIf(!isMacOS)( + "terminated Workers do not leak mach receive rights", + async () => { + const cycles = slow ? 6 : 10; + using dir = tempDir("worker-machport-leak", { + "worker.js": `postMessage("ready");\nsetInterval(() => {}, 1e9);\n`, + "main.js": ` + import { dlopen, ptr, toArrayBuffer } from "bun:ffi"; + + const { symbols } = dlopen("/usr/lib/libSystem.B.dylib", { + task_self_trap: { args: [], returns: "u32" }, + mach_port_names: { args: ["u32", "ptr", "ptr", "ptr", "ptr"], returns: "i32" }, + }); + + const task = symbols.task_self_trap(); + const MACH_PORT_TYPE_RECEIVE = 1 << 17; + + function countReceiveRights() { + const names = new BigUint64Array(1); + const namesCnt = new Uint32Array(1); + const types = new BigUint64Array(1); + const typesCnt = new Uint32Array(1); + const kr = symbols.mach_port_names(task, ptr(names), ptr(namesCnt), ptr(types), ptr(typesCnt)); + if (kr !== 0) throw new Error("mach_port_names failed: " + kr); + const cnt = typesCnt[0]; + const arr = new Uint32Array(toArrayBuffer(Number(types[0]), 0, cnt * 4)); + let n = 0; + for (let i = 0; i < cnt; i++) { + if (arr[i] & MACH_PORT_TYPE_RECEIVE) n++; + } + return n; + } + + const workerUrl = new URL("./worker.js", import.meta.url).href; + + async function cycle() { + const w = new Worker(workerUrl); + await new Promise((resolve, reject) => { + w.onmessage = resolve; + w.onerror = e => reject(new Error("worker error: " + (e && e.message))); + }); + await w.terminate(); + } + + // Warm up lazy one-time allocations (thread pools, dispatch state). + for (let i = 0; i < 3; i++) await cycle(); + + const CYCLES = ${cycles}; + const baseline = countReceiveRights(); + for (let i = 0; i < CYCLES; i++) await cycle(); + + // The worker thread releases the port on its exit path, which can lag terminate(). + const deadline = Date.now() + 15_000; + let current = countReceiveRights(); + while (current - baseline > CYCLES / 2 && Date.now() < deadline) { + await Bun.sleep(50); + current = countReceiveRights(); + } + + if (current - baseline > CYCLES / 2) { + console.log("LEAK baseline=" + baseline + " final=" + current + " cycles=" + CYCLES); + process.exit(1); + } + console.log("OK baseline=" + baseline + " final=" + current); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "inherit", + }); + + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + + expect(stdout).toStartWith("OK "); + expect(exitCode).toBe(0); + }, + timeout, +); diff --git a/test/leaksan.supp b/test/leaksan.supp index 06f00aa8c43d..41f7879d7e23 100644 --- a/test/leaksan.supp +++ b/test/leaksan.supp @@ -37,6 +37,8 @@ leak:JSModuleLoader__import leak:dyld::ThreadLocalVariables leak:JSC__JSModuleLoader__loadAndEvaluateModule leak:uws_create_app +# uws_create_app_with_ssl_ctx ends in a tail call to this, so an optimized build has no frame for the shim. +leak:uWS::TemplatedApp::create(ssl_ctx_st*, bool) leak:uws_h3_create_app leak:lsquic_global_init leak:us_create_quic_client_context From a9704232b70d5591854a7b3013b6f7fda57aff6c Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 22 Oct 2026 14:35:55 +0000 Subject: [PATCH 002/113] Fix the Windows and macOS test failures, the mordant findings and what review found --- packages/bun-types/bun.d.ts | 3 +- src/js/internal/http.ts | 2 +- src/js/internal/tls.ts | 2 - src/js/node/_http_outgoing.ts | 4 +- src/js/node/_http_server.ts | 7 +- src/js/node/net.ts | 7 +- src/js/node/tls.ts | 8 +- src/jsc/VirtualMachine.rs | 2 +- src/jsc/rare_data.rs | 13 +- src/runtime/node/quic/tls.rs | 16 ++- src/runtime/server/ServerConfig.rs | 7 +- src/runtime/socket/Listener.rs | 51 ++----- src/runtime/socket/SSLConfig.rs | 13 +- src/runtime/socket/socket_body.rs | 36 +++-- src/runtime/socket/tls_socket_functions.rs | 72 +++++----- test/cli/hot/hot.test.ts | 47 +++++++ test/expectations.txt | 5 + test/js/bun/net/socket.test.ts | 128 +++++++++++++++++- .../js/bun/net/tls-fatal-error-closes.test.ts | 17 ++- test/js/bun/spawn/spawn.ipc.test.ts | 2 + test/js/first_party/ws/ws.test.ts | 35 +++++ test/js/node/http/node-http.test.ts | 97 ++++++++----- test/js/node/http/node-https-server.test.ts | 37 +++++ test/js/node/net/node-net.test.ts | 115 +++++++++------- test/js/node/quic/quic-sni.test.ts | 28 ++-- test/js/node/tls/node-tls-context.test.ts | 24 ++-- 26 files changed, 557 insertions(+), 221 deletions(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 457de353fdea..0f478aee8ea9 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -6879,8 +6879,7 @@ declare module "bun" { * Call the {@link SocketHandler.timeout `timeout`} handler once, `seconds` seconds from now. * * The socket stays open. Call {@link end `end()`} or {@link terminate `terminate()`} in the handler to close it. - * `end()` first sends the data an earlier `end(data)` queued, so it never closes a socket whose peer stopped - * reading. `terminate()` drops that data and closes right away. + * Both drop the data an earlier `end(data)` queued and the peer hasn't taken yet. * * Reads and writes don't restart the timer. To reset the timeout, call this function again. `0` cancels it. * diff --git a/src/js/internal/http.ts b/src/js/internal/http.ts index 1ab050a6cc2a..733f0e130fd6 100644 --- a/src/js/internal/http.ts +++ b/src/js/internal/http.ts @@ -10,7 +10,7 @@ const { setServerCustomOptions, setServerAppFlags, setServerMaxHeadersCount, dra useStrictMethodValidation: boolean, lenientHttpFlags: number, maxHeaderSize: number, - onClientError: (ssl: boolean, socket: any, errorCode: number, rawPacket: ArrayBuffer) => undefined, + onClientError: (socket: any, errorCode: number, rawPacket: ArrayBuffer) => undefined, onConnection?: (socketHandle: any, secured: boolean) => undefined, ) => void; setServerAppFlags: ( diff --git a/src/js/internal/tls.ts b/src/js/internal/tls.ts index c42d09717dca..1ad27cbd8842 100644 --- a/src/js/internal/tls.ts +++ b/src/js/internal/tls.ts @@ -376,8 +376,6 @@ function unsealPfxForNative(tls) { const unsealed = { __proto__: tls, key, cert, ca, pfx: undefined }; if (!pfxCAs) return unsealed; if (ca) unsealed.ca = $isArray(ca) ? [...ca, ...pfxCAs] : [ca, ...pfxCAs]; - // No addCACert() follows to complete the chain of a lone identity. Several carry theirs already. - if (typeof cert === "string") unsealed.cert = cert + pfxCAs[0]; return unsealed; } diff --git a/src/js/node/_http_outgoing.ts b/src/js/node/_http_outgoing.ts index 712ba22c854e..0f44c576a604 100644 --- a/src/js/node/_http_outgoing.ts +++ b/src/js/node/_http_outgoing.ts @@ -1066,10 +1066,10 @@ function queueEndCallback(msg, callback) { // When the last write fails there's no 'finish', and end() callbacks get the error like a Writable's. function onFinish(outmsg, err?) { - if (!err && !outmsg?.socket?._hadError) return void outmsg.emit("finish"); const callbacks = outmsg[kEndCallbacks]; + if (callbacks) outmsg[kEndCallbacks] = undefined; + if (!err && !outmsg.socket?._hadError) return void outmsg.emit("finish"); if (!callbacks) return; - outmsg[kEndCallbacks] = undefined; err ??= outmsg[kErrored] ?? outmsg[kSocket]?.errored ?? $ERR_STREAM_DESTROYED("end"); for (let i = 0; i < callbacks.length; i++) callbacks[i](err); } diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index a727e143bc0e..259a721c754f 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -316,6 +316,10 @@ function onTLSClientError(this: Server, err, socket) { if (!this.emit("clientError", err, socket)) socket.destroy(err); } +function emitTLSClientErrorNT(server: Server, err, socket) { + server.emit("tlsClientError", err, socket); +} + type NodeHTTPServer = import("node:http").Server; interface Server extends NodeHTTPServer { maxHeaderSize: number | undefined; @@ -1827,7 +1831,8 @@ function getNodeHTTPServerSocket() { // this.server changes when the socket is passed to another server's emit('connection'). const server = this.#listenerGeneration.server; const sniError = this[kSNIError]; - if (sniError) server.emit("tlsClientError", sniError, this); + // On the next tick, still before 'close', so a listener that throws can't skip the cleanup below. + if (sniError) process.nextTick(emitTLSClientErrorNT, server, sniError, this); const tracked = server[kTrackedConnections]; if (tracked) { tracked.delete(this); diff --git a/src/js/node/net.ts b/src/js/node/net.ts index d07c17b97146..a9bcda9b9868 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -905,6 +905,7 @@ const SocketHandlers = { if (callback && self[kRejectedWrite] === undefined) { const writeChunk = self._pendingData; const res = rejected ?? socket.$write(writeChunk || "", self._pendingEncoding || "utf8"); + self[kBytesWritten] = socket.bytesWritten; if (typeof res === "number" && res < 0) { // The retried send failed for good (peer gone): $write returned -errno. if (!holdRejectedWrite(self, socket, res, callback)) failWrite(self, res, callback); @@ -1775,6 +1776,7 @@ const SocketHandlers2 = { if (callback && self[kRejectedWrite] === undefined) { const writeChunk = self._pendingData; const res = rejected ?? socket.$write(writeChunk || "", self._pendingEncoding || "utf8"); + self[kBytesWritten] = socket.bytesWritten; if (typeof res === "number" && res < 0) { // The retried send failed for good (peer gone): $write returned -errno. if (!holdRejectedWrite(self, socket, res, callback)) failWrite(self, res, callback); @@ -3023,7 +3025,8 @@ function fdSyncWritev(data, callback) { // Returns true if write(2) failed because the fd is non-blocking, its buffer is full, and a FileSink can poll it. function isFullPipe(self, err) { - if (process.platform === "win32" || err?.code !== "EAGAIN") return false; + // Linux only. kqueue doesn't reliably report a FIFO's readiness on macOS, and a queued write could wait forever. + if (process.platform !== "linux" || err?.code !== "EAGAIN") return false; if (self[kSyncWriteSink] !== undefined) return true; const stats = require("node:fs").fstatSync(self[kSyncWriteFd]); return stats.isFIFO() || stats.isSocket(); @@ -3305,6 +3308,8 @@ Socket.prototype._write = function _write(chunk, encoding, callback) { return false; } const res = socket.$write(chunk, encoding); + // A transport whose write() destroys this socket made _destroy save the count without these bytes. + this[kBytesWritten] = socket.bytesWritten; if (res < 0) { // The kernel rejected the send outright (peer reset): $write returned the // negative errno; deliver it like the EBADF/EPIPE branch above. diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 557f35716081..9c6349c8ff72 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -689,12 +689,12 @@ function TLSSocket(socket?, options?) { // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/wrap.js#L929 if (isServer) { const sniCallback = options.SNICallback; - if (sniCallback != null) { + if (sniCallback) { validateFunction(sniCallback, "options.SNICallback"); this._SNICallback = sniCallback; } const alpnCallback = options.ALPNCallback; - if (alpnCallback != null) { + if (alpnCallback) { validateFunction(alpnCallback, "options.ALPNCallback"); if (options.ALPNProtocols) { throw $ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS(); @@ -1106,12 +1106,12 @@ function Server(options, secureConnectionListener): void { // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/wrap.js#L929 if (options != null && typeof options === "object") { const sniCallback = options.SNICallback; - if (sniCallback != null) { + if (sniCallback) { validateFunction(sniCallback, "options.SNICallback"); this._SNICallback = sniCallback; } const alpnCallback = options.ALPNCallback; - if (alpnCallback != null) { + if (alpnCallback) { validateFunction(alpnCallback, "options.ALPNCallback"); // Node forbids combining the dynamic callback with a static list. if (options.ALPNProtocols) { diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index f2bae7455132..45dd4113800b 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -6070,7 +6070,7 @@ impl VirtualMachine { rare.node_quic_callbacks.deinit(); // The next file's `node:tls` starts with no override, so native clients must too. if rare.tls_default_ca.take().is_some() { - rare.default_client_ssl_ctx = None; + rare.drop_client_tls_defaults(); } } let _ = self.event_loop_mut().drain_microtasks(); diff --git a/src/jsc/rare_data.rs b/src/jsc/rare_data.rs index 19fa36db9c3d..2bd53672817b 100644 --- a/src/jsc/rare_data.rs +++ b/src/jsc/rare_data.rs @@ -233,6 +233,8 @@ pub struct RareData { /// `bun_runtime` (it calls `SSLContextCache::get_or_create_opts`). /// Held until `tls.DEFAULT_CIPHERS` is assigned so the weak-cache entry never tombstones. pub default_client_ssl_ctx: Option, + /// The config of a `fetch` without TLS options, kept so each request doesn't copy and hash the default CA list. + pub http_client_tls_defaults: Option, /// `bun_runtime::node::StatWatcherScheduler` — erased `RefPtr` payload; /// lazy-init in `bun_runtime::node::node_fs_stat_watcher`. @@ -304,6 +306,7 @@ impl Default for RareData { test_parallel_ipc_group: SocketGroup::default(), socket_groups: SocketGroups::default(), default_client_ssl_ctx: None, + http_client_tls_defaults: None, node_fs_stat_watcher_scheduler: None, memory_pressure_watcher: None, listening_sockets_for_watch_mode: Mutex::new(Vec::new()), @@ -827,8 +830,7 @@ impl RareData { owned.extend_from_slice(ciphers); owned.push(0); self.tls_default_ciphers = Some(owned.into_boxed_slice()); - // Built from the previous list. Open connections hold their own reference. - self.default_client_ssl_ctx = None; + self.drop_client_tls_defaults(); } pub(crate) fn tls_default_ca(&self) -> Option<&[Box<[u8]>]> { @@ -838,8 +840,13 @@ impl RareData { /// An empty list trusts nothing, as in Node. pub(crate) fn set_tls_default_ca(&mut self, certs: Vec>) { self.tls_default_ca = Some(certs.into_boxed_slice()); - // Built from the previous list. Open connections hold their own reference. + self.drop_client_tls_defaults(); + } + + /// Both were built from the previous defaults. Open connections hold their own reference. + pub(crate) fn drop_client_tls_defaults(&mut self) { self.default_client_ssl_ctx = None; + self.http_client_tls_defaults = None; } pub fn push_cleanup_hook( diff --git a/src/runtime/node/quic/tls.rs b/src/runtime/node/quic/tls.rs index 46bef08df8f4..88d32c9bd563 100644 --- a/src/runtime/node/quic/tls.rs +++ b/src/runtime/node/quic/tls.rs @@ -11,6 +11,8 @@ pub(super) struct TlsConfig { pub certs_pem: Vec>, pub keys_pem: Vec>, pub ca_pem: Vec>, + /// True if there's neither a `ca` option nor a `tls.setDefaultCACertificates()` list. + pub default_ca_store: bool, pub crl_pem: Vec>, pub verify_peer_strict: bool, pub verify_hostname: bool, @@ -89,13 +91,17 @@ impl TlsConfig { tls: JSValue, is_server: bool, ) -> JsResult { + let default_ca = global.bun_vm().tls_default_ca(); let mut config = TlsConfig { is_server, alpn: Vec::new(), servername: None, certs_pem: Vec::new(), keys_pem: Vec::new(), - ca_pem: Vec::new(), + ca_pem: default_ca + .map(|certs| certs.iter().map(|cert| cert.to_vec()).collect()) + .unwrap_or_default(), + default_ca_store: default_ca.is_none(), crl_pem: Vec::new(), verify_peer_strict: false, verify_hostname: false, @@ -130,7 +136,11 @@ impl TlsConfig { config.keys_pem = collect_pem(global, v)?; } if let Some(v) = tls.get(global, "ca")? { - config.ca_pem = collect_pem(global, v)?; + let ca_pem = collect_pem(global, v)?; + if !ca_pem.is_empty() { + config.ca_pem = ca_pem; + config.default_ca_store = false; + } } if let Some(v) = tls.get(global, "crl")? { config.crl_pem = collect_pem(global, v)?; @@ -452,7 +462,7 @@ impl TlsContext { return Err("invalid TLS groups list"); } } - if config.ca_pem.is_empty() { + if config.default_ca_store { // `load_crl_store` writes to the context's store, and every context shares the default one. let store = if config.crl_pem.is_empty() { us_get_shared_default_ca_store() diff --git a/src/runtime/server/ServerConfig.rs b/src/runtime/server/ServerConfig.rs index c4a07490eb9c..3ea24f28f082 100644 --- a/src/runtime/server/ServerConfig.rs +++ b/src/runtime/server/ServerConfig.rs @@ -583,10 +583,10 @@ fn get_routes_object(global: &JSGlobalObject, arg: JSValue) -> JsResult JsResult> { - if !args.is_node_http_server || !tls.is_object() { + if !is_node_http_server || !tls.is_object() { return Ok(None); } let Some(value) = tls.get_truthy(global, "secureContext")? else { @@ -1212,6 +1212,7 @@ impl ServerConfig { } if let Some(tls) = arg.get_truthy(global, "tls")? { + let is_node_http_server = args.is_node_http_server; if tls.is_falsey() { args.ssl_config = None; } else if tls.js_type().is_array() { @@ -1242,7 +1243,7 @@ impl ServerConfig { } } } - } else if let Some(ctx) = node_http_ssl_ctx(global, &args, tls)? { + } else if let Some(ctx) = node_http_ssl_ctx(global, is_node_http_server, tls)? { args.ssl_ctx = Some(ctx); // Marks the server as TLS for every reader of `ssl_config`. args.ssl_config = Some(crate::socket::tls_true_defaults(vm, true)); diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index 8ae5b6804d11..cced5077e80a 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -111,6 +111,8 @@ pub(crate) struct Listener { pub(crate) context: bun_jsc::ContextId, /// This listener's key in `VirtualMachine::hot_map()` while it's registered there. pub(crate) hot_key: JsCell>>, + /// The `hot_reload_counter` of the last run that listened on this address. + pub(crate) hot_run: Cell, } bun_jsc::impl_abort_handle_owner!(Listener, abort_handle, |this, _cause| { @@ -300,13 +302,16 @@ impl Listener { // `--hot` runs the module again while the previous run's listener still holds the address. let hot_key = hot_reload_key(&socket_config); - if let Some(entry) = hot_key + let hot_run = VirtualMachine::get().hot_reload_counter; + if let Some(previous) = hot_key .as_deref() .and_then(|key| VirtualMachine::get().as_mut().hot_map()?.get_entry(key)) .filter(|entry| entry.tag == HOT_MAP_TAG_LISTENER) - { // SAFETY: `do_stop` and `deinit` remove the entry, so a registered listener is live. - let previous = unsafe { &*entry.ptr.cast::() }; + .map(|entry| unsafe { &*entry.ptr.cast::() }) + // This run's own listener isn't stale. Binding again fails with EADDRINUSE, which port probes rely on. + .filter(|previous| previous.hot_run.get() != hot_run) + { // Its connections stay open and run the new handlers. previous .handlers @@ -326,6 +331,7 @@ impl Listener { data.set(cx.global(), default_data); } }); + previous.hot_run.set(hot_run); return Ok(this_value); } Listener::do_stop(previous, false); @@ -380,6 +386,7 @@ impl Listener { strong_data: JsCell::new(Strong::empty()), this_value: JsCell::new(JsRef::empty()), hot_key: JsCell::new(None), + hot_run: Cell::new(0), abort_handle: bun_jsc::AbortHandle::for_owner::(), context: cx.context().id(), })); @@ -513,6 +520,7 @@ impl Listener { strong_data: JsCell::new(Strong::empty()), this_value: JsCell::new(JsRef::empty()), hot_key: JsCell::new(None), + hot_run: Cell::new(0), abort_handle: bun_jsc::AbortHandle::for_owner::(), context: cx.context().id(), })); @@ -729,6 +737,7 @@ impl Listener { }, ); self.hot_key.set(Some(key)); + self.hot_run.set(VirtualMachine::get().hot_reload_counter); } fn unregister_for_hot_reload(&self) { @@ -758,40 +767,8 @@ impl Listener { pub(crate) fn on_name_pipe_created( listener: &Listener, ) -> bun_ptr::ThisPtr> { - debug_assert!(SSL == listener.ssl); - - let this_socket = NewSocket::::new(NewSocket:: { - ref_count: bun_ptr::RefCount::init(), - handlers: JsCell::new(Some(Rc::clone(&listener.handlers))), - socket: Cell::new(uws::NewSocketHandler::::DETACHED), - protos: JsCell::new(listener.protos.clone()), - // `protos` is `Option>` so we clone the listener's slice. - flags: Cell::new(listener.accepted_socket_flags()), - owned_ssl_ctx: JsCell::new(None), - this_value: JsCell::new(jsc::JsRef::empty()), - poll_ref: JsCell::new(KeepAlive::init()), - ref_pollref_on_connect: Cell::new(true), - connection: JsCell::new(None), - local_binding: JsCell::new(None), - server_name: JsCell::new(None), - buffered_data_for_node_net: Default::default(), - bytes_written: Cell::new(0), - bytes_read: Cell::new(0), - native_callback: JsCell::new(crate::socket::NativeCallbacks::None), - twin: JsCell::new(None), - verify_error: JsCell::new(None), - latest_session: Cell::new(None), - }); - let s = this_socket; - s.ref_(); - // See `on_create`: each accepted named-pipe connection holds the loop - // on its own so `conn.unref()` is meaningful. - s.poll_ref.with_mut(|p| p.ref_(bun_io::js_vm_ctx())); - if let Some(default_data) = listener.strong_data.get().get() { - let global = listener.handlers.global_object; - NewSocket::::data_set_cached(s.get_this_value(&global), &global, default_data); - } - s + // The pipe attaches later, and `on_create` only touches a socket that is connected. + Self::on_create::(listener, uws::NewSocketHandler::::DETACHED) } /// Called from `BunListener::on_open` (uws dispatch) for every accepted socket. diff --git a/src/runtime/socket/SSLConfig.rs b/src/runtime/socket/SSLConfig.rs index 2284aab9c5bf..f48791d8f29a 100644 --- a/src/runtime/socket/SSLConfig.rs +++ b/src/runtime/socket/SSLConfig.rs @@ -327,9 +327,16 @@ pub(crate) fn http_client_defaults(vm: &VirtualMachine) -> Option NewSocket { let global = handlers.global_object; let this_value = this.get_this_value(&global); - if let Err(err) = callback.call(&global, this_value, &[this_value]) { + this.update_flags(|f| f.insert(Flags::IN_TIMEOUT_HANDLER)); + let called = callback.call(&global, this_value, &[this_value]); + this.update_flags(|f| f.remove(Flags::IN_TIMEOUT_HANDLER)); + if let Err(err) = called { handlers.call_error_handler(this_value, &[this_value, global.take_error(err)])?; } Ok(()) @@ -3410,6 +3414,9 @@ impl NewSocket { // `write_or_end` reaches `internal_flush`, which re-enters JS. let _guard = this.ref_guard(); + if this.flags.get().contains(Flags::IN_TIMEOUT_HANDLER) { + this.discard_queued_writes(); + } // No writable event reaches the raw half of an `upgradeTLS` pair, so nothing would send its tail. let queues_tail = !this.flags.get().contains(Flags::BYPASS_TLS); let result = match this.write_or_end::(global, args.mut_(), queues_tail) { @@ -3954,6 +3961,11 @@ impl NewSocket { unsafe { &*std::ptr::from_ref::(this).cast::() } } + #[inline(always)] + fn tls_handle(this: &Self) -> TlsHandle<'_> { + TlsHandle::Socket(Self::as_tls(this)) + } + #[bun_jsc::host_fn(method)] pub(crate) fn disable_renegotiation( this: &Self, @@ -3973,7 +3985,7 @@ impl NewSocket { f: &CallFrame, ) -> JsResult { if SSL { - tls_socket_functions::is_session_reused(Self::as_tls(this), g, f) + tls_socket_functions::is_session_reused(Self::tls_handle(this), g, f) } else { Ok(JSValue::FALSE) } @@ -4053,7 +4065,7 @@ impl NewSocket { f: &CallFrame, ) -> JsResult { if SSL { - tls_socket_functions::export_keying_material(Self::as_tls(this), g, f) + tls_socket_functions::export_keying_material(Self::tls_handle(this), g, f) } else { Ok(JSValue::UNDEFINED) } @@ -4065,7 +4077,7 @@ impl NewSocket { f: &CallFrame, ) -> JsResult { if SSL { - tls_socket_functions::get_ephemeral_key_info(Self::as_tls(this), g, f) + tls_socket_functions::get_ephemeral_key_info(Self::tls_handle(this), g, f) } else { Ok(JSValue::NULL) } @@ -4073,7 +4085,7 @@ impl NewSocket { #[bun_jsc::host_fn(method)] pub(crate) fn get_cipher(this: &Self, g: &JSGlobalObject, f: &CallFrame) -> JsResult { if SSL { - tls_socket_functions::get_cipher(Self::as_tls(this), g, f) + tls_socket_functions::get_cipher(Self::tls_handle(this), g, f) } else { Ok(JSValue::UNDEFINED) } @@ -4085,7 +4097,7 @@ impl NewSocket { f: &CallFrame, ) -> JsResult { if SSL { - tls_socket_functions::get_tls_peer_finished_message(Self::as_tls(this), g, f) + tls_socket_functions::get_tls_peer_finished_message(Self::tls_handle(this), g, f) } else { Ok(JSValue::UNDEFINED) } @@ -4097,7 +4109,7 @@ impl NewSocket { f: &CallFrame, ) -> JsResult { if SSL { - tls_socket_functions::get_tls_finished_message(Self::as_tls(this), g, f) + tls_socket_functions::get_tls_finished_message(Self::tls_handle(this), g, f) } else { Ok(JSValue::UNDEFINED) } @@ -4121,7 +4133,7 @@ impl NewSocket { f: &CallFrame, ) -> JsResult { if SSL { - tls_socket_functions::get_tls_version(Self::as_tls(this), g, f) + tls_socket_functions::get_tls_version(Self::tls_handle(this), g, f) } else { Ok(JSValue::NULL) } @@ -4145,7 +4157,7 @@ impl NewSocket { f: &CallFrame, ) -> JsResult { if SSL { - tls_socket_functions::get_peer_certificate(Self::as_tls(this), g, f) + tls_socket_functions::get_peer_certificate(Self::tls_handle(this), g, f) } else { Ok(JSValue::NULL) } @@ -4157,7 +4169,7 @@ impl NewSocket { f: &CallFrame, ) -> JsResult { if SSL { - tls_socket_functions::get_certificate(Self::as_tls(this), g, f) + tls_socket_functions::get_certificate(Self::tls_handle(this), g, f) } else { Ok(JSValue::UNDEFINED) } @@ -4181,7 +4193,7 @@ impl NewSocket { f: &CallFrame, ) -> JsResult { if SSL { - tls_socket_functions::get_x509_certificate(Self::as_tls(this), g, f) + tls_socket_functions::get_x509_certificate(Self::tls_handle(this), g, f) } else { Ok(JSValue::UNDEFINED) } @@ -4335,6 +4347,8 @@ bitflags::bitflags! { const ALPN_REFUSED = 1 << 16; /// A send is reading `buffered_data_for_node_net`, so its caller frees the queue if the socket closes. const SENDING_QUEUE = 1 << 17; + /// The `timeout` handler is running. `end()` closes without waiting for the peer to take `end(data)`'s tail. + const IN_TIMEOUT_HANDLER = 1 << 18; } } diff --git a/src/runtime/socket/tls_socket_functions.rs b/src/runtime/socket/tls_socket_functions.rs index a46f9ab8984b..9dd2e73d79ec 100644 --- a/src/runtime/socket/tls_socket_functions.rs +++ b/src/runtime/socket/tls_socket_functions.rs @@ -270,24 +270,24 @@ use crate::node::StringOrBuffer; // `socket_body` instance. type This = super::TLSSocket; -/// Gives the getters that only read the `SSL*` their handle. `ssl()` returns `None` once the connection has closed. -pub(crate) trait TlsHandle { - fn ssl(&self) -> Option<*mut boringssl::SSL>; +/// Gives the getters that only read the `SSL*` their handle. +#[derive(Clone, Copy)] +pub(crate) enum TlsHandle<'a> { + Socket(&'a This), + /// `this` of a JSNodeHTTPServerSocket method, the handle of a node:https `req.socket`. + NodeHTTPServerSocket(JSValue), } -impl TlsHandle for This { - fn ssl(&self) -> Option<*mut boringssl::SSL> { - self.socket.get().ssl() - } -} - -/// `this` of a JSNodeHTTPServerSocket method, the handle of a node:https `req.socket`. -struct NodeHTTPServerSocket(JSValue); - -impl TlsHandle for NodeHTTPServerSocket { - fn ssl(&self) -> Option<*mut boringssl::SSL> { - let ssl = Bun__NodeHTTPServerSocket__ssl(self.0); - (!ssl.is_null()).then_some(ssl) +impl TlsHandle<'_> { + /// Returns `None` once the connection has closed. Call it after anything that can run JS. + fn ssl(self) -> Option<*mut boringssl::SSL> { + match self { + Self::Socket(socket) => socket.socket.get().ssl(), + Self::NodeHTTPServerSocket(value) => { + let ssl = Bun__NodeHTTPServerSocket__ssl(value); + (!ssl.is_null()).then_some(ssl) + } + } } } @@ -299,55 +299,55 @@ unsafe extern "C" { // The methods of JSNodeHTTPServerSocketPrototype.cpp that `tls.TLSSocket` calls on its handle. #[bun_jsc::host_fn(export = "Bun__NodeHTTPServerSocket__getPeerCertificate")] fn node_http_get_peer_certificate(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { - get_peer_certificate(&NodeHTTPServerSocket(frame.this()), global, frame) + get_peer_certificate(TlsHandle::NodeHTTPServerSocket(frame.this()), global, frame) } #[bun_jsc::host_fn(export = "Bun__NodeHTTPServerSocket__getCertificate")] fn node_http_get_certificate(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { - get_certificate(&NodeHTTPServerSocket(frame.this()), global, frame) + get_certificate(TlsHandle::NodeHTTPServerSocket(frame.this()), global, frame) } #[bun_jsc::host_fn(export = "Bun__NodeHTTPServerSocket__getX509Certificate")] fn node_http_get_x509_certificate(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { - get_x509_certificate(&NodeHTTPServerSocket(frame.this()), global, frame) + get_x509_certificate(TlsHandle::NodeHTTPServerSocket(frame.this()), global, frame) } #[bun_jsc::host_fn(export = "Bun__NodeHTTPServerSocket__getCipher")] fn node_http_get_cipher(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { - get_cipher(&NodeHTTPServerSocket(frame.this()), global, frame) + get_cipher(TlsHandle::NodeHTTPServerSocket(frame.this()), global, frame) } #[bun_jsc::host_fn(export = "Bun__NodeHTTPServerSocket__getTLSVersion")] fn node_http_get_tls_version(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { - get_tls_version(&NodeHTTPServerSocket(frame.this()), global, frame) + get_tls_version(TlsHandle::NodeHTTPServerSocket(frame.this()), global, frame) } #[bun_jsc::host_fn(export = "Bun__NodeHTTPServerSocket__getEphemeralKeyInfo")] fn node_http_get_ephemeral_key_info( global: &JSGlobalObject, frame: &CallFrame, ) -> JsResult { - get_ephemeral_key_info(&NodeHTTPServerSocket(frame.this()), global, frame) + get_ephemeral_key_info(TlsHandle::NodeHTTPServerSocket(frame.this()), global, frame) } #[bun_jsc::host_fn(export = "Bun__NodeHTTPServerSocket__getTLSFinishedMessage")] fn node_http_get_tls_finished_message( global: &JSGlobalObject, frame: &CallFrame, ) -> JsResult { - get_tls_finished_message(&NodeHTTPServerSocket(frame.this()), global, frame) + get_tls_finished_message(TlsHandle::NodeHTTPServerSocket(frame.this()), global, frame) } #[bun_jsc::host_fn(export = "Bun__NodeHTTPServerSocket__getTLSPeerFinishedMessage")] fn node_http_get_tls_peer_finished_message( global: &JSGlobalObject, frame: &CallFrame, ) -> JsResult { - get_tls_peer_finished_message(&NodeHTTPServerSocket(frame.this()), global, frame) + get_tls_peer_finished_message(TlsHandle::NodeHTTPServerSocket(frame.this()), global, frame) } #[bun_jsc::host_fn(export = "Bun__NodeHTTPServerSocket__isSessionReused")] fn node_http_is_session_reused(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { - is_session_reused(&NodeHTTPServerSocket(frame.this()), global, frame) + is_session_reused(TlsHandle::NodeHTTPServerSocket(frame.this()), global, frame) } #[bun_jsc::host_fn(export = "Bun__NodeHTTPServerSocket__exportKeyingMaterial")] fn node_http_export_keying_material( global: &JSGlobalObject, frame: &CallFrame, ) -> JsResult { - export_keying_material(&NodeHTTPServerSocket(frame.this()), global, frame) + export_keying_material(TlsHandle::NodeHTTPServerSocket(frame.this()), global, frame) } pub(super) fn get_servername( @@ -432,7 +432,7 @@ pub(super) fn get_peer_x509_certificate( } pub(super) fn get_x509_certificate( - this: &impl TlsHandle, + this: TlsHandle<'_>, global: &JSGlobalObject, _frame: &CallFrame, ) -> JsResult { @@ -449,7 +449,7 @@ pub(super) fn get_x509_certificate( } pub(super) fn get_tls_version( - this: &impl TlsHandle, + this: TlsHandle<'_>, global: &JSGlobalObject, _frame: &CallFrame, ) -> JsResult { @@ -503,7 +503,7 @@ pub(super) fn set_max_send_fragment( } pub(super) fn get_peer_certificate( - this: &impl TlsHandle, + this: TlsHandle<'_>, global: &JSGlobalObject, frame: &CallFrame, ) -> JsResult { @@ -686,7 +686,7 @@ pub(super) fn get_peer_certificate( } pub(super) fn get_certificate( - this: &impl TlsHandle, + this: TlsHandle<'_>, global: &JSGlobalObject, _frame: &CallFrame, ) -> JsResult { @@ -702,7 +702,7 @@ pub(super) fn get_certificate( } pub(super) fn get_tls_finished_message( - this: &impl TlsHandle, + this: TlsHandle<'_>, global: &JSGlobalObject, _frame: &CallFrame, ) -> JsResult { @@ -841,7 +841,7 @@ pub(super) fn get_shared_sigalgs( } pub(super) fn get_cipher( - this: &impl TlsHandle, + this: TlsHandle<'_>, global: &JSGlobalObject, _frame: &CallFrame, ) -> JsResult { @@ -902,7 +902,7 @@ pub(super) fn get_cipher( } pub(super) fn get_tls_peer_finished_message( - this: &impl TlsHandle, + this: TlsHandle<'_>, global: &JSGlobalObject, _frame: &CallFrame, ) -> JsResult { @@ -984,7 +984,7 @@ pub(crate) fn set_key_cert( } pub(crate) fn export_keying_material( - this: &impl TlsHandle, + this: TlsHandle<'_>, global: &JSGlobalObject, frame: &CallFrame, ) -> JsResult { @@ -1062,7 +1062,7 @@ pub(crate) fn export_keying_material( } pub(super) fn get_ephemeral_key_info( - this: &impl TlsHandle, + this: TlsHandle<'_>, global: &JSGlobalObject, _frame: &CallFrame, ) -> JsResult { @@ -1288,7 +1288,7 @@ pub(super) fn disable_renegotiation( } pub(super) fn is_session_reused( - this: &impl TlsHandle, + this: TlsHandle<'_>, _global: &JSGlobalObject, _frame: &CallFrame, ) -> JsResult { diff --git a/test/cli/hot/hot.test.ts b/test/cli/hot/hot.test.ts index 41a81ed80d67..dd0ea86e1e06 100644 --- a/test/cli/hot/hot.test.ts +++ b/test/cli/hot/hot.test.ts @@ -1430,3 +1430,50 @@ it.each(isWindows ? ["tcp", "tls"] : ["tcp", "unix", "tls"])( }, timeout, ); + +// How get-port, portfinder and detect-port look for a free port. +it( + "should report EADDRINUSE when one --hot run listens on the same port twice", + async () => { + using dir = tempDir("hot-listen-twice", { + "index.ts": ` + import { once } from "node:events"; + import net from "node:net"; + + const free = net.createServer().listen(0, "127.0.0.1"); + await once(free, "listening"); + const { port } = free.address(); + free.close(); + await once(free, "close"); + + const app = net.createServer(socket => socket.end("app")); + await once(app.listen(port, "127.0.0.1"), "listening"); + + const probe = net.createServer(); + const probed = await new Promise(resolve => { + probe.once("error", err => resolve(err.code)); + probe.listen(port, "127.0.0.1", () => probe.close(() => resolve("free"))); + }); + const reply = await new Promise(resolve => { + const client = net.connect(port, "127.0.0.1"); + client.on("data", data => resolve(data.toString())); + client.on("error", err => resolve(err.code)); + }); + console.log(JSON.stringify({ probed, reply })); + process.exit(0); + `, + }); + await using runner = spawn({ + cmd: [bunExe(), "--hot", "run", "index.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", + }); + const [stdout, exitCode] = await Promise.all([runner.stdout.text(), runner.exited]); + expect(stdout).toBe(JSON.stringify({ probed: "EADDRINUSE", reply: "app" }) + "\n"); + expect(exitCode).toBe(0); + }, + timeout, +); diff --git a/test/expectations.txt b/test/expectations.txt index 646c14fe144c..e505a1b5c60d 100644 --- a/test/expectations.txt +++ b/test/expectations.txt @@ -23,6 +23,11 @@ # ships ClangCL. Runs everywhere else. (Verified still failing, build 87834.) [ WINDOWS ] test/bundler/native-plugin.test.ts [ SKIP ] # node-gyp needs a ClangCL toolset the Windows agents do not have +# The test passes an accepted named-pipe connection as a child's stdout. +# spawn() can only hand a net.Socket's descriptor to a child on POSIX. +# Passes on Linux and macOS. (Fails on both Windows lanes, build 130937.) +[ WINDOWS ] test/js/node/test/parallel/test-child-process-server-close.js [ FAIL ] # a named-pipe net.Socket can't be a child's stdio yet + # Cluster workers sharing a listen port behave differently on Linux, where # SO_REUSEPORT load-balances across the workers' own listeners instead of the # primary distributing accepted connections; the upstream test's expectations diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index 19ccb46d0c88..7e311155b019 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -13,7 +13,9 @@ import { getMaxFD, isASAN, isDebug, + isFreeBSD, isLinux, + isMacOS, isWindows, libcPathForDlopen, normalizeBunSnapshot, @@ -6355,6 +6357,7 @@ describe.concurrent("a socket closed by data() while its peer's reset is being d let acceptedFd = -1; const report = {}; + let connectReturned = false; const outcome = Promise.withResolvers(); const server = Bun.listen({ hostname: "127.0.0.1", @@ -6373,9 +6376,13 @@ describe.concurrent("a socket closed by data() while its peer's reset is being d socket: { data() {}, open: () => outcome.resolve("open"), - connectError: (_socket, error) => outcome.resolve(error.code), + connectError(_socket, error) { + if (checkFds) report.refusedInsideConnect = !connectReturned; + outcome.resolve(error.code); + }, }, }).catch(() => {}); + connectReturned = true; // Bun.connect() to an IP literal creates its socket before it returns, and that // socket has to reuse the accepted socket's fd number for the stale read to hit it. if (checkFds) report.acceptedFdReusedByConnect = isOpen(acceptedFd); @@ -6412,10 +6419,20 @@ describe.concurrent("a socket closed by data() while its peer's reset is being d stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - const expectedReport = isWindows - ? { outcome: "ECONNREFUSED" } - : { acceptedFdFreeInClose: true, acceptedFdReusedByConnect: true, outcome: "ECONNREFUSED" }; - expect({ stdout, stderr }).toEqual({ stdout: JSON.stringify(expectedReport) + "\n", stderr: "" }); + expect(stderr).toBe(""); + const report = JSON.parse(stdout); + // A BSD kernel can refuse a loopback connect inside connect(2). That socket's fd is closed again before Bun.connect() returns. + const refusedInsideConnect = (isMacOS || isFreeBSD) && report.refusedInsideConnect === true; + expect(report).toEqual( + isWindows + ? { outcome: "ECONNREFUSED" } + : { + acceptedFdFreeInClose: true, + refusedInsideConnect, + acceptedFdReusedByConnect: !refusedInsideConnect, + outcome: "ECONNREFUSED", + }, + ); expect(exitCode).toBe(0); }); }); @@ -7161,6 +7178,107 @@ describe.concurrent("end(data) larger than the send buffer", () => { expect(exitCode).toBe(0); }, ); + + // In its own process: the sweeps also advance every other socket's timeout on the thread. + // Windows takes a first send of any size whole, so nothing is queued there. + it.skipIf(isWindows)( + "calls timeout() while the tail waits on a peer that sent its FIN and stopped reading", + async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + /* js */ ` + import { sweepSocketTimers } from "bun:internal-for-testing"; + const ended = Promise.withResolvers(); + const closed = Promise.withResolvers(); + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + data: socket => console.log("end", socket.end(Buffer.alloc(16 << 20))), + end: () => (console.log("peer's FIN"), ended.resolve()), + timeout: socket => (console.log("timeout"), socket.terminate()), + close: () => (console.log("close"), closed.resolve()), + }, + }); + using peer = await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + allowHalfOpen: true, + socket: { + open(socket) { + socket.pause(); + socket.write("request"); + socket.shutdown(); + }, + data() {}, + }, + }); + await ended.promise; + // One sweep is 4 seconds, and an accepted socket starts with a 120 second timeout. + sweepSocketTimers(30); + await closed.promise; + `, + ], + 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: `end ${16 << 20}\npeer's FIN\ntimeout\nclose\n`, stderr: "" }); + expect(exitCode).toBe(0); + }, + ); + + // In its own process, and not on Windows, for the same reasons. + it.skipIf(isWindows).each(["tcp", "tls"] as const)( + "%s end() in the timeout handler closes a socket whose peer stopped reading the tail", + async transport => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + /* js */ ` + import { sweepSocketTimers } from "bun:internal-for-testing"; + const { key, cert } = JSON.parse(process.env.TEST_TLS); + const queued = Promise.withResolvers(); + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls: key ? { key, cert } : undefined, + socket: { + data: socket => (console.log("end", socket.end(Buffer.alloc(16 << 20))), queued.resolve()), + timeout: socket => (console.log("timeout"), void socket.end()), + close: () => console.log("close"), + }, + }); + function sendRequest(socket) { + socket.write("request"); + socket.pause(); + } + using peer = await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + tls: key ? { ca: cert } : undefined, + // A TLS socket can't be paused before it has read the handshake. + socket: { ...(key ? { handshake: sendRequest } : { open: sendRequest }), data() {} }, + }); + await queued.promise; + // One sweep is 4 seconds. An accepted socket starts with a 120 second timeout, and a TLS close waits 10 more for unsent records. + sweepSocketTimers(33); + console.log("done"); + `, + ], + env: { ...bunEnv, TEST_TLS: JSON.stringify(transport === "tls" ? { key: tls.key, cert: tls.cert } : {}) }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr }).toEqual({ stdout: `end ${16 << 20}\ntimeout\nclose\ndone\n`, stderr: "" }); + expect(exitCode).toBe(0); + }, + ); }); describe.concurrent.each(["tcp", "tls"] as const)( diff --git a/test/js/bun/net/tls-fatal-error-closes.test.ts b/test/js/bun/net/tls-fatal-error-closes.test.ts index c3848799732a..0a68753d34d6 100644 --- a/test/js/bun/net/tls-fatal-error-closes.test.ts +++ b/test/js/bun/net/tls-fatal-error-closes.test.ts @@ -8,7 +8,7 @@ // Two engines: openssl.c drives a socket, SSLWrapper drives TLS over a Duplex, a CONNECT tunnel or a named pipe. import { RedisClient, SQL } from "bun"; import { afterAll, describe, expect, test } from "bun:test"; -import { tls as cert } from "harness"; +import { tls as cert, isMacOS, isWindows } from "harness"; import { createHash } from "node:crypto"; import { once } from "node:events"; import https from "node:https"; @@ -493,7 +493,7 @@ test.each([false, true])("a client whose peer resets at accept hears ECONNRESET }); const events: string[] = []; const closed = Promise.withResolvers(); - await Bun.connect({ + const connected = Bun.connect({ hostname: "127.0.0.1", port: listener.port, tls: { rejectUnauthorized: false }, @@ -509,6 +509,17 @@ test.each([false, true])("a client whose peer resets at accept hears ECONNRESET }, }, }); + // On Windows the reset can arrive before connect() completes, so the promise rejects with it instead. + if ( + isWindows && + (await connected.then( + () => false, + error => error.code === "ECONNRESET", + )) + ) + return; + await connected; await closed.promise; - expect(events).toEqual(["handshake(false, ECONNRESET)", "close(ECONNRESET)"]); + // On macOS the event loop sees the reset itself, and that closes without a handshake event, like any reset mid-handshake. + expect(events).toEqual([...(isMacOS ? [] : ["handshake(false, ECONNRESET)"]), "close(ECONNRESET)"]); }); diff --git a/test/js/bun/spawn/spawn.ipc.test.ts b/test/js/bun/spawn/spawn.ipc.test.ts index 1a6ad3383f52..324c9003a516 100644 --- a/test/js/bun/spawn/spawn.ipc.test.ts +++ b/test/js/bun/spawn/spawn.ipc.test.ts @@ -606,6 +606,8 @@ it.skipIf(isWindows)( libc.close(); handle.close(); + // A failed fcntl() returns -1, and -1 & FD_CLOEXEC would read as the flag being set. + expect(fdFlags).toBeGreaterThanOrEqual(0); expect({ message, peer, cloexec: fdFlags & FD_CLOEXEC }).toEqual({ message: { hello: "handle" }, peer: { extras: ["closed", "closed"] }, diff --git a/test/js/first_party/ws/ws.test.ts b/test/js/first_party/ws/ws.test.ts index d27b640be753..9522dba3283e 100644 --- a/test/js/first_party/ws/ws.test.ts +++ b/test/js/first_party/ws/ws.test.ts @@ -1635,6 +1635,41 @@ describe("client TLS options", () => { }); }); + it("sends each certificate of a pfx once", async () => { + using server = await serve({ maxVersion: "TLSv1.2" }); + // TLS 1.2 sends the client's Certificate message in the clear, so a relay can count its entries. + let sent = Buffer.alloc(0); + const relay = net.createServer(client => { + const upstream = net.connect(Number(new URL(server.url).port), "127.0.0.1"); + client.on("data", chunk => { + sent = Buffer.concat([sent, chunk]); + upstream.write(chunk); + }); + upstream.on("data", chunk => client.write(chunk)); + client.on("error", () => {}).on("close", () => upstream.destroy()); + upstream.on("error", () => {}).on("close", () => client.destroy()); + }); + await once(relay.listen(0, "127.0.0.1"), "listening"); + try { + const url = `wss://localhost:${(relay.address() as AddressInfo).port}`; + expect(await dial(url, { ca, pfx, passphrase: "sample" })).toBe(identified); + } finally { + relay.close(); + } + let handshake = Buffer.alloc(0); + for (let at = 0; at + 5 <= sent.length && sent[at] === 22; at += 5 + sent.readUInt16BE(at + 3)) { + handshake = Buffer.concat([handshake, sent.subarray(at + 5, at + 5 + sent.readUInt16BE(at + 3))]); + } + let certificates = 0; + for (let at = 0; at + 4 <= handshake.length; at += 4 + handshake.readUIntBE(at + 1, 3)) { + if (handshake[at] !== 11) continue; + const end = at + 4 + handshake.readUIntBE(at + 1, 3); + for (let entry = at + 7; entry < end; entry += 3 + handshake.readUIntBE(entry, 3)) certificates++; + } + // agent1 and ca1. + expect(certificates).toBe(2); + }); + it("minVersion and maxVersion", async () => { using tls12 = await serve({ maxVersion: "TLSv1.2" }); using tls13 = await serve({ minVersion: "TLSv1.3" }); diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index ec2389f548ad..04d9e7c43c6c 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -615,9 +615,9 @@ describe("node:http", () => { } }); server.listen({ port: 0 }, () => { - const _done = (err?: unknown) => { + var _done = (...args) => { server.close(); - done(err); + done(...args); }; callback(server, (server.address() as AddressInfo).port, _done); }); @@ -7989,38 +7989,33 @@ it("addTrailers() and end() don't crash on the response of a request that was up expect(exitCode).toBe(0); }); -it("a listener can cancel Readable.toWeb(req) and end the response in the same tick", async () => { - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - ` - const http = require("node:http"); - const { Readable } = require("node:stream"); - const server = http.createServer((req, res) => { - const socket = req.socket; - req.on("error", () => {}); - res.on("finish", () => console.log("keep-alive timeout", socket.timeout)); - Readable.toWeb(req).cancel(); - res.end("ok"); - }); - server.keepAliveTimeout = 4000; - server.keepAliveTimeoutBuffer = 0; - server.listen(0, "127.0.0.1", async () => { - const response = await fetch("http://127.0.0.1:" + server.address().port); - console.log(response.status, await response.text()); - server.close(); - server.closeAllConnections(); - }); - `, - ], - 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: "keep-alive timeout 4000\n200 ok\n", stderr: "" }); - expect(exitCode).toBe(0); +it("a request that finished releases its end() callback", async () => { + const server = createServer((req, res) => void req.resume().on("end", () => res.end())); + await once(server.listen(0, "127.0.0.1"), "listening"); + const requests: http.ClientRequest[] = []; + const captured: WeakRef[] = []; + try { + for (let i = 0; i < 20; i++) { + const req = http.request({ + port: (server.address() as AddressInfo).port, + host: "127.0.0.1", + method: "POST", + agent: false, + }); + requests.push(req); + const value = { i }; + captured.push(new WeakRef(value)); + req.end("x", () => void value.i); + const [res] = await once(req, "response"); + await once(res.resume(), "end"); + } + await new Promise(resolve => setImmediate(resolve)); + Bun.gc(true); + expect(captured.filter(ref => ref.deref() !== undefined).length).toBeLessThan(5); + expect(requests).toHaveLength(20); + } finally { + server.close(); + } }); describe("a client request destroyed in the middle of its upload", () => { @@ -8058,3 +8053,37 @@ describe("a client request destroyed in the middle of its upload", () => { } }); }); + +it("a listener can cancel Readable.toWeb(req) and end the response in the same tick", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const http = require("node:http"); + const { Readable } = require("node:stream"); + const server = http.createServer((req, res) => { + const socket = req.socket; + req.on("error", () => {}); + res.on("finish", () => console.log("keep-alive timeout", socket.timeout)); + Readable.toWeb(req).cancel(); + res.end("ok"); + }); + server.keepAliveTimeout = 4000; + server.keepAliveTimeoutBuffer = 0; + server.listen(0, "127.0.0.1", async () => { + const response = await fetch("http://127.0.0.1:" + server.address().port); + console.log(response.status, await response.text()); + server.close(); + server.closeAllConnections(); + }); + `, + ], + 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: "keep-alive timeout 4000\n200 ok\n", stderr: "" }); + expect(exitCode).toBe(0); +}); diff --git a/test/js/node/http/node-https-server.test.ts b/test/js/node/http/node-https-server.test.ts index ef87d60925e0..e46444326ba9 100644 --- a/test/js/node/http/node-https-server.test.ts +++ b/test/js/node/http/node-https-server.test.ts @@ -661,6 +661,11 @@ describe("https.Server has the methods of tls.Server", () => { ); }); + test.each([false, 0, "", null])("SNICallback and ALPNCallback set to %p count as absent", async value => { + await using server = https.createServer({ ...agent2, SNICallback: value as any, ALPNCallback: value as any }); + expect(await servedCN(await listen(server), "a.example.com")).toBe("agent2"); + }); + test.each<[string, NonNullable, string]>([ ["passes an error", (_name, callback) => callback(new Error("no context")), "no context"], [ @@ -692,6 +697,38 @@ describe("https.Server has the methods of tls.Server", () => { await reported.promise; expect(events).toEqual([`clientError ${message}`, `tlsClientError ${message} true`]); }); + + test("close() finishes after a 'clientError' listener threw for a refused name", async () => { + const script = ` + const https = require("node:https"); + const tls = require("node:tls"); + const { key, cert } = ${JSON.stringify(agent2)}; + const connect = servername => + tls.connect({ port: server.address().port, host: "127.0.0.1", ca: cert, servername, checkServerIdentity() {} }); + let response; + const SNICallback = (name, callback) => callback(name === "refused.example.com" ? new Error("refused") : null); + const server = https.createServer({ key, cert, SNICallback }, (req, res) => { + response = res; + connect("refused.example.com").on("error", () => {}); + }); + server.on("clientError", () => { + throw new Error("listener threw"); + }); + process.on("uncaughtException", err => { + console.log(err.message); + // The first connection is still open, so 'close' waits for the connection count to reach zero. + server.close(() => server.getConnections((_, count) => console.log("closed", count))); + response.end(); + }); + server.listen(0, "127.0.0.1", () => { + connect("a.example.com").resume().write("GET / HTTP/1.1\\r\\nHost: a.example.com\\r\\nConnection: close\\r\\n\\r\\n"); + }); + `; + 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 }).toEqual({ stdout: "listener threw\nclosed 0\n", stderr: "" }); + expect(exitCode).toBe(0); + }); }); test("servers with the same ticket keys resume each other's sessions", async () => { diff --git a/test/js/node/net/node-net.test.ts b/test/js/node/net/node-net.test.ts index f350d8e997fe..b58065661c7a 100644 --- a/test/js/node/net/node-net.test.ts +++ b/test/js/node/net/node-net.test.ts @@ -41,6 +41,7 @@ import { import { join } from "node:path"; import { duplexPair } from "node:stream"; import { connect as tlsConnect, TLSSocket } from "node:tls"; +import { getSystemErrorMap } from "node:util"; const socket_domain = tmpdirSync(); @@ -1523,7 +1524,8 @@ describe("Socket fd adoption", () => { } }); - describe.skipIf(isWindows)("over a non-blocking pipe", () => { + // Other platforms still fail the write with EAGAIN. + describe.skipIf(!isLinux)("over a non-blocking pipe", () => { const nonBlocking = fs.constants.O_WRONLY | fs.constants.O_NONBLOCK; it("delivers writes bigger than the pipe's buffer", async () => { @@ -3990,30 +3992,35 @@ it("setTimeout() fires while connecting with a write queued", async () => { } }); -describe("setTimeout() with a queued write", () => { - // Returns a client whose last write is stuck behind a peer that isn't reading. - async function connectToPausedPeer() { - const accepted = Promise.withResolvers(); - const server = createServer({ pauseOnConnect: true }, accepted.resolve).listen(0, "127.0.0.1"); - await once(server, "listening"); - const socket = connect((server.address() as any).port, "127.0.0.1"); - await once(socket, "connect"); - const peer = await accepted.promise; - const chunk = Buffer.alloc(1 << 20); - while (socket.write(chunk)) {} - return { - socket, - peer, - [Symbol.dispose]() { - socket.destroy(); - peer.destroy(); - server.close(); - }, - }; - } +// Returns a connected client and a server that never reads. +async function connectToPausedPeer() { + const peers: Socket[] = []; + const server = createServer({ pauseOnConnect: true }, peer => void peers.push(peer.on("error", () => {}))); + await once(server.listen(0, "127.0.0.1"), "listening"); + const port = (server.address() as any).port as number; + const socket = connect(port, "127.0.0.1"); + await Promise.all([once(socket, "connect"), once(server, "connection")]); + return { + socket, + peer: peers[0], + port, + [Symbol.dispose]() { + socket.destroy(); + for (const peer of peers) peer.destroy(); + server.close(); + }, + }; +} +// Writes until a write has to wait for the peer. One big write isn't enough: Winsock accepts a first send of any size. +function writeUntilQueued(stream: { write(chunk: Buffer, cb: (err?: any) => void): boolean }, events: string[] = []) { + const chunk = Buffer.alloc(1 << 20); + while (stream.write(chunk, err => err && events.push(`write ${err.code}`))) {} +} +describe("setTimeout() with a queued write", () => { it("fires when the peer stops reading", async () => { using pair = await connectToPausedPeer(); + writeUntilQueued(pair.socket); pair.socket.setTimeout(10); await once(pair.socket, "timeout"); expect(pair.socket.writableLength).toBeGreaterThan(0); @@ -4022,6 +4029,7 @@ describe("setTimeout() with a queued write", () => { it("restarts the timer when the write completes", async () => { using pair = await connectToPausedPeer(); const { socket, peer } = pair; + writeUntilQueued(socket); let restarts = 0; // @ts-expect-error socket._unrefTimer = () => void restarts++; @@ -4089,15 +4097,17 @@ describe("connect() replaced on the handle", () => { }); it("destroys the socket with the errno it returns", async () => { + // libuv's UV_EACCES is -13 on POSIX and -4092 on Windows. + const [errno] = [...getSystemErrorMap()].find(([, [name]]) => name === "EACCES")!; const socket = new Socket(); try { socket.once("connectionAttempt", () => { // @ts-expect-error - socket._handle.connect = () => -13; + socket._handle.connect = () => errno; }); socket.connect({ host: "127.0.0.1", port: 1 }); const [error] = await once(socket, "error"); - expect(error).toMatchObject({ code: "EACCES", syscall: "connect", address: "127.0.0.1", port: 1 }); + expect(error).toMatchObject({ code: "EACCES", errno, syscall: "connect", address: "127.0.0.1", port: 1 }); } finally { socket.destroy(); } @@ -4124,6 +4134,37 @@ describe("bytesWritten", () => { } }); + it("counts a write during which the transport destroys the socket", async () => { + const { Duplex } = require("node:stream"); + let onClientWrite: (() => void) | undefined; + const side = (peer: () => any, onWrite?: () => void) => + new Duplex({ + read() {}, + write(chunk: Buffer, encoding: string, callback: () => void) { + onWrite?.(); + peer().push(chunk); + callback(); + }, + }); + const clientSide = side( + () => serverSide, + () => onClientWrite?.(), + ); + const serverSide = side(() => clientSide); + const server = new TLSSocket(serverSide, { isServer: true, ...tlsCert }).on("error", () => {}); + const client = require("node:tls").connect({ socket: clientSide, ca: tlsCert.cert, servername: "localhost" }); + try { + await once(client, "secureConnect"); + onClientWrite = () => client.destroy(); + client.write("hello"); + await once(client, "close"); + expect(client.bytesWritten).toBe(5); + } finally { + client.destroy(); + server.destroy(); + } + }); + it("still reports the count after the peer resets the connection", async () => { const closed = Promise.withResolvers(); const server = createServer(socket => { @@ -4322,7 +4363,8 @@ it("onread doesn't replay bytes it declined on the previous connection after rec } }); -it.concurrent.each(["client", "accepted"])( +// Linux only: a loopback RST lands before the close() that sends it returns. Elsewhere the kernel can take the rest of the write first. +it.concurrent.skipIf(!isLinux).each(["client", "accepted"])( "a peer reset with a write in flight and no 'error' listener throws the read error (%s socket)", async side => { await using proc = Bun.spawn({ @@ -4366,30 +4408,7 @@ it.concurrent.each(["client", "accepted"])( ); describe("destroy()", () => { - // Returns a connected client and a server that never reads. - async function connectToPausedPeer() { - const peers: Socket[] = []; - const server = createServer({ pauseOnConnect: true }, peer => void peers.push(peer.on("error", () => {}))); - await once(server.listen(0, "127.0.0.1"), "listening"); - const port = (server.address() as any).port as number; - const socket = connect(port, "127.0.0.1"); - await once(socket, "connect"); - return { - socket, - port, - [Symbol.dispose]() { - socket.destroy(); - for (const peer of peers) peer.destroy(); - server.close(); - }, - }; - } const code = (name: string, events: string[]) => (err?: any) => void events.push(`${name} ${err?.code}`); - // Writes until a write has to wait for the peer. One big write isn't enough: Winsock accepts a first send of any size. - function writeUntilQueued(stream: { write(chunk: Buffer, cb: (err?: any) => void): boolean }, events: string[] = []) { - const chunk = Buffer.alloc(1 << 20); - while (stream.write(chunk, err => err && events.push(`write ${err.code}`))) {} - } it("emits 'close' without 'end'", async () => { using pair = await connectToPausedPeer(); diff --git a/test/js/node/quic/quic-sni.test.ts b/test/js/node/quic/quic-sni.test.ts index d71cb469a026..0b674b6181c6 100644 --- a/test/js/node/quic/quic-sni.test.ts +++ b/test/js/node/quic/quic-sni.test.ts @@ -353,6 +353,7 @@ test("a context without `ca` verifies against the default CA store", async () => import { readFileSync } from "node:fs"; import { join } from "node:path"; import { connect, listen } from "node:quic"; + import { setDefaultCACertificates } from "node:tls"; const readKey = name => readFileSync(join(${JSON.stringify(keysDir)}, name)); const identity = (agent, cert = readKey(agent + "-cert.pem")) => ({ @@ -388,16 +389,20 @@ test("a context without `ca` verifies against the default CA store", async () => return outcome; } - console.log( - JSON.stringify({ - SSL_CERT_FILE: await verdict("agent1"), - NODE_EXTRA_CA_CERTS: await verdict("agent3"), - bundledRoot: await verdict("bundled-root-probe"), - caReplacesTheDefaults: await verdict("agent3", { ca: "ca1-cert.pem" }), - crl: await verdict("agent3", { crl: "ca2-crl-agent3.pem" }), - nextContextWithoutCrl: await verdict("agent3"), - }), - ); + const verdicts = { + SSL_CERT_FILE: await verdict("agent1"), + NODE_EXTRA_CA_CERTS: await verdict("agent3"), + bundledRoot: await verdict("bundled-root-probe"), + caReplacesTheDefaults: await verdict("agent3", { ca: "ca1-cert.pem" }), + crl: await verdict("agent3", { crl: "ca2-crl-agent3.pem" }), + nextContextWithoutCrl: await verdict("agent3"), + }; + setDefaultCACertificates([]); + verdicts.emptyDefaultList = await verdict("agent3"); + setDefaultCACertificates([readKey("ca1-cert.pem")]); + verdicts.inDefaultList = await verdict("agent1"); + verdicts.notInDefaultList = await verdict("agent3"); + console.log(JSON.stringify(verdicts)); await server.close(); `; @@ -423,6 +428,9 @@ test("a context without `ca` verifies against the default CA store", async () => caReplacesTheDefaults: "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", crl: "CERT_REVOKED", nextContextWithoutCrl: "verified", + emptyDefaultList: "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", + inDefaultList: "verified", + notInDefaultList: "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", }), stderr: expect.stringContaining( "ExperimentalWarning: quic is an experimental feature and might change at any time", diff --git a/test/js/node/tls/node-tls-context.test.ts b/test/js/node/tls/node-tls-context.test.ts index 63fea961fbff..11c10fb315d0 100644 --- a/test/js/node/tls/node-tls-context.test.ts +++ b/test/js/node/tls/node-tls-context.test.ts @@ -1312,6 +1312,8 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("setKeyCert() in ALPNCallback (%s agent1: { key: agent1Key, cert: agent1Cert }, agent3: { key: agent3Key, cert: agent3Cert }, }; + // Clients verify the server's chain but not its name. Their `ca` also completes their own chain up to the root. + const verifying = { ca: [ca1, ca2, agent2Cert, loadPEM("ca5-cert.pem")], checkServerIdentity: () => undefined }; const trustedBy = { agent1: "a", agent3: "b" } as const; const servedFor = { a: "agent2", b: "agent3" } as const; type Offer = { servername: string | false; protocols: string[] }; @@ -1401,7 +1403,7 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("setKeyCert() in ALPNCallback (%s it.each(["agent1", "agent3"] as const)("serves and verifies %s under the selected context", async name => { using transport = await transports[kind](createAlpnServer()); const offer = async (alpn: "a" | "b", session?: Buffer) => { - const options = { ...clients[name], rejectUnauthorized: false, ALPNProtocols: [alpn], session }; + const options = { ...clients[name], ...verifying, ALPNProtocols: [alpn], session }; const { served, seen, session: issued } = await exchange(transport.open(options)); return { outcome: { served, seen }, issued }; }; @@ -1410,7 +1412,7 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("setKeyCert() in ALPNCallback (%s seen: trustedBy[name] === alpn ? { alpn, authorized: true, error: null, resumed } - : { alpn, authorized: false, error: "UNABLE_TO_VERIFY_LEAF_SIGNATURE", resumed }, + : { alpn, authorized: false, error: "SELF_SIGNED_CERT_IN_CHAIN", resumed }, }); const [fullA, fullB] = [await offer("a"), await offer("b")]; expect([fullA.outcome, fullB.outcome]).toEqual([expected("a", false), expected("b", false)]); @@ -1434,7 +1436,7 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("setKeyCert() in ALPNCallback (%s }, }); using transport = await transports.listener(server); - const { served } = await exchange(transport.open({ rejectUnauthorized: false, ALPNProtocols: ["a"] })); + const { served } = await exchange(transport.open({ ...verifying, ALPNProtocols: ["a"] })); expect(served).toBe("agent10.example.com"); }); @@ -1491,7 +1493,7 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("setKeyCert() in ALPNCallback (%s it("does nothing on a client, as in Node", async () => { using transport = await transports.listener(createAlpnServer()); - const client = transport.open({ ...clients.agent1, rejectUnauthorized: false, ALPNProtocols: ["a"] }); + const client = transport.open({ ...clients.agent1, ...verifying, ALPNProtocols: ["a"] }); client.on("connect", () => client.setKeyCert(tls.createSecureContext(clients.agent3))); // The default context trusts ca1, which issued agent1 and not agent3. expect((await exchange(client)).seen.authorized).toBe(true); @@ -1511,7 +1513,7 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("setKeyCert() in ALPNCallback (%s }, }); using transport = await transports.listener(server); - const { served } = await exchange(transport.open({ rejectUnauthorized: false, ALPNProtocols: ["a"] })); + const { served } = await exchange(transport.open({ ...verifying, ALPNProtocols: ["a"] })); expect({ thrown, served }).toEqual({ thrown: ["SetKeyCert"], served: "agent2" }); }); @@ -1525,7 +1527,7 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("setKeyCert() in ALPNCallback (%s }, }); using transport = await transports.listener(server); - const options = { ...clients.agent3, rejectUnauthorized: false }; + const options = { ...clients.agent3, ...verifying }; const { served, seen, session } = await exchange(transport.open({ ...options, ALPNProtocols: ["late"] })); expect({ served, seen }).toEqual({ served: "agent2", @@ -1535,7 +1537,7 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("setKeyCert() in ALPNCallback (%s expect((await exchange(transport.open({ ...options, ALPNProtocols: ["a"], session }))).seen).toEqual({ alpn: "a", authorized: false, - error: "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + error: "SELF_SIGNED_CERT_IN_CHAIN", resumed: false, }); }); @@ -1560,7 +1562,7 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("setKeyCert() in ALPNCallback (%s if (how === "addContext()") server.addContext("named.test", named); using transport = await transports[kind](server); const offer = async (alpn: string, servername: string) => { - const options = { ...clients.agent1, rejectUnauthorized: false, ALPNProtocols: [alpn], servername }; + const options = { ...clients.agent1, ...verifying, ALPNProtocols: [alpn], servername }; const { served, seen } = await exchange(transport.open(options)); return { served, authorized: seen.authorized }; }; @@ -1588,7 +1590,7 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("setKeyCert() in ALPNCallback (%s }, }); using transport = await transports[kind](server); - const options = { rejectUnauthorized: false, servername: "named.test" }; + const options = { ...verifying, servername: "named.test" }; await exchange(transport.open({ ...options, ALPNProtocols: ["a"] })); expect(calls.splice(0)).toEqual(alpnFirst ? ["ALPNCallback", "SNICallback"] : ["SNICallback", "ALPNCallback"]); @@ -1608,7 +1610,7 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("setKeyCert() in ALPNCallback (%s SNICallback: (_, callback) => callback(null, (server as any)._sharedCreds), }); using transport = await transports.listener(server); - const options = { rejectUnauthorized: false, ALPNProtocols: ["b"], servername: "named.test" }; + const options = { ...verifying, ALPNProtocols: ["b"], servername: "named.test" }; const outcomes: unknown[] = []; for (const name of ["agent1", "agent3"] as const) { const { served, seen } = await exchange(transport.open({ ...clients[name], ...options })); @@ -1652,7 +1654,7 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("setKeyCert() in ALPNCallback (%s }, }); using transport = await transports.listener(server); - const options = { rejectUnauthorized: false, ALPNProtocols: ["a"] }; + const options = { ...verifying, ALPNProtocols: ["a"] }; expect((await exchange(transport.open({ ...options, [setting]: ofServer }))).served).toBe("agent3"); const refused = once(server, "tlsClientError"); From 49545fd2136a4ca5513941ca551716aed0e8c6c2 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 22 Oct 2026 15:42:25 +0000 Subject: [PATCH 003/113] http: consolidate the open fetch, Bun.serve, node:http, node:http2 and HTTP/3 pull requests --- docs/bundler/executables.mdx | 13 +- docs/guides/http/fetch-unix.mdx | 10 +- docs/guides/http/stream-file.mdx | 15 +- docs/runtime/debugger.mdx | 4 +- docs/runtime/environment-variables.mdx | 1 + docs/runtime/http/error-handling.mdx | 9 +- docs/runtime/http/routing.mdx | 26 +- docs/runtime/http/server.mdx | 56 +- docs/runtime/module-resolution.mdx | 2 +- docs/runtime/networking/fetch.mdx | 21 +- docs/runtime/nodejs-compat.mdx | 4 +- docs/runtime/sql.mdx | 9 + packages/bun-types/bun.d.ts | 13 +- packages/bun-types/fetch.d.ts | 2 + packages/bun-types/globals.d.ts | 5 + packages/bun-types/serve.d.ts | 80 +- packages/bun-usockets/src/bsd.c | 93 +- packages/bun-usockets/src/context.c | 16 +- packages/bun-usockets/src/eventing/libuv.c | 11 + packages/bun-usockets/src/internal/internal.h | 2 + .../src/internal/networking/bsd.h | 4 +- packages/bun-usockets/src/libusockets.h | 21 +- packages/bun-usockets/src/loop.c | 6 + packages/bun-usockets/src/node_quic_shim.c | 71 +- packages/bun-usockets/src/quic.c | 225 +- packages/bun-usockets/src/quic.h | 41 +- packages/bun-usockets/src/socket.c | 23 + packages/bun-usockets/src/udp.c | 3 +- packages/bun-uws/src/App.h | 34 +- packages/bun-uws/src/AsyncSocketData.h | 2 +- packages/bun-uws/src/ChunkedEncoding.h | 148 +- packages/bun-uws/src/Http2Context.h | 69 +- packages/bun-uws/src/Http3App.h | 6 + packages/bun-uws/src/Http3Context.h | 23 +- packages/bun-uws/src/Http3ContextData.h | 3 + packages/bun-uws/src/Http3Request.h | 2 + packages/bun-uws/src/Http3Response.h | 85 +- packages/bun-uws/src/Http3ResponseData.h | 4 + packages/bun-uws/src/HttpContext.h | 205 +- packages/bun-uws/src/HttpContextData.h | 2 + packages/bun-uws/src/HttpParser.h | 68 +- packages/bun-uws/src/HttpResponse.h | 156 +- packages/bun-uws/src/HttpResponseData.h | 36 +- packages/bun-uws/src/HttpRouter.h | 4 +- packages/bun-uws/src/Utilities.h | 34 +- .../lsqpack/accept-empty-field-section.patch | 14 + patches/lsquic/abort-error-code-zero.patch | 39 + patches/lsquic/cubic-llp64-overflow.patch | 25 + patches/lsquic/datagram-ids.patch | 236 + patches/lsquic/delayed-uni-streams.patch | 85 + patches/lsquic/going-away-tickable.patch | 14 + patches/lsquic/hold-stream-window.patch | 69 + patches/lsquic/local-transport-error.patch | 45 + .../lsquic/merge-acks-contained-range.patch | 16 + patches/lsquic/mini-conn-ctx.patch | 170 + patches/lsquic/peer-stream-limits.patch | 43 + patches/lsquic/stop-sending-tickable.patch | 13 + patches/lsquic/stream-is-finished.patch | 34 + .../stream-send-limit-by-initiator.patch | 96 + patches/lsquic/tick-once-per-pass.patch | 70 + patches/lsquic/tickable-per-connection.patch | 108 + patches/lsquic/zero-rtt-header-type.patch | 18 + scripts/build/deps/lsqpack.ts | 3 + scripts/build/deps/lsquic.ts | 89 + src/base64/lib.rs | 11 + src/boringssl_sys/boringssl.rs | 28 + src/bun_core/fmt.rs | 71 +- src/collections/hive_array.rs | 3 +- src/http/AsyncHTTP.rs | 36 +- src/http/H2Client.rs | 16 +- src/http/HTTPContext.rs | 30 +- src/http/InternalState.rs | 10 +- src/http/ProxyTunnel.rs | 121 +- src/http/SendFile.rs | 77 +- src/http/error.rs | 3 +- src/http/h2_client/ClientSession.rs | 7 +- src/http/h2_client/Stream.rs | 3 + src/http/h2_client/dispatch.rs | 18 +- src/http/h3_client/AltSvc.rs | 29 +- src/http/h3_client/ClientContext.rs | 39 +- src/http/h3_client/ClientSession.rs | 53 +- src/http/h3_client/PendingConnect.rs | 2 +- src/http/h3_client/Stream.rs | 2 - src/http/h3_client/callbacks.rs | 31 +- src/http/h3_client/encode.rs | 9 +- src/http/lib.rs | 458 +- src/http/lshpack.rs | 137 + src/http_jsc/method_jsc.rs | 17 +- .../WebSocketUpgradeClient.rs | 7 +- src/http_types/ETag.rs | 13 + src/http_types/FetchRedirect.rs | 1 + src/http_types/Method.rs | 129 +- src/http_types/h2.rs | 1 + src/http_types/lib.rs | 9 + src/io/PipeReader.rs | 10 + src/js/builtins.d.ts | 2 + src/js/internal-for-testing.ts | 12 + src/js/internal/cluster/child.ts | 14 +- src/js/internal/cluster/primary.ts | 13 +- src/js/internal/http.ts | 22 +- src/js/internal/http1_server_fallback.ts | 178 +- src/js/internal/quic/quic.ts | 290 +- src/js/internal/quic/state.ts | 19 + src/js/internal/quic/symbols.ts | 4 + src/js/internal/test/binding.ts | 16 + src/js/internal/url.ts | 6 + src/js/internal/validators.ts | 1 - src/js/node/_http_client.ts | 8 +- src/js/node/_http_outgoing.ts | 76 +- src/js/node/_http_server.ts | 516 +- src/js/node/dgram.ts | 17 +- src/js/node/http.ts | 3 +- src/js/node/http2.ts | 1353 +++-- src/js/node/https.ts | 24 +- src/js/node/url.ts | 10 +- src/js/private.d.ts | 1 + src/js/thirdparty/node-fetch.ts | 116 +- src/js/thirdparty/undici.js | 914 ++- src/jsc/AbortSignal.rs | 1 + src/jsc/CommonStrings.rs | 6 + src/jsc/FetchHeaders.rs | 21 +- src/jsc/JSGlobalObject.rs | 7 + src/jsc/VirtualMachine.rs | 2 + src/jsc/bindings/BunCommonStrings.cpp | 3 + src/jsc/bindings/BunCommonStrings.h | 1 + src/jsc/bindings/BunString.cpp | 8 +- src/jsc/bindings/ErrorCode.cpp | 5 + src/jsc/bindings/ErrorCode.ts | 1 + src/jsc/bindings/JSEnvironmentVariableMap.cpp | 5 + src/jsc/bindings/NodeHTTP.cpp | 38 +- src/jsc/bindings/ServerRouteList.cpp | 4 +- src/jsc/bindings/bindings.cpp | 12 +- src/jsc/bindings/c-bindings.cpp | 40 + src/jsc/bindings/decodeURIComponentSIMD.cpp | 6 +- src/jsc/bindings/headers.h | 3 + .../bindings/node/JSNodeHTTPServerSocket.cpp | 160 +- .../bindings/node/JSNodeHTTPServerSocket.h | 9 +- .../node/JSNodeHTTPServerSocketPrototype.cpp | 36 + .../node/http/JSConnectionsListPrototype.cpp | 4 +- src/jsc/bindings/webcore/AbortSignal.h | 1 + src/jsc/bindings/webcore/FetchHeaders.h | 5 + src/jsc/bindings/webcore/JSFetchHeaders.cpp | 72 +- src/jsc/bindings/webcore/JSFetchHeaders.h | 2 + src/jsc/bindings/webcore/JSWebSocket.cpp | 4 +- .../webcore/streams/BunStandaloneTextSink.h | 2 +- .../webcore/streams/BunStreamConsumers.cpp | 26 +- .../webcore/streams/BunStreamSource.cpp | 2 +- .../streams/JSDirectStreamController.cpp | 6 +- .../streams/JSDirectStreamController.h | 3 +- .../webcore/streams/JSReadableStream.cpp | 6 +- .../webcore/streams/JSReadableStream.h | 2 + .../JSReadableStreamDefaultController.cpp | 8 + .../streams/JSReadableStreamDefaultReader.cpp | 15 +- .../webcore/streams/JSStreamTeeState.h | 2 + .../streams/ReadableStreamOperations.cpp | 61 +- .../bindings/webcore/streams/StreamsForward.h | 6 + .../webcore/streams/WebStreamsExports.cpp | 8 +- .../webcore/streams/WebStreamsInternals.h | 21 +- .../webcore/streams/WebStreamsMisc.cpp | 56 + src/jsc/event_loop.rs | 18 +- src/jsc/rare_data.rs | 28 + src/jsc/webcore_types.rs | 14 +- src/libdeflate_sys/libdeflate.rs | 8 +- src/lsquic_sys/lib.rs | 127 +- src/picohttp/lib.rs | 133 +- src/resolver/data_url.rs | 216 +- src/runtime/api/BunObject.rs | 19 +- src/runtime/api/bun/h2/connection.rs | 493 +- src/runtime/api/bun/h2/hpack.rs | 113 +- src/runtime/api/bun/h2/stream.rs | 2 +- src/runtime/api/bun/h2/wire.rs | 2 + src/runtime/api/bun/h2_frame_parser.rs | 1942 +++--- src/runtime/api/h2.classes.ts | 10 +- src/runtime/api/html_rewriter.rs | 1 + src/runtime/bake/dev_server/ensure_route.rs | 1 + src/runtime/dns_jsc/cares_jsc.rs | 9 + src/runtime/jsc_hooks.rs | 14 + src/runtime/node/node_fs.rs | 8 +- src/runtime/node/node_http_binding.rs | 41 +- src/runtime/node/node_quic_binding.rs | 4 + src/runtime/node/quic/endpoint.rs | 595 +- src/runtime/node/quic/ffi.rs | 1 + src/runtime/node/quic/session.rs | 458 +- src/runtime/node/quic/stream.rs | 301 +- src/runtime/node/quic/tls.rs | 87 +- src/runtime/server/DevErrorPage.rs | 18 +- src/runtime/server/DirectoryRoute.rs | 1 - src/runtime/server/FileResponseStream.rs | 95 +- src/runtime/server/FileRoute.rs | 45 +- src/runtime/server/HTMLBundle.rs | 90 +- src/runtime/server/NodeHTTPResponse.bind.ts | 2 +- src/runtime/server/NodeHTTPResponse.rs | 72 +- src/runtime/server/RequestContext.rs | 455 +- src/runtime/server/ServerConfig.rs | 185 +- src/runtime/server/StaticRoute.rs | 37 +- src/runtime/server/mod.rs | 629 +- src/runtime/server/server_body.rs | 101 +- src/runtime/socket/Listener.rs | 31 +- src/runtime/socket/socket_body.rs | 29 +- src/runtime/socket/udp_socket.rs | 2 +- src/runtime/webcore/Blob.rs | 70 +- src/runtime/webcore/Body.rs | 27 +- src/runtime/webcore/FormData.rs | 16 +- src/runtime/webcore/Request.rs | 151 +- src/runtime/webcore/Response.rs | 148 +- src/runtime/webcore/fetch.rs | 251 +- src/runtime/webcore/fetch/FetchSession.rs | 6 +- src/runtime/webcore/fetch/FetchTasklet.rs | 78 +- src/runtime/webcore/streams.rs | 11 +- src/sys/lib.rs | 17 - src/url/lib.rs | 26 +- src/uws/lib.rs | 6 +- src/uws_sys/App.rs | 93 +- src/uws_sys/Loop.rs | 3 +- src/uws_sys/Response.rs | 79 +- src/uws_sys/SocketGroup.rs | 18 + src/uws_sys/_libusockets.h | 6 - src/uws_sys/h3.rs | 15 + src/uws_sys/lib.rs | 2 + src/uws_sys/libuwsockets.cpp | 164 +- src/uws_sys/libuwsockets_h3.cpp | 7 +- src/uws_sys/quic.rs | 10 - src/uws_sys/quic/Context.rs | 6 +- src/uws_sys/quic/Stream.rs | 17 +- src/uws_sys/socket.rs | 2 +- src/uws_sys/us_socket_t.rs | 6 +- src/zlib/lib.rs | 64 +- test/bundler/bundler_compile.test.ts | 39 + test/flaky-tests.txt | 2 +- test/harness.ts | 23 + test/integration/bun-types/fixture/fetch.ts | 10 + test/integration/bun-types/fixture/serve.ts | 48 + .../jsresult-swallow.inventory.json | 2 +- test/js/bun/cookie/cookie-map.test.ts | 13 + test/js/bun/http/body-leak-test-fixture.ts | 46 +- test/js/bun/http/bun-serve-args.test.ts | 116 +- test/js/bun/http/bun-serve-file.test.ts | 1164 +++- test/js/bun/http/bun-serve-headers.test.ts | 306 +- test/js/bun/http/bun-serve-html-405.test.ts | 62 +- test/js/bun/http/bun-serve-html.test.ts | 353 +- test/js/bun/http/bun-serve-routes.test.ts | 299 +- test/js/bun/http/bun-serve-ssl.test.ts | 33 + test/js/bun/http/bun-serve-static-helpers.ts | 132 +- test/js/bun/http/bun-serve-static.test.ts | 125 +- test/js/bun/http/bun-server.test.ts | 86 + .../bun/http/decodeURIComponentSIMD.test.ts | 87 +- .../fetch-abort-ssl-context-eviction.test.ts | 16 +- test/js/bun/http/fetch-file-upload.test.ts | 276 +- .../bun/http/fetch-header-count-limit.test.ts | 121 +- test/js/bun/http/http-server-chunking.test.ts | 958 ++- test/js/bun/http/leaks-test.test.ts | 12 +- .../bun/http/proxy-stress-adversarial.test.ts | 238 +- .../bun/http/proxy-stress-concurrent.test.ts | 507 +- test/js/bun/http/proxy-stress-errors.test.ts | 105 + test/js/bun/http/proxy-stress-helpers.ts | 11 +- .../bun/http/proxy-stress-memory-fixture.ts | 245 +- test/js/bun/http/proxy.test.js | 67 +- test/js/bun/http/proxy.test.ts | 31 + test/js/bun/http/req-url-leak-fixture.js | 21 +- test/js/bun/http/req-url-leak.test.ts | 70 +- .../http/request-constructor-leak-fixture.js | 10 +- test/js/bun/http/request-smuggling.test.ts | 160 +- .../http/response-constructor-leak-fixture.js | 4 +- ...erve-413-streaming-late-resolve-fixture.ts | 152 +- ...serve-async-stream-client-abort-fixture.ts | 27 + test/js/bun/http/serve-body-leak.test.ts | 262 +- .../http/serve-direct-readable-stream.test.ts | 45 +- .../bun/http/serve-directory-routes.test.ts | 19 + test/js/bun/http/serve-epoll-add-fail.test.ts | 74 +- test/js/bun/http/serve-http2-fixture.ts | 7 +- test/js/bun/http/serve-http2-helpers.ts | 15 +- .../js/bun/http/serve-http2-lifecycle.test.ts | 110 + test/js/bun/http/serve-http2.test.ts | 110 +- test/js/bun/http/serve-http3.test.ts | 1636 +++++- test/js/bun/http/serve-listen.test.ts | 80 +- ...erve-pending-promise-abort-leak-fixture.ts | 95 - .../serve-pending-promise-abort-leak.test.ts | 697 ++- ...serve-response-stream-sink-leak-fixture.ts | 21 +- .../serve-response-stream-sink-leak.test.ts | 46 +- .../js/bun/http/serve-reused-response.test.ts | 165 +- .../http/serve-stream-body-error-fixture.ts | 6 +- .../serve-stream-reject-flush-leak-fixture.ts | 15 +- test/js/bun/http/serve-syscall-fault.test.ts | 45 + test/js/bun/http/serve.test.ts | 1331 ++++- .../http/server-fetch-string-leak-fixture.js | 15 +- test/js/bun/http/server-url-invalid.test.ts | 8 +- .../io/fetch/fetch-abort-slow-connect.test.ts | 75 +- .../bun/module-graph/module-graph-gc.test.ts | 19 +- ...-cause-duplicated-character-or-segfault.ts | 7 +- ...entError-when-Content-Length-is-invalid.ts | 2 +- test/js/bun/test/test-timers.test.ts | 15 + test/js/bun/util/cookie.test.js | 2 +- ...socket-server-upgrade-early-frames.test.ts | 64 +- test/js/first_party/undici/undici.test.ts | 1249 +++- .../async-context-http2-session-settings.js | 52 + test/js/node/cluster.test.ts | 93 +- test/js/node/dgram/node-dgram.test.js | 35 + .../http/early-hints-crlf-injection.test.ts | 296 +- .../fixtures/http.compress.leak.server.ts | 10 +- test/js/node/http/node-fetch.test.js | 212 +- .../node/http/node-http-backpressure.test.ts | 274 +- .../node-http-connect-proxy-pipes-fixture.js | 53 + .../node-http-connect-unix-hangup-fixture.js | 13 +- test/js/node/http/node-http-connect.node.mts | 14 +- test/js/node/http/node-http-connect.test.ts | 691 ++- .../node-http-displaced-response-fixture.ts | 159 +- .../http/node-http-displaced-response.test.ts | 53 +- .../node/http/node-http-nested-cork.test.ts | 387 +- test/js/node/http/node-http-parser.test.ts | 27 + .../node/http/node-http-pinned-write.test.ts | 15 +- .../node/http/node-http-req-complete.test.ts | 27 + .../http/node-http-req-socket-pause.test.ts | 83 +- .../node-http-server-abort-events.test.ts | 51 + .../http/node-http-server-close-drain.test.ts | 734 ++- ...http-server-close-paused-upload-fixture.js | 144 + .../node-http-server-socket-end-drain.test.ts | 115 + .../http/node-http-server-timeouts.test.ts | 296 +- .../node/http/node-http-syscall-fault.test.ts | 349 +- .../http/node-http-transfer-encoding.test.ts | 367 +- test/js/node/http/node-http-uaf-fixture-2.ts | 33 +- test/js/node/http/node-http-uaf-fixture.ts | 153 +- test/js/node/http/node-http-uaf.test.ts | 306 +- .../http/node-http-upgrade-body-fixture.js | 216 +- .../node/http/node-http-upgrade-body.test.ts | 52 +- test/js/node/http/node-http-with-ws.test.ts | 21 +- ...node-http-writehead-termination-fixture.ts | 2 +- test/js/node/http/node-http.test.ts | 1252 +++- test/js/node/http/node-https-server.test.ts | 9 +- test/js/node/http2/h2-conformance.test.ts | 3084 +++++++++- .../http2/node-http2-client-close.test.ts | 1012 +++- .../http2/node-http2-invalid-padding.test.ts | 84 +- test/js/node/http2/node-http2.test.js | 5184 ++++++++++++----- test/js/node/net/node-net-server.test.ts | 24 + test/js/node/quic/busy-loop-sender-fixture.ts | 41 + .../quic/outbound-body-release-fixture.ts | 52 + .../js/node/quic/outbound-body-rss-fixture.ts | 57 + test/js/node/quic/quic-endpoint.test.ts | 1031 +++- test/js/node/quic/quic-sni.test.ts | 57 +- test/js/node/quic/quic-stream.test.ts | 1701 +++++- ...quic-worker-terminate-handshake-fixture.ts | 72 + test/js/node/string-module.test.js | 8 + .../test/parallel/test-cluster-bind-twice.js | 113 + .../node/test/parallel/test-http2-binding.js | 262 + .../test-http2-origin-set-max-size.mjs | 110 + .../parallel/test-http2-respond-errors.js | 52 + ...st-https-proxy-request-header-overflow.mjs | 48 + .../test-quic-internal-setcallbacks.mjs | 7 +- .../test-quic-stream-limits-pending.mjs | 31 +- .../parallel/test-quic-stream-limits-uni.mjs | 19 +- .../test/sequential/test-http2-ping-flood.js | 60 + test/js/node/zlib/zlib.test.js | 22 + .../express-body-parser-test.test.ts | 13 +- test/js/third_party/express/express.test.ts | 9 +- .../third_party/grpc-js/test-deadline.test.ts | 87 + test/js/third_party/st/st.fixture.ts | 10 +- test/js/web/fetch/abort-signal-leak.test.ts | 46 +- test/js/web/fetch/abortsignal-leak-fixture.ts | 359 +- test/js/web/fetch/blob.test.ts | 38 + test/js/web/fetch/body-clone.test.ts | 387 +- test/js/web/fetch/body-stream.test.ts | 1090 ++-- test/js/web/fetch/body.test.ts | 93 + test/js/web/fetch/client-fetch.test.ts | 2 +- .../web/fetch/fetch-abort-stream-body.test.ts | 66 +- test/js/web/fetch/fetch-args.test.ts | 96 + test/js/web/fetch/fetch-chunked-size.test.ts | 68 + test/js/web/fetch/fetch-gzip.test.ts | 49 +- .../web/fetch/fetch-http2-adversarial.test.ts | 140 +- test/js/web/fetch/fetch-http2-client.test.ts | 1867 +++--- test/js/web/fetch/fetch-http3-client.test.ts | 478 +- test/js/web/fetch/fetch-keepalive.test.ts | 46 +- .../js/web/fetch/fetch-leak-test-fixture-5.js | 6 +- test/js/web/fetch/fetch-leak.test.ts | 178 +- test/js/web/fetch/fetch-preconnect.test.ts | 24 + test/js/web/fetch/fetch-redirect.test.ts | 239 +- test/js/web/fetch/fetch-session.test.ts | 23 +- .../fetch-stream-body-ended-inline-fixture.ts | 10 +- .../fetch/fetch-stream-cancel-leak.test.ts | 454 +- test/js/web/fetch/fetch-tcp-stress.test.ts | 29 +- test/js/web/fetch/fetch.stream.test.ts | 1721 ++---- test/js/web/fetch/fetch.test.ts | 726 ++- .../fetch.tls.cert-mismatch-churn.fixture.ts | 121 +- .../fetch.tls.renegotiation-peer-fixture.mjs | 3 + test/js/web/fetch/fetch.tls.test.ts | 39 +- test/js/web/fetch/fetch.unix.test.ts | 126 +- test/js/web/fetch/headers.test.ts | 191 +- test/js/web/fetch/response.test.ts | 193 +- test/js/web/fetch/utf8-bom.test.ts | 242 +- test/js/web/fetch/wpt/data-urls/base64.any.js | 18 + .../fetch/wpt/data-urls/data-urls-wpt.test.ts | 51 + .../web/fetch/wpt/data-urls/processing.any.js | 22 + .../fetch/wpt/data-urls/resources/base64.json | 82 + .../wpt/data-urls/resources/data-urls.json | 214 + .../web/fetch/wpt/headers-basic-wpt.test.ts | 31 + test/js/web/fetch/wpt/headers-basic.any.js | 275 + .../FormData-multipart-serialization.test.ts | 2 +- test/js/web/html/FormData.test.ts | 25 + .../js/web/request/request-clone-leak.test.ts | 188 +- .../web/request/request-method-getter.test.ts | 86 +- test/js/web/request/request.test.ts | 134 + .../workers/worker-late-completion.test.ts | 70 + .../workers/worker-terminate-lifetime.test.ts | 102 + test/parallel-denylist.txt | 1 + test/regression/issue/12042.test.ts | 5 +- test/regression/issue/21257.test.ts | 5 +- 404 files changed, 44070 insertions(+), 14713 deletions(-) create mode 100644 patches/lsqpack/accept-empty-field-section.patch create mode 100644 patches/lsquic/abort-error-code-zero.patch create mode 100644 patches/lsquic/cubic-llp64-overflow.patch create mode 100644 patches/lsquic/datagram-ids.patch create mode 100644 patches/lsquic/delayed-uni-streams.patch create mode 100644 patches/lsquic/going-away-tickable.patch create mode 100644 patches/lsquic/hold-stream-window.patch create mode 100644 patches/lsquic/local-transport-error.patch create mode 100644 patches/lsquic/merge-acks-contained-range.patch create mode 100644 patches/lsquic/mini-conn-ctx.patch create mode 100644 patches/lsquic/peer-stream-limits.patch create mode 100644 patches/lsquic/stop-sending-tickable.patch create mode 100644 patches/lsquic/stream-is-finished.patch create mode 100644 patches/lsquic/stream-send-limit-by-initiator.patch create mode 100644 patches/lsquic/tick-once-per-pass.patch create mode 100644 patches/lsquic/tickable-per-connection.patch create mode 100644 patches/lsquic/zero-rtt-header-type.patch delete mode 100644 test/js/bun/http/serve-pending-promise-abort-leak-fixture.ts create mode 100644 test/js/node/async_hooks/async-context/async-context-http2-session-settings.js create mode 100644 test/js/node/http/node-http-connect-proxy-pipes-fixture.js create mode 100644 test/js/node/http/node-http-server-close-paused-upload-fixture.js create mode 100644 test/js/node/quic/busy-loop-sender-fixture.ts create mode 100644 test/js/node/quic/outbound-body-release-fixture.ts create mode 100644 test/js/node/quic/outbound-body-rss-fixture.ts create mode 100644 test/js/node/quic/quic-worker-terminate-handshake-fixture.ts create mode 100644 test/js/node/test/parallel/test-cluster-bind-twice.js create mode 100644 test/js/node/test/parallel/test-http2-binding.js create mode 100644 test/js/node/test/parallel/test-http2-origin-set-max-size.mjs create mode 100644 test/js/node/test/parallel/test-http2-respond-errors.js create mode 100644 test/js/node/test/parallel/test-https-proxy-request-header-overflow.mjs create mode 100644 test/js/node/test/sequential/test-http2-ping-flood.js create mode 100644 test/js/web/fetch/wpt/data-urls/base64.any.js create mode 100644 test/js/web/fetch/wpt/data-urls/data-urls-wpt.test.ts create mode 100644 test/js/web/fetch/wpt/data-urls/processing.any.js create mode 100644 test/js/web/fetch/wpt/data-urls/resources/base64.json create mode 100644 test/js/web/fetch/wpt/data-urls/resources/data-urls.json create mode 100644 test/js/web/fetch/wpt/headers-basic-wpt.test.ts create mode 100644 test/js/web/fetch/wpt/headers-basic.any.js diff --git a/docs/bundler/executables.mdx b/docs/bundler/executables.mdx index a05760ff47be..9f8651229cf1 100644 --- a/docs/bundler/executables.mdx +++ b/docs/bundler/executables.mdx @@ -800,12 +800,10 @@ import styles from "./styles.css" with { type: "file" }; import { file, serve } from "bun"; serve({ - // Inside the compiled executable, file() on an embedded path returns an - // in-memory Blob, which routes only accepts wrapped in a Response. routes: { - "/favicon.ico": new Response(file(favicon)), - "/logo.png": new Response(file(logo)), - "/styles.css": new Response(file(styles)), + "/favicon.ico": file(favicon), + "/logo.png": file(logo), + "/styles.css": file(styles), }, fetch(req) { return new Response("Not found", { status: 404 }); @@ -988,12 +986,11 @@ import "./public/styles.css" with { type: "file" }; import { embeddedFiles, serve } from "bun"; // Build static routes from all embedded files -const staticRoutes: Record = {}; +const staticRoutes: Record = {}; for (const blob of embeddedFiles) { // Remove hash from filename: "icon-a1b2c3d4.png" -> "icon.png" const name = blob.name.replace(/-[a-z0-9]+\./, "."); - // embeddedFiles are plain Blobs, which routes does not accept directly - staticRoutes[`/${name}`] = new Response(blob); + staticRoutes[`/${name}`] = blob; } serve({ diff --git a/docs/guides/http/fetch-unix.mdx b/docs/guides/http/fetch-unix.mdx index b28f53966877..c03b07eb1ced 100644 --- a/docs/guides/http/fetch-unix.mdx +++ b/docs/guides/http/fetch-unix.mdx @@ -1,10 +1,10 @@ --- -title: fetch with unix domain sockets in Bun -sidebarTitle: Fetch with unix domain sockets +title: fetch with Unix domain sockets in Bun +sidebarTitle: Fetch with Unix domain sockets mode: center --- -In Bun, `fetch()` can send HTTP requests over a [unix domain socket](https://en.wikipedia.org/wiki/Unix_domain_socket) with the `unix` option. +In Bun, `fetch()` can send HTTP requests over a [Unix domain socket](https://en.wikipedia.org/wiki/Unix_domain_socket) with the `unix` option. ```ts fetch-unix.ts icon="/icons/typescript.svg" const unix = "/var/run/docker.sock"; @@ -17,9 +17,9 @@ console.log(body); // { ... } --- -The `unix` option is the local file path to a unix domain socket. `fetch()` sends the request over that socket instead of a TCP connection. HTTPS is also supported: use the `https://` protocol in the URL instead of `http://`. +The `unix` option is the local file path to a Unix domain socket. `fetch()` sends the request over that socket instead of a TCP connection. HTTPS is also supported: use the `https://` protocol in the URL instead of `http://`. -To send a `POST` request to an API endpoint over a unix domain socket: +To send a `POST` request to an API endpoint over a Unix domain socket: ```ts fetch-unix.ts icon="/icons/typescript.svg" const response = await fetch("https://hostname/a/path", { diff --git a/docs/guides/http/stream-file.mdx b/docs/guides/http/stream-file.mdx index beda12ed2aae..f4697b70c6e0 100644 --- a/docs/guides/http/stream-file.mdx +++ b/docs/guides/http/stream-file.mdx @@ -34,17 +34,28 @@ new Response(Bun.file("./img.png")).headers.get("Content-Type"); Putting it all together with [`Bun.serve()`](/runtime/http/server). +The client chooses the request path, and `new URL(req.url).pathname` starts with `/`. Passing it straight to `Bun.file()` would serve any file the process can read. Resolve it against a fixed directory and reject anything outside that directory. + ```ts server.ts icon="/icons/typescript.svg" +import path from "node:path"; + +const root = path.resolve("./public"); + // static file server Bun.serve({ async fetch(req) { - const path = new URL(req.url).pathname; - const file = Bun.file(path); + const filePath = path.join(root, decodeURIComponent(new URL(req.url).pathname)); + if (!filePath.startsWith(root + path.sep)) return new Response("Forbidden", { status: 403 }); + + const file = Bun.file(filePath); + if (!(await file.exists())) return new Response("Not Found", { status: 404 }); return new Response(file); }, }); ``` +To serve a whole directory without writing this yourself, use a [directory route](/runtime/http/routing#directory-routes). + --- See [`Bun.write()`](/runtime/file-io#writing-files-bun-write). diff --git a/docs/runtime/debugger.mdx b/docs/runtime/debugger.mdx index d83684a0dca7..482367c6b941 100644 --- a/docs/runtime/debugger.mdx +++ b/docs/runtime/debugger.mdx @@ -145,7 +145,7 @@ await fetch("https://example.com", { ``` ```txt -[fetch] $ curl --http1.1 "https://example.com/" -X POST -H "content-type: application/json" -H "Connection: keep-alive" -H "User-Agent: Bun/1.3.3" -H "Accept: */*" -H "Host: example.com" -H "Accept-Encoding: gzip, deflate, br" --compressed -H "Content-Length: 13" --data-raw "{\"foo\":\"bar\"}" +[fetch] $ curl --http1.1 'https://example.com/' -X POST -H 'content-type: application/json' -H 'Connection: keep-alive' -H 'User-Agent: Bun/1.3.3' -H 'Accept: */*' -H 'Host: example.com' -H 'Accept-Encoding: gzip, deflate, br' --compressed -H 'Content-Length: 13' --data-raw '{"foo":"bar"}' [fetch] > HTTP/1.1 POST https://example.com/ [fetch] > content-type: application/json [fetch] > Connection: keep-alive @@ -169,6 +169,8 @@ await fetch("https://example.com", { The lines with `[fetch] >` are the request from your local code, and the lines with `[fetch] <` are the response from the remote server. +On macOS and Linux, each argument of the `curl` command is single-quoted, so a shell like `bash` or `zsh` passes `$`, backticks and double quotes to `curl` as they are. On Windows, the arguments are double-quoted for `cmd.exe`. + To print without the `curl` command, set `BUN_CONFIG_VERBOSE_FETCH` to `true`. ```ts index.ts icon="/icons/typescript.svg" diff --git a/docs/runtime/environment-variables.mdx b/docs/runtime/environment-variables.mdx index d9ef390c6e4e..18e358aebe6d 100644 --- a/docs/runtime/environment-variables.mdx +++ b/docs/runtime/environment-variables.mdx @@ -222,6 +222,7 @@ Bun reads these environment variables to configure aspects of its behavior. | `NO_COLOR` | If `NO_COLOR=1`, then ANSI color output is [disabled](https://no-color.org/). | | `FORCE_COLOR` | If `FORCE_COLOR=1`, then ANSI color output is forced on, even if `NO_COLOR` is set. | | `BUN_CONFIG_MAX_HTTP_REQUESTS` | Sets the maximum number of concurrent HTTP requests sent by `fetch` and `bun install`. `fetch` defaults to `256`. `bun install` defaults to `64`, and its `--network-concurrency` flag overrides this variable. Lower it if you run into rate limits or connection issues. | +| `BUN_CONFIG_HTTP_IDLE_TIMEOUT` | Seconds an HTTP request sent by `fetch` or `bun install` can go without progress before it fails with a timeout error. The timer covers the TLS handshake, the response headers and the response body. Defaults to `300`. `0` disables it. | | `BUN_INSTALL_WINDOWS_RENAME_RETRY_MS` | Windows only. How many milliseconds `bun install` keeps retrying a rename that fails because another process, usually antivirus, has a file in the directory open. Defaults to `5000`. | | `BUN_CONFIG_WS_CLOSE_TIMEOUT` | Sets how many seconds a `WebSocket` client waits after sending a Close frame for the server to reply and close the connection. After that, Bun closes it. If the server never sent a Close frame, the `close` event has code `1006`. Defaults to `30`. `0` disables the timeout. | | `BUN_CONFIG_NO_CLEAR_TERMINAL_ON_RELOAD` | If `BUN_CONFIG_NO_CLEAR_TERMINAL_ON_RELOAD=true`, then `bun --watch` does not clear the console on reload | diff --git a/docs/runtime/http/error-handling.mdx b/docs/runtime/http/error-handling.mdx index 7dce8b66d8e7..119a42232fb0 100644 --- a/docs/runtime/http/error-handling.mdx +++ b/docs/runtime/http/error-handling.mdx @@ -28,13 +28,20 @@ In development mode, when a request handler throws and no `error` handler return To handle server-side errors, implement an `error` handler. Return a `Response` to serve to the client when an error occurs. In `development` mode, this response replaces Bun's default error page. +Error messages and stack traces can contain request input and absolute file paths. Escape them with [`Bun.escapeHTML()`](/runtime/utils#bun-escapehtml) before you put them in HTML, and don't send them to clients in production. + ```ts Bun.serve({ fetch(req) { throw new Error("woops!"); }, error(error) { - return new Response(`
${error}\n${error.stack}
`, { + console.error(error); + if (process.env.NODE_ENV === "production") { + return new Response("Internal Server Error", { status: 500 }); + } + return new Response(`
${Bun.escapeHTML(`${error}\n${error.stack}`)}
`, { + status: 500, headers: { "Content-Type": "text/html", }, diff --git a/docs/runtime/http/routing.mdx b/docs/runtime/http/routing.mdx index 969c5d7fa64a..ade75c8ce7d3 100644 --- a/docs/runtime/http/routing.mdx +++ b/docs/runtime/http/routing.mdx @@ -124,7 +124,9 @@ Bun.serve({ }); ``` -Bun automatically decodes percent-encoded route parameter values, including Unicode characters. Bun replaces invalid Unicode with the Unicode replacement character (`\uFFFD`). +A path segment is a parameter only when it starts with `:`, and the parameter captures the whole segment. A `:` anywhere else in a segment is matched literally, so `/files:batchGet/:id` has one parameter, `id`. + +Bun automatically decodes percent-encoded route parameter values, including Unicode characters. Bun replaces invalid Unicode with the Unicode replacement character (`\uFFFD`). A `%` that isn't followed by two hexadecimal digits stays a literal `%`. ### Static responses @@ -163,10 +165,14 @@ Bun caches static route responses for the lifetime of the server object. To relo Serving a file from a route behaves differently depending on whether you buffer the file content or serve it directly: ```ts +const logo = Bun.file("./logo.png"); + Bun.serve({ routes: { // Static route - content is buffered in memory at startup - "/logo.png": new Response(await Bun.file("./logo.png").bytes()), + "/logo.png": new Response(await logo.bytes(), { + headers: { "Content-Type": logo.type }, + }), // File route - content is read from filesystem on each request "/download.zip": new Response(Bun.file("./download.zip")), @@ -181,6 +187,7 @@ Bun.serve({ - **If-None-Match** - Returns `304 Not Modified` when client ETag matches - **No 404 handling** - Missing files cause startup errors, not runtime 404s - **Memory usage** - Full file content stored in RAM +- **No Content-Type** - Bytes have no type, so set the `Content-Type` header yourself. `file.type` is the type Bun infers from the file extension - **Best for**: Small static assets, API responses, frequently accessed files **File routes** (`new Response(Bun.file(path))`) read from filesystem per request: @@ -207,7 +214,9 @@ Bun.serve({ }); ``` -Bun percent-decodes the part of the request URL after the prefix once and opens it relative to `dir`. Bun rejects non-canonical paths with `404`, so the served path is always the path the router matched. A path is non-canonical if it contains `.`, `..`, empty segments, `%2F`, or a `%XX` sequence encoding a character that may appear literally in a path segment. On Linux the open uses `openat2(RESOLVE_IN_ROOT)`, so the kernel clamps symlinks that would escape `dir`. +Bun percent-decodes the part of the request URL after the prefix once and opens it relative to `dir`. Bun rejects non-canonical paths with `404`, so the served path is always the path the router matched. A path is non-canonical if it contains a segment that is exactly `.` or `..`, an empty segment, `%2F`, or a `%XX` sequence encoding a character that may appear literally in a path segment. On Linux the open uses `openat2(RESOLVE_IN_ROOT)`, so the kernel clamps symlinks that would escape `dir`. On macOS and Windows, and on Linux when `openat2` is unavailable (kernels before 5.6, or a seccomp policy that blocks it), Bun follows symlinks, so a symlink inside `dir` exposes its target even when the target is outside `dir`. + +Bun doesn't filter dotfiles. It serves any regular file under `dir`, including a `.env` file or the contents of a `.git` directory. Point `dir` at a directory that only contains public files. Routing is case-sensitive, but filesystems on macOS and Windows are case-insensitive by default. As a result, a @@ -252,15 +261,16 @@ Bun.serve({ fetch(req) { // parse `Range` header const [start = 0, end = Infinity] = req.headers - .get("Range") // Range: bytes=0-100 - .split("=") // ["Range: bytes", "0-100"] + .get("Range") // "bytes=0-100" + .split("=") // ["bytes", "0-100"] .at(-1) // "0-100" - .split("-") // ["0", "100"] - .map(Number); // [0, 100] + .split("-") // ["0", "100"], or ["100", ""] for "bytes=100-" + .map(part => (part ? Number(part) : undefined)); // [0, 100] // return a slice of the file + // the end of a `Range` is inclusive and the end of a `slice` is exclusive, so add 1 const bigFile = Bun.file("./big-video.mp4"); - return new Response(bigFile.slice(start, end)); + return new Response(bigFile.slice(start, end + 1)); }, }); ``` diff --git a/docs/runtime/http/server.mdx b/docs/runtime/http/server.mdx index 59060ff7de15..047e3babce34 100644 --- a/docs/runtime/http/server.mdx +++ b/docs/runtime/http/server.mdx @@ -46,6 +46,48 @@ const server = Bun.serve({ console.log(`Server running at ${server.url}`); ``` +### Splitting routes across files + +`routes` is a plain object, so you can define groups of routes in separate files and spread them together. Annotate `req` with `BunRequest` to keep `req.params` typed. + +```ts title="users.ts" icon="/icons/typescript.svg" +import type { BunRequest } from "bun"; + +export const userRoutes = { + "/users/:id": (req: BunRequest<"/users/:id">) => { + return new Response(`Hello User ${req.params.id}!`); + }, +}; +``` + +```ts title="posts.ts" icon="/icons/typescript.svg" +export const postRoutes = { + "/api/posts": { + GET: () => new Response("List posts"), + POST: async (req: Request) => { + const post = await req.json(); + return Response.json({ created: true, post }); + }, + }, +}; +``` + +```ts title="index.ts" icon="/icons/typescript.svg" +import { userRoutes } from "./users"; +import { postRoutes } from "./posts"; + +const server = Bun.serve({ + routes: { + ...userRoutes, + ...postRoutes, + }, + + fetch(req) { + return new Response("Not Found", { status: 404 }); + }, +}); +``` + --- ## HTML imports @@ -147,7 +189,7 @@ NODE_PORT=4002 bun server.ts ## Unix domain sockets -To listen on a [unix domain socket](https://en.wikipedia.org/wiki/Unix_domain_socket), pass the `unix` option with the path to the socket. +To listen on a [Unix domain socket](https://en.wikipedia.org/wiki/Unix_domain_socket), pass the `unix` option with the path to the socket. ```ts Bun.serve({ @@ -171,7 +213,7 @@ Bun.serve({ }); ``` -Unlike unix domain sockets, abstract namespace sockets are not bound to the filesystem and are automatically removed when the last reference to the socket is closed. +Unlike Unix domain sockets, abstract namespace sockets are not bound to the filesystem and are automatically removed when the last reference to the socket is closed. --- @@ -213,7 +255,7 @@ Bun.serve({ ``` - `http3` is not supported with unix domain sockets: QUIC requires a UDP port. `http1: false` requires `http2: true` or + `http3` is not supported with Unix domain sockets: QUIC requires a UDP port. `http1: false` requires `http2: true` or `http3: true`. @@ -265,6 +307,8 @@ Bun.serve({ }); ``` +`idleTimeout` is also how long Bun keeps an idle keep-alive connection open between requests. Behind a load balancer or a reverse proxy, set it above the keep-alive time the proxy uses for its upstream connections, or to `0`. Otherwise the proxy can send a request on a connection that Bun is closing. + **Streaming & Server-Sent Events** — The idle timer applies while a response is being streamed. If your stream goes quiet for longer than `idleTimeout`, Bun closes the connection mid-response. For long-lived streams, disable the @@ -336,6 +380,8 @@ await server.stop(true); By default, `stop()` allows in-flight requests and WebSocket connections to complete. The server closes idle keep-alive connections immediately. Connections with a request in flight close once the server has sent their response. Pass `true` to immediately terminate all connections instead. Open WebSockets close with code `1001`. The returned promise resolves once every connection has closed. +The last HTTP/1.1 response on a connection carries `Connection: close`. An HTTP/2 connection gets a `GOAWAY` frame, and once its streams are done the server ends its side of the connection. The connection closes when the client ends its side too, or after 10 seconds. + ### `server.closeIdleConnections()` To close keep-alive connections that are not currently serving a request, without stopping the server: @@ -392,7 +438,7 @@ Use this for development and hot reloading. You can update only `fetch`, `error` ### `server.timeout(Request, seconds)` -Override the idle timeout for an individual request. Pass `0` to disable the timeout entirely for that request. +Override the idle timeout for an individual request. Pass `0` to disable the timeout entirely for that request. The maximum is `255`, and larger values are clamped to it. ```ts const server = Bun.serve({ @@ -621,7 +667,7 @@ interface Server extends Disposable { * Make a request to the running server. * Useful for testing or internal routing. */ - fetch(request: Request | string): Response | Promise; + fetch(request: Request | string | URL, init?: RequestInit): Response | Promise; /** * Upgrade an HTTP request to a WebSocket connection. diff --git a/docs/runtime/module-resolution.mdx b/docs/runtime/module-resolution.mdx index 77760cb4b9ad..bd9c93319d8d 100644 --- a/docs/runtime/module-resolution.mdx +++ b/docs/runtime/module-resolution.mdx @@ -307,7 +307,7 @@ Bun includes its own implementation of a few npm packages. When you import one o | `abort-controller`, `abort-controller/polyfill` | The global `AbortController` and `AbortSignal`. | | `next/dist/compiled/undici`, `next/dist/compiled/node-fetch`, `next/dist/compiled/ws` | The same built-in modules as `undici`, `node-fetch`, and `ws`. | -A built-in module does not implement every API of the package it replaces. For example, the built-in `undici` exports `Agent`, `Pool`, `Client`, `ProxyAgent`, and `MockAgent` so that imports succeed. These classes do not manage connections, and `fetch` ignores the `dispatcher` option. `stream()`, `pipeline()`, `connect()`, and `upgrade()` throw an error. If your code needs an API that is missing, [open an issue](https://bun.com/issues). +A built-in module does not implement every API of the package it replaces. For example, the built-in `undici` sends every request with Bun's `fetch`. `Agent`, `Pool`, `Client`, and `ProxyAgent` implement `request()`, `stream()`, `dispatch()`, `close()`, and `destroy()`. They apply their origin, their proxy, `socketPath`, and the TLS options in `connect`, and `fetch` and `request()` do the same for the `dispatcher` option and the global dispatcher. They do not manage connections, so `connections`, `pipelining`, the timeouts, and the `lookup` hook in `connect` have no effect, and a `connect` function is never called. `MockAgent` is exported so that imports succeed. `pipeline()`, `connect()`, and `upgrade()` throw an error. If your code needs an API that is missing, [open an issue](https://bun.com/issues). Bun matches the whole specifier. Any other subpath, such as `undici/package.json`, resolves to the installed package. For a replaced specifier, `require.resolve()` and `import.meta.resolve()` return the name of the built-in module instead of a file path. For example, both `"undici"` and `"next/dist/compiled/undici"` resolve to `"undici"`. diff --git a/docs/runtime/networking/fetch.mdx b/docs/runtime/networking/fetch.mdx index 280c77533d9d..61d933c4a1b2 100644 --- a/docs/runtime/networking/fetch.mdx +++ b/docs/runtime/networking/fetch.mdx @@ -239,6 +239,8 @@ const response = await fetch("https://hostname/a/path", { }); ``` +Bun follows a redirect to the same origin (a relative `Location`, or the same scheme, host and port) over the socket. A redirect to another origin leaves the socket and connects over TCP. + ### TLS To use a client certificate, use the `tls` option: @@ -473,6 +475,10 @@ bun --fetch-preconnect https://bun.com ./my-script.ts Bun automatically reuses connections to the same host. This is called **connection pooling**, and it can significantly reduce the time spent establishing connections. +An idle connection is reused for up to 5 minutes. If the response has a `Keep-Alive: timeout=N` header, Bun stops reusing the connection 2 seconds before the server's timeout runs out, and doesn't pool it at all when `N` is 2 or less. A request that reaches the server just as it closes an idle connection fails with `ECONNRESET`, and Bun only retries idempotent methods (`GET`, `HEAD`, `PUT`, `DELETE`, ...), never a `POST`. If your server closes idle connections early and doesn't send that header, set a shorter `keepAlive.idleTimeout` on a fetch session. + +Each process has one pool of idle connections for plain HTTP, one for HTTPS, and one for each custom `tls` configuration. A pool holds up to 64 connections, including connections through a proxy and HTTP/2 sessions. When it's full, Bun closes the connection that has been idle the longest. `BUN_CONFIG_MAX_HTTP_REQUESTS` limits simultaneous requests and doesn't change the pool size. + #### Fetch sessions A `Bun.FetchSession` holds connection settings and owns a keep-alive pool. `session.fetch` is `fetch` with that session, and it is bound, so it works wherever a `fetch` function is accepted (`new SomeClient({ fetch: session.fetch })`). It has no `preconnect`. `fetch(url, { session })` does the same through the global `fetch`. Bun never shares a connection between two sessions, or between a session and plain `fetch`. @@ -499,13 +505,13 @@ An option given on the request takes precedence over the session's, `proxy: fals The constructor throws for a `proxy` or a `tls.checkServerIdentity` that it cannot use, such as `proxy: 8080`, a `proxy` object without a `url`, or a `checkServerIdentity` that is not a function. For `proxy`, only `undefined`, `null` and `""` mean no option. On a request, `fetch` ignores such a `proxy` (`true` throws), and the request keeps the proxy of its session or of the environment. -Bun rounds `idleTimeout` up to its socket timer, which moves in 4 second steps up to four minutes and in whole minutes beyond that. A connection is never closed before `idleTimeout`, and it can stay open for up to two steps longer. The longest `idleTimeout` is 238 minutes. A larger value means 238 minutes. +A connection that has been idle for `idleTimeout` seconds (rounded up to a whole second) is never reused. A server's `Keep-Alive: timeout=N` header can only shorten that. The socket timer that closes the connection is coarser. It moves in 4 second steps up to four minutes and in whole minutes beyond that, so the connection can stay open for up to two steps longer. The longest `idleTimeout` is 238 minutes. A larger value means 238 minutes. Without `maxIdleSockets` a session has no limit of its own. The idle connections of all sessions and of plain `fetch` share one bounded pool per kind. When that pool is full, Bun closes the longest-idle connection in it, whichever session it belongs to. A `maxIdleSockets` above the size of the pool has no effect. #### Connecting to a specific address -To send a request for a hostname to an address you resolved and checked yourself, put the address in the URL and name the origin in the `Host` header and in `tls.serverName`. Bun dials the address, sends `Host` (as `:authority` over HTTP/2), uses `serverName` for SNI, and verifies the certificate against `serverName`. +To send a request for a hostname to an address you resolved and checked yourself, put the address in the URL and name the origin in the `Host` header and in `tls.serverName`. Bun dials the address, sends `Host` (as `:authority` over HTTP/2 and HTTP/3), uses `serverName` for SNI, and verifies the certificate against `serverName`. ```ts import { lookup } from "node:dns/promises"; @@ -525,7 +531,6 @@ const response = await fetch(`https://${family === 6 ? `[${address}]` : address} - Follow redirects yourself (`redirect: "manual"`): `Host` and `serverName` describe this origin only, and a relative `Location` resolves against the address. - A name with several addresses has no fallback here. Try each address yourself. - Bun sends `Host` as given. Include the port when it is not the scheme's default. A plain `http://` URL needs only `Host`. -- `tls.serverName` works with HTTP/1.1 and HTTP/2. `protocol: "http3"` rejects it. - Each distinct `serverName` has its own TLS context and keep-alive pool. Bun caches up to 60 of them. #### Simultaneous connection limit @@ -566,11 +571,15 @@ await write("output.txt", response); ### Implementation details -- Connection pooling is enabled by default. You can disable it per-request with `keepalive: false` or the `"Connection: close"` header. +- Connection pooling is enabled by default. To send a request on a new connection, pass `keepalive: false` in the `fetch()` options. Bun closes that connection after the response. + - `fetch()` reads `keepalive` from its options, not from a `Request`. For a `Request`, write `fetch(request, { keepalive: false })`. + - A [`Bun.FetchSession`](#fetch-sessions) with `keepAlive: false` does the same for each of its requests. + - A `Connection: close` header has a different effect. Bun closes the connection after the response, but the request can go out on an idle connection from the pool. + - These rules apply to HTTP/1.1, which `fetch()` uses by default. An HTTP/2 or HTTP/3 request can share a connection that is already open, with or without `keepalive: false`. - Bun optimizes large file uploads using the operating system's `sendfile` syscall under specific conditions: - - The file must be larger than 32KB + - The file must be a regular file (not a pipe, socket, or device) larger than 32KB - The request must not be using a proxy or the `compress` option - - On macOS, only regular files (not pipes, sockets, or devices) can use `sendfile` + - On macOS, Bun reads the file in chunks as the socket drains instead of calling `sendfile` - When these conditions aren't met, or when using S3/streaming uploads, Bun falls back to reading the file into memory - Bun only uses this optimization for HTTP (not HTTPS) requests, where the file can be sent directly from the kernel to the network stack; Bun does not use it on Windows - S3 operations automatically handle signing requests and merging authentication headers diff --git a/docs/runtime/nodejs-compat.mdx b/docs/runtime/nodejs-compat.mdx index 2331b3a5a030..38fb1088a10f 100644 --- a/docs/runtime/nodejs-compat.mdx +++ b/docs/runtime/nodejs-compat.mdx @@ -45,7 +45,7 @@ We update this page regularly. It reflects the latest version of Bun's compatibi ### [`node:http`](https://nodejs.org/api/http.html) -🟢 Fully implemented. `http.Server` does not extend `net.Server`. Bun ignores `listen(handle)` and the `fd`, `ipv6Only` and `signal` options of `listen()`. `keepAlive`/`keepAliveInitialDelay` on the server are no-ops. +🟢 Fully implemented. `http.Server` does not extend `net.Server`. `listen({ fd })` is not supported on Windows. Bun ignores `listen(handle)` and the `ipv6Only` option of `listen()`. ### [`node:https`](https://nodejs.org/api/https.html) @@ -105,7 +105,7 @@ We update this page regularly. It reflects the latest version of Bun's compatibi ### [`node:cluster`](https://nodejs.org/api/cluster.html) -🟡 `net` and `dgram` servers in workers are shared through the primary as in Node.js (`SCHED_RR` and `SCHED_NONE`), and handles can be passed with `worker.send()`. `node:http`/`node:https` servers in workers each bind their own socket instead, so load-balancing HTTP requests across processes is only supported on Linux (through `SO_REUSEPORT`). Otherwise, implemented but not battle-tested. +🟡 `net` and `dgram` servers in workers are shared through the primary as in Node.js (`SCHED_RR` and `SCHED_NONE`), and handles can be passed with `worker.send()`. `node:http`/`node:https` servers in workers that listen on a fixed port each bind their own socket instead, so load-balancing HTTP requests across processes is only supported on Linux (through `SO_REUSEPORT`). A Unix socket path, port `0` and `listen({ fd })` are shared through the primary (not on Windows): every worker accepts on the one socket, as under `SCHED_NONE`. Otherwise, implemented but not battle-tested. ### [`node:crypto`](https://nodejs.org/api/crypto.html) diff --git a/docs/runtime/sql.mdx b/docs/runtime/sql.mdx index cf291eb21513..438c8b94e86e 100644 --- a/docs/runtime/sql.mdx +++ b/docs/runtime/sql.mdx @@ -695,6 +695,15 @@ Queries are lazy: they only start executing when awaited or run with `.execute() A query starts in the call to `.then()`, `.catch()`, `.finally()` or `.execute()`. `await query` calls `.then()`. A query that you never await or execute never runs. `query.active` is `true` while a connection runs the query. +Bun reads parameters late too. It keeps a reference to each interpolated value and serializes it when it sends the query, not when the template literal is evaluated. Don't change a `TypedArray`, `Buffer`, `ArrayBuffer`, `Date` or `sql(object)` value until the query settles. To reuse a buffer right away, pass a copy: `typedArray.slice()`, `Buffer.from(buffer)` or `arrayBuffer.slice(0)`. `subarray()` and `Buffer#slice()` return views of the same memory, not copies. + +```ts +const bytes = new Uint8Array([1, 2, 3, 4]); +const query = sql`INSERT INTO files (data) VALUES (${bytes})`; +bytes.fill(0); // the query hasn't read `bytes` yet +await query; // stores 00000000, not 01020304 +``` + To cancel a query, call `cancel()` on the query object. ```ts diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 59aa530c8e58..82c8394b175f 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -9110,12 +9110,13 @@ declare module "bun" { | boolean | { /** - * Seconds an idle connection stays in the pool before it is closed. - * The socket timer is coarse: it moves in 4 second steps up to four - * minutes, and in whole minutes beyond that. Bun rounds the value up - * to the timer, so a connection is never closed before `idleTimeout`, - * and it can stay open for up to two steps longer. The longest is - * 238 minutes (14280), and a larger value means that. + * Seconds an idle connection can be reused for, rounded up to a + * whole second. A server's `Keep-Alive: timeout=N` header can only + * shorten that. The socket timer that closes the connection is + * coarser: it moves in 4 second steps up to four minutes, and in + * whole minutes beyond that, so the connection can stay open for up + * to two steps longer. The longest is 238 minutes (14280), and a + * larger value means that. * * @default 300 */ diff --git a/packages/bun-types/fetch.d.ts b/packages/bun-types/fetch.d.ts index 37db04850a2d..08b1e8cb0944 100644 --- a/packages/bun-types/fetch.d.ts +++ b/packages/bun-types/fetch.d.ts @@ -71,6 +71,7 @@ declare module "bun" { interface BunRequestOverride extends LibOrFallbackRequest { headers: BunHeadersOverride; + clone(): Request; /** * Returns a {@link ReadableStream} of the body decoded as UTF-8 text. * @@ -83,6 +84,7 @@ declare module "bun" { interface BunResponseOverride extends LibOrFallbackResponse { headers: BunHeadersOverride; + clone(): Response; /** * Returns a {@link ReadableStream} of the body decoded as UTF-8 text. * diff --git a/packages/bun-types/globals.d.ts b/packages/bun-types/globals.d.ts index 349cd414a182..d3751f204dd9 100644 --- a/packages/bun-types/globals.d.ts +++ b/packages/bun-types/globals.d.ts @@ -1940,6 +1940,7 @@ declare var Response: Bun.__internal.UseLibDomIfAvailable< * * @param body Value to serialize with `JSON.stringify` * @param init Options for the response + * @throws {TypeError} If `JSON.stringify(body)` returns `undefined`, as it does for `undefined`, a symbol or a function * @link https://github.com/whatwg/fetch/issues/1389 */ json(body?: any, init?: ResponseInit | number): Response; @@ -2095,6 +2096,10 @@ interface BunFetchRequestInit extends RequestInit { * default (h2 is offered iff the flag is on). `"h2"`, `"h3"` and `"h1"` * are aliases. * + * Without this option, a request the server refuses over HTTP/2 with + * `HTTP_1_1_REQUIRED` before sending a response is sent again over + * HTTP/1.1, unless its body is a stream. + * * Requires `https`. Not part of the Fetch API specification. * @experimental */ diff --git a/packages/bun-types/serve.d.ts b/packages/bun-types/serve.d.ts index 4d8dcc170ffb..026b4e9152e2 100644 --- a/packages/bun-types/serve.d.ts +++ b/packages/bun-types/serve.d.ts @@ -615,13 +615,11 @@ declare module "bun" { namespace Serve { type ExtractRouteParams = string extends T ? Record - : T extends `${string}:${infer Param}/${infer Rest}` - ? { [K in Param]: string } & ExtractRouteParams - : T extends `${string}:${infer Param}` + : T extends `${string}/:${infer Param}/${infer Rest}` + ? { [K in Param]: string } & ExtractRouteParams<`/${Rest}`> + : T extends `${string}/:${infer Param}` ? { [K in Param]: string } - : T extends `${string}*` - ? {} - : {}; + : {}; /** * Development configuration for {@link Bun.serve} @@ -677,11 +675,16 @@ declare module "bun" { * * The route path **must** end in `/*`. The part of the request URL after * the prefix is percent-decoded once and opened relative to `dir`. - * Non-canonical paths (containing `.`, `..`, empty segments, `%2F`, or a - * `%XX` sequence encoding a character that may appear literally in a path - * segment) are rejected with `404` so the served path is always the path - * the router matched. On Linux the open uses `openat2(RESOLVE_IN_ROOT)`, - * so symlinks that would escape `dir` are clamped by the kernel. Routing + * Non-canonical paths (a segment that is exactly `.` or `..`, an empty + * segment, `%2F`, or a `%XX` sequence encoding a character that may appear + * literally in a path segment) are rejected with `404` so the served path + * is always the path the router matched. On Linux the open uses + * `openat2(RESOLVE_IN_ROOT)`, so symlinks that would escape `dir` are + * clamped by the kernel. On macOS and Windows, and on Linux when `openat2` + * is unavailable (kernels before 5.6, or seccomp), symlinks are followed, + * even outside `dir`. Dotfiles aren't filtered: any regular file under + * `dir` is served, including `.env` or the contents of `.git/`, so `dir` + * should only contain public files. Routing * is case-sensitive but filesystems on macOS and Windows are not by * default: do not place access-controlled content inside `dir` and rely * on an overlapping route to gate it. @@ -713,13 +716,24 @@ declare module "bun" { statCache?: boolean; } - type BaseRouteValue = Response | false | HTMLBundle | BunFile | DirectoryRouteOptions; + /** + * `Bun.file(path)` is read from disk on each request. Any other `Blob` + * (`new Blob()`, a file embedded in a compiled executable, a + * {@link Bun.embeddedFiles} entry) is served from memory, like + * `new Response(blob)`. An {@link S3File} is rejected. + */ + type BaseRouteValue = Response | false | HTMLBundle | Blob | DirectoryRouteOptions; type Routes = { [Path in R]: | BaseRouteValue | Handler, Server, Response> - | Partial, Server, Response> | Response>>; + | Partial< + Record< + HTTPMethod, + Handler, Server, Response> | Exclude + > + >; }; type RoutesWithUpgrade = { @@ -727,7 +741,11 @@ declare module "bun" { | BaseRouteValue | Handler, Server, Response | undefined | void> | Partial< - Record, Server, Response | undefined | void> | Response> + Record< + HTTPMethod, + | Handler, Server, Response | undefined | void> + | Exclude + > >; }; @@ -840,10 +858,28 @@ declare module "bun" { /** * The maximum size of a request body, in bytes + * + * A request with a larger `Content-Length` gets a `413` before its + * handler runs. A body without a declared length is counted as it + * arrives. When it passes the limit, Bun aborts the request the way it + * does when the client disconnects: `request.signal` aborts and reads of + * the body reject. The client gets a `413`, or a connection reset if the + * response has already started. + * * @default 1024 * 1024 * 128 // 128MB */ maxRequestBodySize?: number; + /** + * Sets the number of seconds to wait before timing out a connection + * due to inactivity. `0` disables the timeout. The maximum is `255`. + * + * This is also how long an idle keep-alive connection stays open between requests. + * + * @default 10 + */ + idleTimeout?: number; + /** * Whether to render contextual errors with Bun's error page * @default process.env.NODE_ENV !== 'production' @@ -948,14 +984,6 @@ declare module "bun" { * @experimental */ http1?: boolean; - - /** - * Sets the number of seconds to wait before timing out a connection - * due to inactivity. - * - * @default 10 - */ - idleTimeout?: number; } interface UnixServeOptions extends BaseServeOptions { @@ -1062,13 +1090,16 @@ declare module "bun" { reload(options: Serve.Options): Server; /** - * Mock the fetch handler for a running server. + * Mock the fetch handler for a running server. The handler receives + * `(request, server)`, as it does for a request from a socket. + * + * `init` applies to a string or a `URL`. It is ignored for a `Request`. * * This feature is not fully implemented: it doesn't normalize URLs * consistently in all cases and it doesn't always call the `error` * handler. */ - fetch(request: Request | string): Response | Promise; + fetch(request: Request | string | URL, init?: RequestInit): Response | Promise; /** * Upgrade a {@link Request} to a {@link ServerWebSocket} @@ -1221,6 +1252,7 @@ declare module "bun" { /** * Reset the idle timeout of the given Request to the given number of seconds. `0` means no timeout. + * The maximum is `255`, and larger values are clamped to it. * * @example * ```js diff --git a/packages/bun-usockets/src/bsd.c b/packages/bun-usockets/src/bsd.c index a1190b3a6515..d39708b2947e 100644 --- a/packages/bun-usockets/src/bsd.c +++ b/packages/bun-usockets/src/bsd.c @@ -83,6 +83,9 @@ extern void uv__winsock_ensure(void); /* We need to emulate sendmmsg, recvmmsg on platform who don't have it */ int bsd_sendmmsg(LIBUS_SOCKET_DESCRIPTOR fd, struct udp_sendbuf* sendbuf, int flags) { + ssize_t injected = 0; int unused = 0; + if (US_FAULT_CHECK(US_FAULT_SENDMSG, fd, injected, unused)) return (int) injected; + (void) injected; (void) unused; #if defined(_WIN32)// || defined(__APPLE__) for (int i = 0; i < sendbuf->num; i++) { while (1) { @@ -153,6 +156,9 @@ int bsd_sendmmsg(LIBUS_SOCKET_DESCRIPTOR fd, struct udp_sendbuf* sendbuf, int fl int bsd_recvmmsg(LIBUS_SOCKET_DESCRIPTOR fd, struct udp_recvbuf *recvbuf, int flags, int max_packets) { if (max_packets > LIBUS_UDP_RECV_COUNT) max_packets = LIBUS_UDP_RECV_COUNT; + ssize_t injected = 0; int unused = 0; + if (US_FAULT_CHECK(US_FAULT_RECVMSG, fd, injected, unused)) return (int) injected; + (void) injected; (void) unused; #if defined(_WIN32) for (int i = 0; i < LIBUS_UDP_RECV_COUNT; i++) { while (1) { @@ -687,6 +693,38 @@ int bsd_socket_buffer_size(LIBUS_SOCKET_DESCRIPTOR fd, int is_recv, int size, in return 0; } +int bsd_socket_delivered_bytes(LIBUS_SOCKET_DESCRIPTOR fd, uint64_t *bytes) { +#if defined(__linux__) + /* The start of the kernel's append-only struct tcp_info. glibc's copy stops before tcpi_bytes_acked. */ + struct { + uint8_t u8[8]; + uint32_t u32[24]; + uint64_t pacing_rate; + uint64_t max_pacing_rate; + uint64_t bytes_acked; + } info; + socklen_t len = sizeof(info); + if (getsockopt(fd, IPPROTO_TCP, TCP_INFO, &info, &len) != 0 || len < sizeof(info)) { + return -1; + } + *bytes = info.bytes_acked; + return 0; +#elif defined(__APPLE__) + struct tcp_connection_info info; + socklen_t len = sizeof(info); + if (getsockopt(fd, IPPROTO_TCP, TCP_CONNECTION_INFO, &info, &len) != 0) { + return -1; + } + *bytes = info.tcpi_txbytes; + return 0; +#else + /* Elsewhere the caller can't tell a slow reader from a stalled one. */ + (void) fd; + (void) bytes; + return -1; +#endif +} + void bsd_socket_flush(LIBUS_SOCKET_DESCRIPTOR fd) { // Linux TCP_CORK has the same underlying corking mechanism as with MSG_MORE #ifdef TCP_CORK @@ -1173,6 +1211,7 @@ inline __attribute__((always_inline)) LIBUS_SOCKET_DESCRIPTOR bsd_bind_listen_fd ) { if (bsd_set_reuse(listenFd, options) != 0) { + *error = LIBUS_ERR; return LIBUS_SOCKET_ERROR; } @@ -1189,6 +1228,7 @@ inline __attribute__((always_inline)) LIBUS_SOCKET_DESCRIPTOR bsd_bind_listen_fd if (listenAddr->ai_family == AF_INET6) { int enabled = (options & LIBUS_SOCKET_IPV6_ONLY) != 0; if (setsockopt(listenFd, IPPROTO_IPV6, IPV6_V6ONLY, &enabled, sizeof(enabled)) != 0) { + *error = LIBUS_ERR; return LIBUS_SOCKET_ERROR; } } @@ -1357,7 +1397,7 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_bound_socket(const char *host, int port, int return fd; } -LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket(const char *host, int port, int options, int* error) { +LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket(const char *host, int port, int options, int* error, int* dns_error) { bsd_winsock_ensure(); struct addrinfo hints, *result; memset(&hints, 0, sizeof(struct addrinfo)); @@ -1369,7 +1409,28 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket(const char *host, int port, int char port_string[16]; snprintf(port_string, 16, "%d", port); - if (getaddrinfo(host, port_string, &hints, &result)) { +#ifndef _WIN32 + errno = 0; +#endif + int gai = getaddrinfo(host, port_string, &hints, &result); + if (gai != 0) { +#ifndef _WIN32 + /* At the descriptor limit glibc answers EAI_NONAME or EAI_SYSTEM, with the cause in errno. */ + if (errno != 0 && (gai == EAI_SYSTEM || errno == EMFILE || errno == ENFILE)) { + *error = errno; + return LIBUS_SOCKET_ERROR; + } +#endif + if (host && strchr(host, ':')) { + /* An IPv6 literal needs no lookup. It fails when its %zone names no interface. */ +#ifdef _WIN32 + *error = WSAEINVAL; +#else + *error = EINVAL; +#endif + return LIBUS_SOCKET_ERROR; + } + *dns_error = gai; return LIBUS_SOCKET_ERROR; } @@ -1377,7 +1438,7 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket(const char *host, int port, int struct addrinfo *listenAddr; for (struct addrinfo *a = result; a != NULL; a = a->ai_next) { if (a->ai_family == AF_INET6) { - listenFd = bsd_create_socket(a->ai_family, a->ai_socktype, a->ai_protocol, NULL); + listenFd = bsd_create_socket(a->ai_family, a->ai_socktype, a->ai_protocol, error); if (listenFd == LIBUS_SOCKET_ERROR) { continue; } @@ -1394,7 +1455,7 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket(const char *host, int port, int for (struct addrinfo *a = result; a != NULL; a = a->ai_next) { if (a->ai_family == AF_INET) { - listenFd = bsd_create_socket(a->ai_family, a->ai_socktype, a->ai_protocol, NULL); + listenFd = bsd_create_socket(a->ai_family, a->ai_socktype, a->ai_protocol, error); if (listenFd == LIBUS_SOCKET_ERROR) { continue; } @@ -1554,20 +1615,18 @@ static LIBUS_SOCKET_DESCRIPTOR bsd_create_unix_socket_address(const char *path, static LIBUS_SOCKET_DESCRIPTOR internal_bsd_create_listen_socket_unix(const char* path, int options, struct sockaddr_un* server_address, size_t addrlen, int* error) { LIBUS_SOCKET_DESCRIPTOR listenFd = LIBUS_SOCKET_ERROR; - listenFd = bsd_create_socket(AF_UNIX, SOCK_STREAM, 0, NULL); + listenFd = bsd_create_socket(AF_UNIX, SOCK_STREAM, 0, error); if (listenFd == LIBUS_SOCKET_ERROR) { return LIBUS_SOCKET_ERROR; } if (us_internal_bind_and_listen(listenFd, (struct sockaddr *) server_address, (socklen_t) addrlen, 512, error)) { - #if defined(_WIN32) - int shouldSimulateENOENT = WSAGetLastError() == WSAENETDOWN; - #endif bsd_close_socket(listenFd); #if defined(_WIN32) - if (shouldSimulateENOENT) { - SetLastError(ERROR_PATH_NOT_FOUND); + /* bind() answers WSAENETDOWN when the directory of the path does not exist. */ + if (*error == WSAENETDOWN) { + *error = ERROR_PATH_NOT_FOUND; } #endif return LIBUS_SOCKET_ERROR; @@ -1582,9 +1641,13 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket_unix(const char *path, size_t l size_t addrlen = 0; if (bsd_create_unix_socket_address(path, len, &dirfd_workaround_for_unix_path_len, &server_address, &addrlen)) { /* The path could not be expressed as a sockaddr_un (the basename - * exceeds sun_path even with the dirfd workaround); surface the errno - * so the caller can report something better than a codeless failure. */ - if (error && errno) *error = errno; + * exceeds sun_path even with the dirfd workaround). That helper + * reports through SetLastError on Windows and errno elsewhere. */ +#if defined(_WIN32) + *error = (int) GetLastError(); +#else + *error = errno; +#endif return LIBUS_SOCKET_ERROR; } @@ -1592,7 +1655,7 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket_unix(const char *path, size_t l if (dirfd_workaround_for_unix_path_len != -1) { if (__pthread_fchdir(dirfd_workaround_for_unix_path_len) != 0) { close(dirfd_workaround_for_unix_path_len); - errno = ENAMETOOLONG; + *error = ENAMETOOLONG; return LIBUS_SOCKET_ERROR; } } @@ -1602,10 +1665,8 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket_unix(const char *path, size_t l #if defined(__APPLE__) if (dirfd_workaround_for_unix_path_len != -1) { - int saved_errno = errno; __pthread_fchdir(-1); close(dirfd_workaround_for_unix_path_len); - errno = saved_errno; } #elif defined(__linux__) if (dirfd_workaround_for_unix_path_len != -1) { diff --git a/packages/bun-usockets/src/context.c b/packages/bun-usockets/src/context.c index 9d087ba990a9..039ef4120de9 100644 --- a/packages/bun-usockets/src/context.c +++ b/packages/bun-usockets/src/context.c @@ -383,8 +383,8 @@ static void us_internal_init_listen_socket(struct us_listen_socket_t *ls, struct us_listen_socket_t *us_socket_group_listen(struct us_socket_group_t *group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, - const char *host, int port, int options, int socket_ext_size, int *error) { - LIBUS_SOCKET_DESCRIPTOR listen_socket_fd = bsd_create_listen_socket(host, port, options, error); + const char *host, int port, int options, int socket_ext_size, int *error, int *dns_error) { + LIBUS_SOCKET_DESCRIPTOR listen_socket_fd = bsd_create_listen_socket(host, port, options, error, dns_error); if (listen_socket_fd == LIBUS_SOCKET_ERROR) { return 0; } @@ -392,14 +392,10 @@ struct us_listen_socket_t *us_socket_group_listen(struct us_socket_group_t *grou struct us_poll_t *p = us_create_poll(group->loop, 0, sizeof(struct us_listen_socket_t)); us_poll_init(p, listen_socket_fd, POLL_TYPE_SEMI_SOCKET); if (us_poll_start_rc(p, group->loop, LIBUS_SOCKET_READABLE) != 0) { - /* EPOLL_CTL_ADD failed (e.g. ENOSPC at fs.epoll.max_user_watches). - * Report via both the out-param and thread-local errno: Bun.listen - * reads *error, Bun.serve reads errno. */ - int saved_errno = errno; + /* EPOLL_CTL_ADD failed (e.g. ENOSPC at fs.epoll.max_user_watches). */ + *error = errno; bsd_close_socket(listen_socket_fd); us_poll_free(p, group->loop); - *error = saved_errno; - errno = saved_errno; return 0; } @@ -458,11 +454,9 @@ struct us_listen_socket_t *us_socket_group_listen_unix(struct us_socket_group_t struct us_poll_t *p = us_create_poll(group->loop, 0, sizeof(struct us_listen_socket_t)); us_poll_init(p, listen_socket_fd, POLL_TYPE_SEMI_SOCKET); if (us_poll_start_rc(p, group->loop, LIBUS_SOCKET_READABLE) != 0) { - int saved_errno = errno; + *error = errno; bsd_close_socket(listen_socket_fd); us_poll_free(p, group->loop); - *error = saved_errno; - errno = saved_errno; return 0; } diff --git a/packages/bun-usockets/src/eventing/libuv.c b/packages/bun-usockets/src/eventing/libuv.c index 5b6b9bf10c55..848736582a8b 100644 --- a/packages/bun-usockets/src/eventing/libuv.c +++ b/packages/bun-usockets/src/eventing/libuv.c @@ -588,6 +588,17 @@ struct us_loop_t *us_timer_loop(struct us_timer_t *t) { return internal_cb->loop; } +void us_internal_sweep_soon(struct us_loop_t *loop) { + struct us_internal_callback_t *internal_cb = + (struct us_internal_callback_t *)loop->data.sweep_timer; + + /* us_timer_set() refuses to restart a running sweep timer. */ + if (internal_cb->has_added_timer_to_event_loop) { + uv_timer_start((uv_timer_t *)(internal_cb + 1), timer_cb, 1, + LIBUS_TIMEOUT_GRANULARITY * 1000); + } +} + // async (internal only) struct us_internal_async *us_internal_create_async(struct us_loop_t *loop, int fallthrough, diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index 724717d18cac..7dcdf7e79bc9 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -182,6 +182,8 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in void us_internal_timer_sweep(us_loop_r loop); void us_internal_enable_sweep_timer(struct us_loop_t *loop); void us_internal_disable_sweep_timer(struct us_loop_t *loop); +/* Tests only: run the sweep on the next loop iteration instead of waiting out LIBUS_TIMEOUT_GRANULARITY. */ +void us_internal_sweep_soon(struct us_loop_t *loop); #ifndef LIBUS_USE_LIBUV /* CLOCK_MONOTONIC in ns. The clock every deadline on the loop is measured * against, so anything comparing against one must read it and not another. */ diff --git a/packages/bun-usockets/src/internal/networking/bsd.h b/packages/bun-usockets/src/internal/networking/bsd.h index e269a5ca81c5..7433464a9b88 100644 --- a/packages/bun-usockets/src/internal/networking/bsd.h +++ b/packages/bun-usockets/src/internal/networking/bsd.h @@ -189,6 +189,8 @@ int bsd_socket_get_tos(LIBUS_SOCKET_DESCRIPTOR fd); * resulting value to *out; on failure returns the setsockopt/getsockopt result * with the error left in errno (WSAGetLastError on Windows). */ int bsd_socket_buffer_size(LIBUS_SOCKET_DESCRIPTOR fd, int is_recv, int size, int *out); +/* See us_socket_delivered_bytes. */ +int bsd_socket_delivered_bytes(LIBUS_SOCKET_DESCRIPTOR fd, uint64_t *bytes); int bsd_prepare_adopted_udp_socket(LIBUS_SOCKET_DESCRIPTOR fd); int bsd_set_reuseaddr(LIBUS_SOCKET_DESCRIPTOR fd); int bsd_bind_udp_fd(LIBUS_SOCKET_DESCRIPTOR fd, const struct sockaddr *addr, int addrlen, int flags); @@ -231,7 +233,7 @@ int bsd_send_is_transient_error(); // return LIBUS_SOCKET_ERROR or the fd that represents listen socket // listen both on ipv6 and ipv4 -LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket(const char *host, int port, int options, int* error); +LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket(const char *host, int port, int options, int* error, int* dns_error); LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket_unix(const char *path, size_t pathlen, int options, int* error); diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index 02f3a0523f41..ce82c00dd26b 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -399,11 +399,16 @@ void us_socket_set_first_flight_before_fin(us_socket_r s) nonnull_fn_decl; /* ── Listen ─────────────────────────────────────────────────────────────── * The listener owns: an embedded group for accepted sockets, the SSL_CTX * (borrowed ref, optional), the SNI tree (optional), and the kind to stamp on - * accepted sockets. */ + * accepted sockets. + * + * On failure these return NULL and set *error to the failing call's errno (a + * WSA or Win32 code on Windows). If the lookup of `host` failed instead, + * *dns_error holds getaddrinfo()'s return value and *error is untouched. The + * two number spaces overlap, so callers zero both first. */ struct us_listen_socket_t *us_socket_group_listen(us_socket_group_r group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, - const char *host, int port, int options, int socket_ext_size, int *error) - __attribute__((nonnull(1, 8))); /* ssl_ctx, host nullable */ + const char *host, int port, int options, int socket_ext_size, int *error, int *dns_error) + __attribute__((nonnull(1, 8, 9))); /* ssl_ctx, host nullable */ struct us_listen_socket_t *us_socket_group_listen_unix(us_socket_group_r group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, const char *path, size_t pathlen, int options, int socket_ext_size, int *error) @@ -724,7 +729,9 @@ void us_socket_remote_address(us_socket_r s, char *nonnull_arg buf, int *nonnull void us_socket_local_address(us_socket_r s, char *nonnull_arg buf, int *nonnull_arg length) nonnull_fn_decl; int us_socket_ipc_write_fd(us_socket_r s, const char *data, int length, int fd) nonnull_fn_decl; -void us_socket_sendfile_needs_more(us_socket_r s) nonnull_fn_decl; +/* Dispatches on_writable once the socket is writable, as if a us_socket_write() had come up short. + * Safe to call from on_writable. Doesn't resume a paused read side. */ +void us_socket_request_writable(us_socket_r s) nonnull_fn_decl; void *us_listen_socket_ext(struct us_listen_socket_t *ls) nonnull_fn_decl; LIBUS_SOCKET_DESCRIPTOR us_listen_socket_get_fd(struct us_listen_socket_t *ls) nonnull_fn_decl; int us_listen_socket_port(struct us_listen_socket_t *ls) nonnull_fn_decl; @@ -756,6 +763,12 @@ int us_socket_get_error(us_socket_r s); * making a stall there routine backpressure, so it asks the kernel * (SO_ERROR, then a zero-byte send probe). */ int us_socket_stalled_write_means_peer_gone(us_socket_r s); +/* A running count of the bytes TCP has delivered to the peer (Linux: tcpi_bytes_acked, macOS: tcpi_txbytes). + * Unlike the send queue's depth, it grows while a producer refills the queue as fast as the peer drains it. + * Returns 0, or -1 if this platform or socket type keeps no such count. */ +int us_socket_delivered_bytes(us_socket_r s, uint64_t *bytes); +/* Returns 1 and moves *mark forward if at least min_bytes were delivered since us_socket_delivered_bytes() read *mark. */ +int us_socket_delivered_since(us_socket_r s, uint64_t *mark, uint64_t min_bytes); void us_socket_ref(us_socket_r s); void us_socket_unref(us_socket_r s); diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index abfdd6af2a55..9a2d39cb5768 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -118,6 +118,12 @@ void us_internal_sweep_if_due(struct us_loop_t *loop) { us_internal_timer_sweep(loop); } +void us_internal_sweep_soon(struct us_loop_t *loop) { + if (loop->data.sweep_next_tick_ns >= 0) { + loop->data.sweep_next_tick_ns = 0; + } +} + #endif diff --git a/packages/bun-usockets/src/node_quic_shim.c b/packages/bun-usockets/src/node_quic_shim.c index 11cac3b9920f..36b58fab9113 100644 --- a/packages/bun-usockets/src/node_quic_shim.c +++ b/packages/bun-usockets/src/node_quic_shim.c @@ -1,4 +1,5 @@ #include "internal/internal.h" +#include "quic.h" #include "lsquic.h" #include "lsxpack_header.h" #include @@ -25,7 +26,7 @@ struct us_nq_vtable { void (*on_stream_reset)(void *stream_ctx, int how, uint64_t error_code); ssize_t (*on_dg_write)(void *conn_ctx, void *buf, size_t buf_sz); void (*on_datagram)(void *conn_ctx, const void *buf, size_t sz); - void (*on_datagram_status)(void *conn_ctx, unsigned count, int acked); + void (*on_datagram_status)(void *conn_ctx, uint64_t id, int status); void (*on_early_data_failed)(void *conn_ctx); void (*on_path_switch)(void *conn_ctx, int validated, int is_preferred, const struct sockaddr *new_local, @@ -40,16 +41,20 @@ struct us_nq_vtable { const char *sni); int (*packets_out)(void *owner, const struct lsquic_out_spec *specs, unsigned n); - void (*on_mini_conn_failed)(void *owner, const struct sockaddr *peer_sa, - uint64_t error_code); + void *(*on_new_mini_conn)(void *owner, lsquic_conn_t *c); + void (*on_mini_conn_failed)(void *conn_ctx, uint64_t error_code); }; +/* The conn already has its session: a client's from lsquic_engine_connect(), + * a server's from its mini conn. Rust reads it from the conn. */ static lsquic_conn_ctx_t *nq_on_new_conn(void *if_ctx, lsquic_conn_t *c) { - void *existing = (void *) lsquic_conn_get_ctx(c); - if (existing) return (lsquic_conn_ctx_t *) existing; struct us_nq_vtable *vt = if_ctx; return (lsquic_conn_ctx_t *) vt->on_new_conn(vt->owner, c); } +static lsquic_conn_ctx_t *nq_on_new_mini_conn(void *if_ctx, lsquic_conn_t *c) { + struct us_nq_vtable *vt = if_ctx; + return (lsquic_conn_ctx_t *) vt->on_new_mini_conn(vt->owner, c); +} static void nq_on_conn_closed(lsquic_conn_t *c) { void *ctx = (void *) lsquic_conn_get_ctx(c); lsquic_conn_set_ctx(c, NULL); @@ -65,10 +70,9 @@ static void nq_on_hsk_done(lsquic_conn_t *c, enum lsquic_hsk_status s) { vt->on_hsk_done(ctx, (int) s); } } -static void nq_on_mini_conn_failed(void *if_ctx, const struct sockaddr *peer_sa, - uint64_t error_code) { - struct us_nq_vtable *vt = if_ctx; - vt->on_mini_conn_failed(vt->owner, peer_sa, error_code); +static void nq_on_mini_conn_failed(lsquic_conn_ctx_t *ctx, uint64_t error_code) { + struct us_nq_vtable *vt = *(struct us_nq_vtable **) ctx; + vt->on_mini_conn_failed(ctx, error_code); } static void nq_on_hsk_confirmed(lsquic_conn_t *c) { void *ctx = (void *) lsquic_conn_get_ctx(c); @@ -149,11 +153,14 @@ static void nq_on_datagram(lsquic_conn_t *c, const void *buf, size_t sz) { vt->on_datagram(ctx, buf, sz); } } -static void nq_on_datagram_status(lsquic_conn_t *c, unsigned count, int acked) { +/* lsquic_sys mirrors these values as LSQ_DG_*. */ +_Static_assert(LSQ_DG_ACKED == 0 && LSQ_DG_LOST == 1 && LSQ_DG_UNSENT == 2, + "lsquic_sys::LSQ_DG_* must match enum lsquic_dg_status"); +static void nq_on_datagram_status(lsquic_conn_t *c, uint64_t id, int status) { void *ctx = (void *) lsquic_conn_get_ctx(c); if (ctx) { struct us_nq_vtable *vt = *(struct us_nq_vtable **) ctx; - vt->on_datagram_status(ctx, count, acked); + vt->on_datagram_status(ctx, id, status); } } static void nq_on_early_data_failed(lsquic_conn_t *c) { @@ -241,6 +248,10 @@ static struct lsxpack_header *nq_hsi_prepare_decode(void *hset, lsxpack_header_prepare_decode(&h->xhdr, h->decode_buf, 0, h->decode_cap); return &h->xhdr; } +static int nq_field_has_byte(const struct lsxpack_header *hdr, int byte) { + return (hdr->name_len && memchr(lsxpack_header_get_name(hdr), byte, hdr->name_len)) + || (hdr->val_len && memchr(lsxpack_header_get_value(hdr), byte, hdr->val_len)); +} static int nq_hsi_process_header(void *hset, struct lsxpack_header *hdr) { struct nq_hset *h = hset; if (!hdr) @@ -251,14 +262,16 @@ static int nq_hsi_process_header(void *hset, struct lsxpack_header *hdr) { if (h->max_bytes && h->total_bytes + hdr->name_len + hdr->val_len > h->max_bytes) return 0; - h->n_pairs++; - h->total_bytes += (size_t) hdr->name_len + hdr->val_len; const char *name = lsxpack_header_get_name(hdr); const char *val = lsxpack_header_get_value(hdr); /* RFC 9114 4.1.2 makes such a field invalid. */ - if ((hdr->name_len && memchr(name, 0, hdr->name_len)) - || (hdr->val_len && memchr(val, 0, hdr->val_len))) + if (nq_field_has_byte(hdr, 0)) return -1; + /* nghttp3 drops a regular field with CR or LF before Node counts it, and fails the session for a pseudo-header. */ + if (nq_field_has_byte(hdr, '\r') || nq_field_has_byte(hdr, '\n')) + return hdr->name_len && name[0] == ':' ? -1 : 0; + h->n_pairs++; + h->total_bytes += (size_t) hdr->name_len + hdr->val_len; size_t need = h->pairs_len + (size_t) hdr->name_len + 1 + (size_t) hdr->val_len + 1; if (need > h->pairs_cap) { @@ -351,6 +364,7 @@ static const struct lsquic_stream_if nq_stream_if = { .on_close = nq_on_stream_close, .on_hsk_done = nq_on_hsk_done, .on_hsk_confirmed = nq_on_hsk_confirmed, + .on_new_mini_conn = nq_on_new_mini_conn, .on_mini_conn_failed = nq_on_mini_conn_failed, .on_goaway_received = nq_on_goaway_received, .on_new_token = nq_on_new_token, @@ -384,19 +398,6 @@ static int nq_packets_out(void *out_ctx, const struct lsquic_out_spec *specs, return vt->packets_out(vt->owner, specs, n); } -static int nq_log_buf(void *ctx, const char *buf, size_t len) { - (void) ctx; - fwrite(buf, 1, len, stderr); - fputc('\n', stderr); - return 0; -} -static const struct lsquic_logger_if nq_logger = { nq_log_buf }; - -void us_nq_enable_logging(const char *level) { - lsquic_logger_init(&nq_logger, NULL, LLTS_HHMMSSUS); - lsquic_set_log_level(level); -} - size_t us_nq_vtable_size(void) { return sizeof(struct us_nq_vtable); } struct us_nq_tp { @@ -444,6 +445,19 @@ void us_nq_settings_init(struct lsquic_engine_settings *s, int is_server, /* lsquic then parses nothing past an unclaimed header block. A client's * default of 2 lets the body reach JS ahead of the `onheaders` before it. */ s->es_max_header_sets = 1; + /* lsquic's default stream windows are for HTTP/3: 0 for a bidirectional + * stream the server opens, 12 or 32 KiB for a unidirectional one. Use the + * window for client-opened bidirectional streams for all three, as Node + * uses one default (256 KiB) for all three. */ + if (is_server) { + s->es_init_max_stream_data_bidi_local = s->es_init_max_stream_data_bidi_remote; + s->es_init_max_stream_data_uni = s->es_init_max_stream_data_bidi_remote; + } else { + s->es_init_max_stream_data_bidi_remote = s->es_init_max_stream_data_bidi_local; + s->es_init_max_stream_data_uni = s->es_init_max_stream_data_bidi_local; + } + /* Node's handshakeTimeout bounds the whole handshake. */ + s->es_handshake_to_strict = 1; } #define NQ_SET(field, ctype) \ @@ -513,6 +527,7 @@ lsquic_engine_t *us_nq_engine_new(int is_server, int is_http, struct us_nq_vtable *vt, const struct lsquic_engine_settings *settings, const char *alpn) { + if (us_quic_global_init() != 0) return NULL; struct lsquic_engine_api api; memset(&api, 0, sizeof(api)); api.ea_settings = settings; diff --git a/packages/bun-usockets/src/quic.c b/packages/bun-usockets/src/quic.c index f5badaaba80f..98dca9590561 100644 --- a/packages/bun-usockets/src/quic.c +++ b/packages/bun-usockets/src/quic.c @@ -67,6 +67,9 @@ struct us_quic_socket_context_s { lsquic_engine_t *engine; struct lsquic_engine_settings settings; SSL_CTX *ssl_ctx; + /* Client only: set while lsquic_engine_connect creates a connection that + * has TLS options of its own. */ + SSL_CTX *connect_ssl_ctx; /* serverName -> SSL_CTX*, in the tree TCP listeners use (crypto/sni_tree.cpp). */ void *sni; int processing; @@ -74,6 +77,10 @@ struct us_quic_socket_context_s { /* us_quic_socket_context_shutdown ran while `processing` was set; * us_quic_engine_leave finishes it. */ int shutdown_pending; + /* us_quic_listen_socket_close ran, or the last UDP fd closed under live + * conns, while `processing` was set; us_quic_engine_leave runs + * us_quic_abort_conns. */ + int abort_pending; int is_client; /* lsquic only advertises es_max_header_list_size. This is what us_quic_hsi_process enforces. */ unsigned int max_header_set_size; @@ -102,6 +109,7 @@ struct us_quic_socket_context_s { void (*on_hsk_done)(us_quic_socket_t *, int); void (*on_goaway)(us_quic_socket_t *); void (*on_close)(us_quic_socket_t *); + void (*on_released)(us_quic_socket_context_t *); void (*on_stream_open)(us_quic_stream_t *, int); void (*on_stream_headers)(us_quic_stream_t *); void (*on_stream_data)(us_quic_stream_t *, const char *, unsigned int, int); @@ -239,6 +247,18 @@ static void us_quic_send_unsent(us_quic_socket_context_t *ctx) { us_quic_engine_leave(ctx); } +/* Closes every conn right away. The engine must be off the stack. cooldown + * skips a conn that is already going away, so abort each one first. */ +static void us_quic_abort_conns(us_quic_socket_context_t *ctx) { + if (!ctx->engine) return; + for (us_quic_socket_t *qs = ctx->conns; qs; qs = qs->next) { + if (qs->conn) lsquic_conn_abort(qs->conn); + } + lsquic_engine_cooldown(ctx->engine); + us_quic_process(ctx); + us_quic_send_unsent(ctx); +} + /* ───── packets out ───── */ static inline socklen_t sa_len(const struct sockaddr *sa) { @@ -328,10 +348,10 @@ static int us_quic_sendmmsg(int fd, struct mmsghdr *mm, unsigned k) { * retires that one datagram through ci_packet_too_large and feeds DPLPMTUD. * * A datagram the kernel refuses for any other reason (EPERM from a firewall - * rule, EACCES, ENETUNREACH, ...) is counted as sent. To lsquic that is a - * packet lost on the wire: the loss timer retransmits it with backoff, and the - * handshake or idle timeout ends a connection that never gets through. Neither - * remaining outcome fits: + * rule, EACCES, ENETUNREACH, ...) is counted as sent, and so is one whose UDP + * socket has closed. To lsquic that is a packet lost on the wire: the loss + * timer retransmits it with backoff, and the handshake or idle timeout ends a + * connection that never gets through. Neither remaining outcome fits: * - With the errno passed through, lsquic closes the connection that owns * specs[sent]. On a shared unconnected UDP socket Linux reports a pending * ICMP error on the next send to *any* destination, so the error isn't @@ -348,7 +368,7 @@ static int us_quic_packets_out(void *out_ctx, const struct lsquic_out_spec *spec struct mmsghdr mm[BATCH]; while (sent < n) { us_quic_listen_socket_t *ls = (us_quic_listen_socket_t *) specs[sent].peer_ctx; - if (!ls->udp) { errno = EBADF; break; } + if (!ls->udp) { sent++; continue; } int fd = us_poll_fd((struct us_poll_t *) ls->udp); unsigned k = 0; while (k < BATCH && sent + k < n && specs[sent + k].peer_ctx == (void *) ls) { @@ -383,7 +403,7 @@ static int us_quic_packets_out(void *out_ctx, const struct lsquic_out_spec *spec #else for (; sent < n; sent++) { us_quic_listen_socket_t *ls = (us_quic_listen_socket_t *) specs[sent].peer_ctx; - if (!ls->udp) { errno = EBADF; break; } + if (!ls->udp) continue; LIBUS_SOCKET_DESCRIPTOR fd = us_poll_fd((struct us_poll_t *) ls->udp); if (us_quic_send_one(fd, &specs[sent]) < 0) { int stop = us_quic_send_would_block(errno) || errno == EMSGSIZE; @@ -396,16 +416,13 @@ static int us_quic_packets_out(void *out_ctx, const struct lsquic_out_spec *spec #endif /* EMSGSIZE goes to lsquic as it is. Everything else that stops the loop is - * backpressure, or a UDP socket that is already closed: report EAGAIN, so - * the engine pauses and resumes from on_drain → send_unsent_packets (or - * from its one-second failsafe when there is no socket left to poll). */ + * backpressure: report EAGAIN, so the engine pauses and resumes from + * on_drain → send_unsent_packets. */ if (sent < n && errno != EMSGSIZE) { errno = EAGAIN; us_quic_listen_socket_t *ls = (us_quic_listen_socket_t *) specs[sent].peer_ctx; - if (ls->udp) { - us_poll_change((struct us_poll_t *) ls->udp, ls->ctx->loop, - LIBUS_SOCKET_READABLE | LIBUS_SOCKET_WRITABLE); - } + us_poll_change((struct us_poll_t *) ls->udp, ls->ctx->loop, + LIBUS_SOCKET_READABLE | LIBUS_SOCKET_WRITABLE); } return (int) sent; } @@ -433,6 +450,17 @@ static void us_quic_udp_on_drain(struct us_udp_socket_t *u) { us_quic_send_unsent(ls->ctx); } +int us_quic_socket_context_is_released(us_quic_socket_context_t *ctx) { + return !ctx->listeners && ctx->conn_count == 0; +} + +/* The last UDP fd and the last conn can go in either order. A drain closes the + * fd with the last conn. us_quic_release_pending_port and the loop's close on a + * socket error take the fd from under live conns. Both ends call this. */ +static void us_quic_report_if_released(us_quic_socket_context_t *ctx) { + if (ctx->on_released && us_quic_socket_context_is_released(ctx)) ctx->on_released(ctx); +} + static void us_quic_udp_on_close(struct us_udp_socket_t *u) { us_quic_listen_socket_t *ls = (us_quic_listen_socket_t *) us_udp_socket_user(u); us_quic_socket_context_t *ctx = ls->ctx; @@ -447,6 +475,14 @@ static void us_quic_udp_on_close(struct us_udp_socket_t *u) { } ls->next = ctx->closed_listeners; ctx->closed_listeners = ls; + /* Conns with no UDP fd left can't reach their peers, and the owner is + * waiting for the release. Close them now, or when the engine leaves. */ + if (ctx->on_released && !ctx->listeners && ctx->conn_count) { + if (ctx->processing) ctx->abort_pending = 1; + else us_quic_abort_conns(ctx); + return; + } + us_quic_report_if_released(ctx); } /* ───── SSL ───── */ @@ -454,7 +490,7 @@ static void us_quic_udp_on_close(struct us_udp_socket_t *u) { static SSL_CTX *us_quic_get_ssl_ctx(void *peer_ctx, const struct sockaddr *local) { (void) local; us_quic_listen_socket_t *ls = (us_quic_listen_socket_t *) peer_ctx; - return ls->ctx->ssl_ctx; + return ls->ctx->connect_ssl_ctx ? ls->ctx->connect_ssl_ctx : ls->ctx->ssl_ctx; } static SSL_CTX *us_quic_lookup_cert(void *cert_ctx, const struct sockaddr *local, const char *sni) { @@ -676,10 +712,14 @@ static void us_quic_on_conn_closed(lsquic_conn_t *conn) { #endif ctx->conn_count--; us_internal_disable_sweep_timer(ctx->loop); - /* During graceful drain the UDP fd is the only thing left holding the - * loop; release it when the last conn closes so the process can exit. */ - if (ctx->closing && ctx->conn_count == 0) { - while (ctx->listeners) us_udp_socket_close(ctx->listeners->udp); + if (ctx->conn_count == 0) { + if (ctx->closing && ctx->listeners) { + /* The graceful drain ends here. Release the UDP fd, the only thing + * left holding the loop. us_quic_udp_on_close reports the release. */ + while (ctx->listeners) us_udp_socket_close(ctx->listeners->udp); + } else { + us_quic_report_if_released(ctx); + } } } @@ -821,10 +861,11 @@ static int us_quic_log_buf(void *ctx, const char *buf, size_t len) { static const struct lsquic_logger_if us_quic_logger = { us_quic_log_buf }; #endif -/* Called once via a thread-safe static local in uws_h3_create_app - * (libuwsockets_h3.cpp), so quic.c stays free of pthread/call_once. */ -void us_quic_global_init(void) { - lsquic_global_init(LSQUIC_GLOBAL_SERVER | LSQUIC_GLOBAL_CLIENT); +/* lsquic_global_init() isn't idempotent: each call allocates a new SSL ex_data + * index, and a session that stored itself at the old index fails its handshake. */ +static int us_quic_global_init_result = -1; +static void us_quic_global_init_once(void) { + us_quic_global_init_result = lsquic_global_init(LSQUIC_GLOBAL_SERVER | LSQUIC_GLOBAL_CLIENT); #ifdef BUN_DEBUG if (getenv("BUN_DEBUG_lsquic")) { lsquic_logger_init(&us_quic_logger, NULL, LLTS_HHMMSSUS); @@ -833,6 +874,27 @@ void us_quic_global_init(void) { #endif } +#ifdef _WIN32 +static INIT_ONCE us_quic_global_once = INIT_ONCE_STATIC_INIT; +static BOOL CALLBACK us_quic_global_init_once_win(PINIT_ONCE o, PVOID p, PVOID *c) { + (void) o; (void) p; (void) c; + us_quic_global_init_once(); + return TRUE; +} +#else +#include +static pthread_once_t us_quic_global_once = PTHREAD_ONCE_INIT; +#endif + +int us_quic_global_init(void) { +#ifdef _WIN32 + InitOnceExecuteOnce(&us_quic_global_once, us_quic_global_init_once_win, NULL, NULL); +#else + pthread_once(&us_quic_global_once, us_quic_global_init_once); +#endif + return us_quic_global_init_result; +} + static void us_quic_prepare_ssl_ctx(SSL_CTX *ssl, const struct us_bun_socket_context_options_t *options) { SSL_CTX_set_min_proto_version(ssl, TLS1_3_VERSION); SSL_CTX_set_max_proto_version(ssl, TLS1_3_VERSION); @@ -845,6 +907,7 @@ us_quic_socket_context_t *us_create_quic_socket_context( struct us_loop_t *loop, struct us_bun_socket_context_options_t options, unsigned int ext_size, unsigned int idle_timeout_s) { + if (us_quic_global_init() != 0) return NULL; enum create_bun_socket_error_t ssl_err = 0; SSL_CTX *ssl = us_ssl_ctx_build_raw(options, &ssl_err); if (!ssl) return NULL; @@ -946,6 +1009,8 @@ void us_quic_socket_context_shutdown(us_quic_socket_context_t *ctx) { void us_quic_socket_context_free(us_quic_socket_context_t *ctx) { if (!ctx) return; ctx->closing = 1; + /* The owner goes away with the context, so the teardown below must not call into it. */ + ctx->on_released = NULL; struct us_loop_t *loop = ctx->loop; for (us_quic_socket_context_t **pp = &loop->data.quic_head; *pp; pp = &(*pp)->next) { if (*pp == ctx) { *pp = ctx->next; break; } @@ -1047,18 +1112,11 @@ us_quic_listen_socket_t *us_quic_socket_context_listen( return ls; } -static void us_quic_listen_socket_finish_close(us_quic_listen_socket_t *ls) { - if (ls->ctx->engine) { - lsquic_engine_cooldown(ls->ctx->engine); - us_quic_process(ls->ctx); - us_quic_send_unsent(ls->ctx); - } - /* on_conn_closed releases every fd when the last conn of a closing ctx goes. */ - if (ls->udp) us_udp_socket_close(ls->udp); -} - void us_quic_listen_socket_close(us_quic_listen_socket_t *ls) { - if (!ls || !ls->udp) return; + if (!ls) return; + us_quic_socket_context_t *ctx = ls->ctx; + /* The fd can be gone with conns left, see us_quic_report_if_released. Abort those. */ + if (!ls->udp && !ctx->conns) return; /* Send CONNECTION_CLOSE on every live conn before the fd disappears; * cooldown alone only schedules GOAWAY, which leaves peers waiting on * in-flight streams that this abrupt close will never serve. @@ -1072,26 +1130,27 @@ void us_quic_listen_socket_close(us_quic_listen_socket_t *ls) { * abrupt stop() went silent and pooled clients reused the dead session * until idle-timeout. IFC_ABORTED takes the IFC_IMMEDIATE_CLOSE_FLAGS * path which always packs CONNECTION_CLOSE. */ - if (ls->ctx->engine) { - for (us_quic_socket_t *qs = ls->ctx->conns; qs; qs = qs->next) { - if (qs->conn) lsquic_conn_abort(qs->conn); - } + if (ctx->engine && ctx->processing) { /* stop(true) from a request handler, or from a microtask it resolved. * The aborted conns only pack their CONNECTION_CLOSE once the running * tick resumes, so the fd has to outlive that call. */ - if (ls->ctx->processing) { - ls->close_pending = 1; - return; + for (us_quic_socket_t *qs = ctx->conns; qs; qs = qs->next) { + if (qs->conn) lsquic_conn_abort(qs->conn); } + ctx->abort_pending = 1; + if (ls->udp) ls->close_pending = 1; + return; } - us_quic_listen_socket_finish_close(ls); + us_quic_abort_conns(ctx); + /* on_conn_closed releases every fd when the last conn of a closing ctx goes. */ + if (ls->udp) us_udp_socket_close(ls->udp); } static void us_quic_finish_pending_closes(us_quic_socket_context_t *ctx) { for (us_quic_listen_socket_t *ls = ctx->listeners; ls; ) { if (!ls->close_pending) { ls = ls->next; continue; } ls->close_pending = 0; - us_quic_listen_socket_finish_close(ls); + us_udp_socket_close(ls->udp); /* udp_on_close unlinked `ls`; restart from the head. */ ls = ctx->listeners; } @@ -1104,7 +1163,12 @@ static void us_quic_after_engine_call(us_quic_socket_context_t *ctx) { ctx->shutdown_pending = 0; us_quic_socket_context_finish_shutdown(ctx); } - us_quic_finish_pending_closes(ctx); + if (ctx->abort_pending) { + /* Also covers a listener whose fd us_quic_release_pending_port already took. */ + ctx->abort_pending = 0; + us_quic_abort_conns(ctx); + us_quic_finish_pending_closes(ctx); + } } int us_quic_listen_socket_port(us_quic_listen_socket_t *ls) { @@ -1132,6 +1196,7 @@ DEF_CB(on_open, void (*cb)(us_quic_socket_t *)) DEF_CB(on_hsk_done, void (*cb)(us_quic_socket_t *, int)) DEF_CB(on_goaway, void (*cb)(us_quic_socket_t *)) DEF_CB(on_close, void (*cb)(us_quic_socket_t *)) +DEF_CB(on_released, void (*cb)(us_quic_socket_context_t *)) DEF_CB(on_stream_open, void (*cb)(us_quic_stream_t *, int)) DEF_CB(on_stream_headers, void (*cb)(us_quic_stream_t *)) DEF_CB(on_stream_data, void (*cb)(us_quic_stream_t *, const char *, unsigned int, int)) @@ -1157,12 +1222,13 @@ int us_quic_stream_write(us_quic_stream_t *s, const char *data, unsigned int len return (int) w; } -void us_quic_stream_want_read(us_quic_stream_t *s, int want) { - if (s->stream) lsquic_stream_wantread(s->stream, want); +void us_quic_stream_hold_window(us_quic_stream_t *s, int hold) { + if (s->stream) lsquic_stream_hold_window(s->stream, hold); } -void us_quic_stream_want_write(us_quic_stream_t *s, int want) { - if (s->stream) lsquic_stream_wantwrite(s->stream, want); +int us_quic_stream_want_write(us_quic_stream_t *s, int want) { + if (!s->stream) return -1; + return lsquic_stream_wantwrite(s->stream, want) < 0 ? -1 : 0; } /* lsquic_stream_send_headers only buffers, and a flush is a no-op while lsquic @@ -1228,7 +1294,10 @@ int us_quic_stream_send_headers(us_quic_stream_t *s, } lsquic_http_headers_t lh = { .count = (int) count, .headers = xh }; + /* The UDP socket leaves stale EAGAINs behind, which would turn a hard failure into a retry. */ + errno = 0; int r = lsquic_stream_send_headers(s->stream, &lh, end_stream); + if (r != 0 && errno == EAGAIN) r = US_QUIC_SEND_HEADERS_RETRY; if (buf != stackbuf) us_free(buf); if (xh != stackh) us_free(xh); if (end_stream && r == 0) lsquic_stream_shutdown(s->stream, 1); @@ -1282,7 +1351,7 @@ void us_quic_stream_reset(us_quic_stream_t *s) { us_quic_stream_reset_with(s, 0x10C); } -/* H3_INTERNAL_ERROR, for a message this endpoint can't send. */ +/* H3_INTERNAL_ERROR, for a message this endpoint can't send or can't finish. */ void us_quic_stream_reset_internal_error(us_quic_stream_t *s) { us_quic_stream_reset_with(s, 0x102); } @@ -1295,6 +1364,10 @@ uint64_t us_quic_stream_reset_code(us_quic_stream_t *s) { return s->stream ? lsquic_stream_get_error_code(s->stream) : 0; } +int us_quic_stream_reset_received(us_quic_stream_t *s) { + return s->stream && lsquic_stream_reset_received(s->stream); +} + int us_quic_stream_refused_by_goaway(us_quic_stream_t *s) { return s->stream && lsquic_stream_id(s->stream) > lsquic_conn_goaway_stream_id(lsquic_stream_conn(s->stream)); @@ -1393,21 +1466,26 @@ static enum ssl_verify_result_t us_quic_client_verify(SSL *ssl, uint8_t *out_ale return ssl_verify_ok; } +static int us_quic_prepare_client_ssl_ctx(SSL_CTX *ssl) { + SSL_CTX_set_min_proto_version(ssl, TLS1_3_VERSION); + SSL_CTX_set_max_proto_version(ssl, TLS1_3_VERSION); + SSL_CTX_set_custom_verify(ssl, SSL_VERIFY_PEER, us_quic_client_verify); + return us_ssl_ctx_set_verify_signature_algorithms(ssl); +} + us_quic_socket_context_t *us_create_quic_client_context( struct us_loop_t *loop, unsigned int ext_size, unsigned int conn_ext_size, unsigned int stream_ext_size) { + if (us_quic_global_init() != 0) return NULL; SSL_CTX *ssl = SSL_CTX_new(TLS_method()); if (!ssl) return NULL; - SSL_CTX_set_min_proto_version(ssl, TLS1_3_VERSION); - SSL_CTX_set_max_proto_version(ssl, TLS1_3_VERSION); X509_VERIFY_PARAM_set_flags(SSL_CTX_get0_param(ssl), X509_V_FLAG_IGNORE_EXPIRED_TRUST_ANCHORS); /* Same root store the H1/H2 client uses (bundled Mozilla roots + platform * CAs + NODE_EXTRA_CA_CERTS); set_default_verify_paths alone doesn't find * the system store on macOS/Windows. */ SSL_CTX_set_cert_store(ssl, us_get_default_ca_store()); - SSL_CTX_set_custom_verify(ssl, SSL_VERIFY_PEER, us_quic_client_verify); - if (!us_ssl_ctx_set_verify_signature_algorithms(ssl)) { SSL_CTX_free(ssl); return NULL; } + if (!us_quic_prepare_client_ssl_ctx(ssl)) { SSL_CTX_free(ssl); return NULL; } us_quic_socket_context_t *ctx = (us_quic_socket_context_t *) us_calloc(1, sizeof(us_quic_socket_context_t) + ext_size); @@ -1482,8 +1560,9 @@ static us_quic_listen_socket_t *us_quic_client_endpoint(us_quic_socket_context_t return ls; } +/* `ssl_ctx` is NULL for the context's own. */ static us_quic_socket_t *us_quic_connect_addr(us_quic_socket_context_t *ctx, - const struct sockaddr *peer, const char *sni, int reject_unauthorized) + const struct sockaddr *peer, const char *sni, int reject_unauthorized, SSL_CTX *ssl_ctx) { us_quic_listen_socket_t *ls = us_quic_client_endpoint(ctx); if (!ls) return NULL; @@ -1504,9 +1583,13 @@ static us_quic_socket_t *us_quic_connect_addr(us_quic_socket_context_t *ctx, return NULL; } + /* lsquic calls us_quic_get_ssl_ctx and SSL_new, which takes its own + * reference, before this returns. */ + ctx->connect_ssl_ctx = ssl_ctx; lsquic_conn_t *conn = lsquic_engine_connect(ctx->engine, N_LSQVER, (struct sockaddr *) &ls->local, peer, ls, NULL, sni, 0, NULL, 0, NULL, 0); + ctx->connect_ssl_ctx = NULL; if (!conn) return NULL; us_quic_socket_t *qs = (us_quic_socket_t *) lsquic_conn_get_ctx(conn); if (qs) { @@ -1537,7 +1620,7 @@ static us_quic_socket_t *us_quic_connect_addr(us_quic_socket_context_t *ctx, * with a throwaway UDP connect() — that does a route lookup and fails fast on * v6 results when the host has no v6 route, letting us fall through to A. */ static us_quic_socket_t *us_quic_connect_result(us_quic_socket_context_t *ctx, - struct addrinfo_result *res, int port, const char *sni, int reject_unauthorized) + struct addrinfo_result *res, int port, const char *sni, int reject_unauthorized, SSL_CTX *ssl_ctx) { for (struct addrinfo *ai = &res->entries->info; ai; ai = ai->ai_next) { struct sockaddr_storage peer; @@ -1571,7 +1654,7 @@ static us_quic_socket_t *us_quic_connect_result(us_quic_socket_context_t *ctx, } us_quic_socket_t *qs = us_quic_connect_addr(ctx, (struct sockaddr *) &peer, - sni, reject_unauthorized); + sni, reject_unauthorized, ssl_ctx); if (qs) return qs; } return NULL; @@ -1582,22 +1665,34 @@ struct us_quic_pending_connect_s { char *sni; int port; int reject_unauthorized; + /* Owned. NULL for the context's own. */ + SSL_CTX *ssl_ctx; struct addrinfo_request *ai_req; void *user; }; int us_quic_socket_context_connect( us_quic_socket_context_t *ctx, const char *host, int port, const char *sni, - int reject_unauthorized, us_quic_socket_t **out_qs, + int reject_unauthorized, const struct us_bun_socket_context_options_t *tls, + us_quic_socket_t **out_qs, struct us_quic_pending_connect_s **out_pending, void *user) { *out_qs = NULL; *out_pending = NULL; + SSL_CTX *ssl_ctx = NULL; + if (tls) { + enum create_bun_socket_error_t ssl_err = 0; + ssl_ctx = us_ssl_ctx_build_raw(*tls, &ssl_err); + if (!ssl_ctx) return -1; + if (!us_quic_prepare_client_ssl_ctx(ssl_ctx)) { SSL_CTX_free(ssl_ctx); return -1; } + } + struct sockaddr_storage peer_ss; if (Bun__parseIpAddress(host, (uint16_t) port, &peer_ss)) { *out_qs = us_quic_connect_addr(ctx, (struct sockaddr *) &peer_ss, sni, - reject_unauthorized); + reject_unauthorized, ssl_ctx); + SSL_CTX_free(ssl_ctx); return *out_qs ? 1 : -1; } @@ -1605,26 +1700,26 @@ int us_quic_socket_context_connect( int cached = Bun__addrinfo_get(ctx->loop, host, (uint16_t) port, &ai_req) == 0; if (cached) { struct addrinfo_result *res = Bun__addrinfo_getRequestResult(ai_req); - if (res->error || !res->entries) { - Bun__addrinfo_freeRequest(ai_req, 1); - return -1; - } - *out_qs = us_quic_connect_result(ctx, res, port, sni, reject_unauthorized); + if (!res->error && res->entries) + *out_qs = us_quic_connect_result(ctx, res, port, sni, reject_unauthorized, ssl_ctx); Bun__addrinfo_freeRequest(ai_req, *out_qs == NULL); + SSL_CTX_free(ssl_ctx); return *out_qs ? 1 : -1; } struct us_quic_pending_connect_s *pc = us_calloc(1, sizeof(*pc)); - if (!pc) { Bun__addrinfo_freeRequest(ai_req, 1); return -1; } + if (!pc) { Bun__addrinfo_freeRequest(ai_req, 1); SSL_CTX_free(ssl_ctx); return -1; } pc->ctx = ctx; pc->sni = sni ? us_strdup(sni) : NULL; if (sni && !pc->sni) { Bun__addrinfo_freeRequest(ai_req, 1); + SSL_CTX_free(ssl_ctx); us_free(pc); return -1; } pc->port = port; pc->reject_unauthorized = reject_unauthorized; + pc->ssl_ctx = ssl_ctx; pc->ai_req = ai_req; pc->user = user; *out_pending = pc; @@ -1645,9 +1740,10 @@ us_quic_socket_t *us_quic_pending_connect_resolved( struct addrinfo_result *res = Bun__addrinfo_getRequestResult(pc->ai_req); if (!res->error && res->entries) { qs = us_quic_connect_result(pc->ctx, res, pc->port, pc->sni, - pc->reject_unauthorized); + pc->reject_unauthorized, pc->ssl_ctx); } Bun__addrinfo_freeRequest(pc->ai_req, qs == NULL); + SSL_CTX_free(pc->ssl_ctx); us_free(pc->sni); us_free(pc); return qs; @@ -1655,6 +1751,7 @@ us_quic_socket_t *us_quic_pending_connect_resolved( void us_quic_pending_connect_cancel(struct us_quic_pending_connect_s *pc) { Bun__addrinfo_freeRequest(pc->ai_req, 1); + SSL_CTX_free(pc->ssl_ctx); us_free(pc->sni); us_free(pc); } diff --git a/packages/bun-usockets/src/quic.h b/packages/bun-usockets/src/quic.h index 25343693f9fb..060cecfc72e7 100644 --- a/packages/bun-usockets/src/quic.h +++ b/packages/bun-usockets/src/quic.h @@ -41,18 +41,21 @@ struct us_quic_header_t { int qpack_index; }; -/* Process-wide lsquic init. Must be called once before the first - * us_create_quic_socket_context; the C++ layer (uws_h3_create_app) does this - * via a thread-safe static local so quic.c stays free of pthread/call_once. */ -void us_quic_global_init(void); +/* Process-wide lsquic init, run once. Every function that calls + * lsquic_engine_new() (here and in node_quic_shim.c) calls it first. Returns 0, + * or -1 if lsquic_global_init() failed. */ +int us_quic_global_init(void); us_quic_socket_context_t *us_create_quic_socket_context( struct us_loop_t *loop, struct us_bun_socket_context_options_t options, unsigned int ext_size, unsigned int idle_timeout_s); /* Send GOAWAY on every connection and stop accepting new ones; the engine and - * timer keep running so in-flight streams drain. */ + * timer keep running so in-flight streams drain. The UDP socket closes with + * the last connection, and on_released reports it. */ void us_quic_socket_context_shutdown(us_quic_socket_context_t *ctx); +/* 1 once the context has no open UDP socket and no connection left. */ +int us_quic_socket_context_is_released(us_quic_socket_context_t *ctx); void us_quic_socket_context_free(us_quic_socket_context_t *ctx); /* Register an additional SSL_CTX for the given SNI hostname. Names match as on a TCP listener. */ @@ -84,10 +87,13 @@ struct addrinfo_request; * *out_qs set). Uncached hostnames return 0 with *out_pending set — caller * registers a Bun__addrinfo callback and invokes * us_quic_pending_connect_resolved() once it fires. -1 on error. `sni` is - * the TLS ServerNameIndication; `reject_unauthorized` is per-connection. */ + * the TLS ServerNameIndication; `reject_unauthorized` is per-connection. + * `tls` gives the connection an SSL_CTX of its own (ca, cert, key, ...). NULL + * uses the default trust store and no client certificate. */ int us_quic_socket_context_connect( us_quic_socket_context_t *ctx, const char *host, int port, const char *sni, - int reject_unauthorized, us_quic_socket_t **out_qs, + int reject_unauthorized, const struct us_bun_socket_context_options_t *tls, + us_quic_socket_t **out_qs, struct us_quic_pending_connect_s **out_pending, void *user); void *us_quic_pending_connect_user(struct us_quic_pending_connect_s *pc); @@ -116,6 +122,12 @@ void us_quic_socket_context_on_goaway(us_quic_socket_context_t *ctx, void (*on_goaway)(us_quic_socket_t *)); void us_quic_socket_context_on_close(us_quic_socket_context_t *ctx, void (*on_close)(us_quic_socket_t *)); +/* us_quic_socket_context_is_released became true. Runs after the close(2), and + * can run inside lsquic_engine_process_conns, so it must not enter the engine + * or free the context. us_quic_socket_context_free doesn't report. A context + * with this hook closes its connections if its last UDP socket goes first. */ +void us_quic_socket_context_on_released(us_quic_socket_context_t *ctx, + void (*on_released)(us_quic_socket_context_t *)); /* Stream-level callbacks */ void us_quic_socket_context_on_stream_open(us_quic_socket_context_t *ctx, @@ -130,10 +142,17 @@ void us_quic_socket_context_on_stream_close(us_quic_socket_context_t *ctx, void (*on_close)(us_quic_stream_t *)); /* Stream I/O. Read happens via on_stream_data; write returns bytes accepted - * (may be < len under flow-control backpressure). */ + * (may be < len under flow-control backpressure). -1 isn't backpressure: the + * send half is reset or finished, so no later write succeeds. */ int us_quic_stream_write(us_quic_stream_t *s, const char *data, unsigned int len); -void us_quic_stream_want_read(us_quic_stream_t *s, int want); -void us_quic_stream_want_write(us_quic_stream_t *s, int want); +/* Receive backpressure, see lsquic_stream_hold_window() in lsquic.h. */ +void us_quic_stream_hold_window(us_quic_stream_t *s, int hold); +/* -1 when the send half is finished (a FIN or a RESET_STREAM is out): no on_stream_writable follows. */ +int us_quic_stream_want_write(us_quic_stream_t *s, int want); +/* lsquic holds one outgoing header block per stream and refuses the next one + * while an earlier block (a 1xx) is still unsent. Nothing was queued: send the + * same block again from on_stream_writable. 0 is sent, -1 is a hard failure. */ +#define US_QUIC_SEND_HEADERS_RETRY 1 int us_quic_stream_send_headers(us_quic_stream_t *s, const struct us_quic_header_t *headers, unsigned int count, int end_stream); /* Send a 1xx interim HEADERS frame (`:status` only); the final response @@ -151,6 +170,8 @@ int us_quic_stream_has_unacked(us_quic_stream_t *s); /* The code of the last RESET_STREAM or STOP_SENDING on the stream, 0 without one. * on_stream_close can still read it. */ uint64_t us_quic_stream_reset_code(us_quic_stream_t *s); +/* The peer sent RESET_STREAM. on_stream_close can still read it. */ +int us_quic_stream_reset_received(us_quic_stream_t *s); /* The server did not process a request above the id in its GOAWAY (RFC 9114 §5.2). * The RFC says the same of the request at the id, but servers on stock lsquic name * the last request they accepted. on_stream_close can still read it. */ diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index 81e5489c8d11..2c41edd7061d 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -403,6 +403,13 @@ void us_internal_rearm_writable(struct us_socket_t *s) { LIBUS_SOCKET_WRITABLE | ((s->flags.is_paused || s->read_eof) ? 0 : LIBUS_SOCKET_READABLE)); } +/* loop.c stops polling for writable after an on_writable that left last_write_failed clear. */ +void us_socket_request_writable(struct us_socket_t *s) { + if (us_socket_is_closed(s)) return; + s->flags.last_write_failed = 1; + us_internal_rearm_writable(s); +} + /* macOS can report more bytes sent than it was given (golang/go#61060). How many it took is unknown, so nothing * may follow them. ECONNABORTED is a peer-gone errno, so it is never retried; the shutdown is for the writers that * take any failed send for backpressure. No write may close under its caller: the writable event does. */ @@ -438,6 +445,22 @@ int us_socket_stalled_write_means_peer_gone(struct us_socket_t *s) { #endif } +int us_socket_delivered_bytes(struct us_socket_t *s, uint64_t *bytes) { + if (us_socket_is_closed(s)) { + return -1; + } + return bsd_socket_delivered_bytes(us_poll_fd(&s->p), bytes); +} + +int us_socket_delivered_since(struct us_socket_t *s, uint64_t *mark, uint64_t min_bytes) { + uint64_t now; + if (us_socket_delivered_bytes(s, &now) != 0 || now < *mark || now - *mark < min_bytes) { + return 0; + } + *mark = now; + return 1; +} + int us_socket_write2(struct us_socket_t *s, const char *header, int header_length, const char *payload, int payload_length) { if (us_socket_is_closed(s) || us_socket_is_shut_down(s)) { return 0; diff --git a/packages/bun-usockets/src/udp.c b/packages/bun-usockets/src/udp.c index efc74898f409..03722c095d9c 100644 --- a/packages/bun-usockets/src/udp.c +++ b/packages/bun-usockets/src/udp.c @@ -235,8 +235,7 @@ struct us_udp_socket_t *us_create_udp_socket( /* IP_RECVERR is only useful when there is an on_recv_error handler to * drain the error queue; without one it only poisons subsequent sends. */ - if (recv_error_cb) flags |= LIBUS_UDP_LINUX_RECVERR; - else flags &= ~LIBUS_UDP_LINUX_RECVERR; + if (!recv_error_cb) flags &= ~LIBUS_UDP_LINUX_RECVERR; LIBUS_SOCKET_DESCRIPTOR fd = bsd_create_udp_socket(host, port, flags, err); if (fd == LIBUS_SOCKET_ERROR) { return 0; diff --git a/packages/bun-uws/src/App.h b/packages/bun-uws/src/App.h index 545b762e4397..0b7e494e3b24 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -756,31 +756,18 @@ struct TemplatedApp { struct ssl_ctx_st *sslCtxOrNull() { return SSL ? sslCtx : nullptr; } public: - /* Host, port, options, callback */ - TemplatedApp &&listen(const std::string &host, int port, int options, MoveOnlyFunction &&handler) { - if (host.empty()) { - return listen(port, options, std::move(handler)); - } - handler(httpContext ? trackListenSocket(httpContext->listen(sslCtxOrNull(), host.c_str(), port, options)) : nullptr); - return std::move(*this); - } - - /* Port, callback */ - TemplatedApp &&listen(int port, MoveOnlyFunction &&handler) { - handler(httpContext ? trackListenSocket(httpContext->listen(sslCtxOrNull(), nullptr, port, 0)) : nullptr); - return std::move(*this); + /* A nullptr host listens on every address. See us_socket_group_listen for the out-params. */ + us_listen_socket_t *listen(const char *host, int port, int options, int *error, int *dnsError) { + return httpContext ? trackListenSocket(httpContext->listen(sslCtxOrNull(), host, port, options, error, dnsError)) : nullptr; } - /* Port, options, callback */ - TemplatedApp &&listen(int port, int options, MoveOnlyFunction &&handler) { - handler(httpContext ? trackListenSocket(httpContext->listen(sslCtxOrNull(), nullptr, port, options)) : nullptr); - return std::move(*this); + us_listen_socket_t *listen_unix(std::string_view path, int options, int *error) { + return httpContext ? trackListenSocket(httpContext->listen_unix(sslCtxOrNull(), path.data(), path.length(), options, error)) : nullptr; } - /* options, callback, path to unix domain socket */ - TemplatedApp &&listen(int options, MoveOnlyFunction &&handler, std::string_view path) { - handler(httpContext ? trackListenSocket(httpContext->listen_unix(sslCtxOrNull(), path.data(), path.length(), options)) : nullptr); - return std::move(*this); + /* already-bound descriptor, backlog, options, errno out */ + us_listen_socket_t *listen_fd(LIBUS_SOCKET_DESCRIPTOR fd, int backlog, int options, int *error) { + return httpContext ? trackListenSocket(httpContext->listen_fd(sslCtxOrNull(), fd, backlog, options, error)) : nullptr; } void setOnSocketClosed(HttpContextData::OnSocketClosedCallback onClose) { @@ -832,6 +819,11 @@ struct TemplatedApp { return std::move(*this); } + TemplatedApp &&setMaxRequestBodySize(uint64_t maxRequestBodySize) { + httpContext->getSocketContextData()->maxRequestBodySize = maxRequestBodySize; + return std::move(*this); + } + TemplatedApp &&setMaxHeadersCount(uint32_t maxHeadersCount) { httpContext->getSocketContextData()->maxHeadersCount = maxHeadersCount; return std::move(*this); diff --git a/packages/bun-uws/src/AsyncSocketData.h b/packages/bun-uws/src/AsyncSocketData.h index 9df9166ff664..a0e5be49022f 100644 --- a/packages/bun-uws/src/AsyncSocketData.h +++ b/packages/bun-uws/src/AsyncSocketData.h @@ -177,7 +177,7 @@ struct AsyncSocketData { * that can still reach a handler counts these. */ bool filteredAccept = false; /* Whether it has fired the filter with -3 (HttpResponse::setNodeHttpTunnelIdle). - * +3 undoes it, and an idle socket fires -4 in place of -2. */ + * +3 or +4 undoes it, and an idle socket fires -4 in place of -2. */ bool filteredIdleTunnel = false; }; diff --git a/packages/bun-uws/src/ChunkedEncoding.h b/packages/bun-uws/src/ChunkedEncoding.h index 567fc941f678..e2caa6d595d9 100644 --- a/packages/bun-uws/src/ChunkedEncoding.h +++ b/packages/bun-uws/src/ChunkedEncoding.h @@ -196,6 +196,13 @@ namespace uWS { constexpr uint64_t STATE_IS_TRAILERS = STATE_HAS_SIZE | STATE_IS_CHUNKED | STATE_WAITING_FOR_LF; constexpr uint64_t STATE_IS_TRAILERS_DONE = STATE_HAS_SIZE | STATE_WAITING_FOR_LF; + /* Bun.serve passes no trailerSection buffer, so it scans the trailer section for + * its end and discards it. The size field counts the scanned bytes. The flags + * reuse bits that mean nothing once STATE_HAS_SIZE is set. */ + constexpr uint64_t STATE_TRAILER_SCAN = STATE_HAS_SIZE | STATE_WAITING_FOR_LF | STATE_IS_CHUNKED_EXTENSION; + constexpr uint64_t STATE_TRAILER_LINE_START = STATE_HAS_HEXDIG; + constexpr uint64_t STATE_TRAILER_AFTER_CR = STATE_IS_CHUNKED; + /* Default cap on the raw size of a captured trailer section. Node counts * trailer bytes against the same max-header-size budget as request headers * in llhttp, so node:http callers thread the per-server maxHeaderSize @@ -214,79 +221,85 @@ namespace uWS { } /* Returns next chunk (empty or not), or if all data was consumed, nullopt is returned. */ - static std::optional getNextChunk(std::string_view &data, uint64_t &state, bool trailer = false, uint64_t *chunkExtensionsConsumed = nullptr, std::string *trailerSection = nullptr, uint64_t maxTrailerSectionSize = MAX_TRAILER_SECTION_SIZE) { - /* node:http compat: the previous call emitted the fin chunk for a message - * whose trailer section completed; this call only resets the state and - * leaves the remaining bytes (the next request, or tunnel data) untouched. */ - if (trailerSection && state == STATE_IS_TRAILERS_DONE) [[unlikely]] { + static std::optional getNextChunk(std::string_view &data, uint64_t &state, uint64_t *chunkExtensionsConsumed = nullptr, std::string *trailerSection = nullptr, uint64_t maxTrailerSectionSize = MAX_TRAILER_SECTION_SIZE) { + /* The previous call emitted the fin chunk for a message whose trailer + * section completed; this call only resets the state and leaves the + * remaining bytes (the next request, or tunnel data) untouched. */ + if (state == STATE_IS_TRAILERS_DONE) [[unlikely]] { state = 0; return std::nullopt; } while (data.length()) { - /* node:http compat: consume the trailer section that follows the final - * 0-size chunk, capturing its raw bytes so the server can populate - * req.trailers. The terminating empty chunk (fin) is only emitted once - * the whole section (including its final CRLF) has been consumed, so - * trailers are always available by the time the message completes. */ - if (trailerSection && state == STATE_IS_TRAILERS) [[unlikely]] { + /* The trailer section (RFC 9112 7.1.2) follows the last chunk. The fin + * chunk is emitted once its terminating empty line is consumed. Only + * STATE_IS_TRAILERS and STATE_TRAILER_SCAN set both of these bits. */ + if ((state & (STATE_HAS_SIZE | STATE_WAITING_FOR_LF)) == (STATE_HAS_SIZE | STATE_WAITING_FOR_LF)) [[unlikely]] { + /* node:http captures the raw bytes for req.trailers. */ + if (trailerSection) { + while (data.length()) { + char c = data[0]; + trailerSection->push_back(c); + data.remove_prefix(1); + if (trailerSection->size() > maxTrailerSectionSize) [[unlikely]] { + state = STATE_IS_TRAILER_OVERFLOW; + return std::nullopt; + } + if (c == '\n') { + /* Bare LF (no preceding CR captured) is a fatal parse error like + * llhttp's HPE_CR_EXPECTED / HPE_INVALID_HEADER_TOKEN — otherwise + * "0\r\n\n" waits for bytes forever. Strict CRLF matches + * consumeHexNumber's own bare-LF rejection above. */ + size_t n = trailerSection->size(); + if (n < 2 || (*trailerSection)[n - 2] != '\r') { + state = STATE_IS_ERROR; + return std::nullopt; + } + if (isCompleteTrailerSection(*trailerSection)) { + /* Message complete: emit the fin chunk. The next call sees + * STATE_IS_TRAILERS_DONE and resets to 0. */ + state = STATE_IS_TRAILERS_DONE; + return std::string_view(nullptr, 0); + } + } + } + return std::nullopt; + } + + /* Reject a bare LF and a CR without LF, so a proxy in front can't + * disagree about where the section ends. */ while (data.length()) { - char c = data[0]; - trailerSection->push_back(c); + unsigned char c = (unsigned char) data[0]; data.remove_prefix(1); - if (trailerSection->size() > maxTrailerSectionSize) [[unlikely]] { + uint64_t count = chunkSize(state) + 1; + if (count > maxTrailerSectionSize) [[unlikely]] { state = STATE_IS_TRAILER_OVERFLOW; return std::nullopt; } - if (c == '\n') { - /* Bare LF (no preceding CR captured) is a fatal parse error like - * llhttp's HPE_CR_EXPECTED / HPE_INVALID_HEADER_TOKEN — otherwise - * "0\r\n\n" waits for bytes forever. Strict CRLF matches - * consumeHexNumber's own bare-LF rejection above. */ - size_t n = trailerSection->size(); - if (n < 2 || (*trailerSection)[n - 2] != '\r') { + if (state & STATE_TRAILER_AFTER_CR) { + if (c != '\n') { state = STATE_IS_ERROR; return std::nullopt; } - if (isCompleteTrailerSection(*trailerSection)) { - /* Message complete: emit the fin chunk. The next call sees - * STATE_IS_TRAILERS_DONE and resets to 0. */ + if (state & STATE_TRAILER_LINE_START) { + /* The empty line ends the message. */ state = STATE_IS_TRAILERS_DONE; return std::string_view(nullptr, 0); } + state = STATE_TRAILER_SCAN | STATE_TRAILER_LINE_START | count; + continue; } - } - return std::nullopt; - } - - // if in "drop trailer mode", consume the terminator bytes after the - // zero-chunk. This is 2 bytes (\r\n) with no trailer, or 4 bytes - // (\r\n\r\n) with trailer=true. Upstream uWS consumed these bytes - // blindly, which let attackers smuggle a second request by placing - // arbitrary bytes (e.g. "X:POST /admin HTTP/1.1\r\n") where the - // final CRLF belongs. Strict validation closes that desync. - // - // chunkSize() starts at 2 or 4 and counts down as bytes arrive; its - // parity gives the expected byte (even -> \r, odd -> \n). The parity - // rule is the same for both initial sizes, so TCP segment - // boundaries that split the terminator are handled naturally. - if (((state & STATE_IS_CHUNKED) == 0) && hasChunkSize(state) && chunkSize(state)) { - while (data.length() && chunkSize(state)) { - char expected = (chunkSize(state) & 1) ? '\n' : '\r'; - if (data[0] != expected) { - state = STATE_IS_ERROR; - return std::nullopt; + if (c == '\r') { + state = (state & ~STATE_SIZE_MASK) | STATE_TRAILER_AFTER_CR | count; + continue; } - data.remove_prefix(1); - decChunkSize(state, 1); - - if (chunkSize(state) == 0) { - /* Terminator fully consumed — parsing is complete. */ - state = 0; + if (c == '\n') { + state = STATE_IS_ERROR; return std::nullopt; } + state = STATE_TRAILER_SCAN | count; } - continue; + return std::nullopt; } if (!hasChunkSize(state)) { @@ -295,25 +308,9 @@ namespace uWS { return std::nullopt; } if (hasChunkSize(state) && chunkSize(state) == 2) { - - //printf("Setting state to trailer-parsing and emitting empty chunk\n"); - - /* node:http compat: the message is not complete until the trailer - * section has been consumed; capture it and emit the fin chunk - * from the trailer-section branch above instead of here. */ - if (trailerSection) { - state = STATE_IS_TRAILERS; - continue; - } - - // set trailer state and increase size to 4 - if (trailer) { - state = 4 /*| STATE_IS_CHUNKED*/ | STATE_HAS_SIZE; - } else { - state = 2 /*| STATE_IS_CHUNKED*/ | STATE_HAS_SIZE; - } - - return std::string_view(nullptr, 0); + /* That was the last chunk. The branch above consumes the trailer section and emits the fin chunk. */ + state = trailerSection ? STATE_IS_TRAILERS : (STATE_TRAILER_SCAN | STATE_TRAILER_LINE_START); + continue; } if (!hasChunkSize(state)) [[unlikely]] { /* Incomplete chunk-size line — need more data from the network. */ @@ -419,16 +416,15 @@ namespace uWS { std::string_view *data; std::optional chunk; uint64_t *state; - bool trailer; uint64_t *chunkExtensionsConsumed; std::string *trailerSection; uint64_t maxTrailerSectionSize; - ChunkIterator(std::string_view *data, uint64_t *state, bool trailer = false, uint64_t *chunkExtensionsConsumed = nullptr, std::string *trailerSection = nullptr, uint64_t maxTrailerSectionSize = MAX_TRAILER_SECTION_SIZE) : data(data), state(state), trailer(trailer), chunkExtensionsConsumed(chunkExtensionsConsumed), trailerSection(trailerSection), maxTrailerSectionSize(maxTrailerSectionSize) { - chunk = uWS::getNextChunk(*data, *state, trailer, chunkExtensionsConsumed, trailerSection, maxTrailerSectionSize); + ChunkIterator(std::string_view *data, uint64_t *state, uint64_t *chunkExtensionsConsumed = nullptr, std::string *trailerSection = nullptr, uint64_t maxTrailerSectionSize = MAX_TRAILER_SECTION_SIZE) : data(data), state(state), chunkExtensionsConsumed(chunkExtensionsConsumed), trailerSection(trailerSection), maxTrailerSectionSize(maxTrailerSectionSize) { + chunk = uWS::getNextChunk(*data, *state, chunkExtensionsConsumed, trailerSection, maxTrailerSectionSize); } - ChunkIterator() : data(nullptr), state(nullptr), trailer(false), chunkExtensionsConsumed(nullptr), trailerSection(nullptr), maxTrailerSectionSize(MAX_TRAILER_SECTION_SIZE) { + ChunkIterator() : data(nullptr), state(nullptr), chunkExtensionsConsumed(nullptr), trailerSection(nullptr), maxTrailerSectionSize(MAX_TRAILER_SECTION_SIZE) { } @@ -452,7 +448,7 @@ namespace uWS { } ChunkIterator &operator++() { - chunk = uWS::getNextChunk(*data, *state, trailer, chunkExtensionsConsumed, trailerSection, maxTrailerSectionSize); + chunk = uWS::getNextChunk(*data, *state, chunkExtensionsConsumed, trailerSection, maxTrailerSectionSize); return *this; } diff --git a/packages/bun-uws/src/Http2Context.h b/packages/bun-uws/src/Http2Context.h index 06c3a55b4a62..91bbce0d5b94 100644 --- a/packages/bun-uws/src/Http2Context.h +++ b/packages/bun-uws/src/Http2Context.h @@ -117,6 +117,8 @@ static constexpr size_t MAX_QUEUED_CONTROL = 1u << 20; /* RST_STREAM token bucket (CVE-2023-44487 class): burst, refill per second. */ static constexpr double RESET_BURST = 1000; static constexpr double RESET_REFILL_PER_SEC = 33; +/* Seconds the peer has to answer the FIN of a graceful close. usockets waits as long for a TLS peer's close_notify. */ +static constexpr unsigned FIN_WAIT_TIMEOUT_S = 10; static constexpr char CLIENT_PREFACE[] = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"; static constexpr uint32_t CLIENT_PREFACE_LEN = 24; @@ -391,6 +393,8 @@ struct Http2Connection { uint32_t lastProcessedStreamId = 0; bool goawaySent = false; bool goawayReceived = false; + /* finish() ran. No frame is read or written after it. */ + bool finSent = false; double resetTokens = http2::RESET_BURST; std::chrono::steady_clock::time_point resetRefilledAt = std::chrono::steady_clock::now(); @@ -424,6 +428,9 @@ struct Http2Connection { /* Seconds of silence before onTimeout; recomputed when a stream sets a * timeout or retires (see Http2Response::timeoutS). */ unsigned idleTimeoutS = 0; + /* Same as HTTP_DELIVERED_BYTES_RECORDED and HttpResponseData::deliveredBytes. */ + bool deliveredBytesRecorded = false; + uint64_t deliveredBytes = 0; int drainDepth = 0; Http2Connection(us_socket_t *socket, Http2Context *context) : s(socket), ctx(context) { @@ -588,7 +595,16 @@ struct Http2Connection { inline void scheduleFlush(); bool drainedAfterGoaway() { - return !closed && streams.empty() && (goawaySent || goawayReceived) && out->length() == 0; + return !closed && !finSent && streams.empty() && (goawaySent || goawayReceived) && out->length() == 0; + } + + /* The graceful close, once drainedAfterGoaway() holds. The kernel may still hold the end of a response, and + * close(2) would drop it as soon as another frame (a WINDOW_UPDATE, say) arrives. So send a FIN behind it and + * drop input until the peer's FIN closes the socket, or onTimeout does. */ + void finish() { + finSent = true; + us_socket_shutdown(s); + us_socket_timeout(s, http2::FIN_WAIT_TIMEOUT_S); } bool takeResetToken() { @@ -647,6 +663,9 @@ struct Http2Context { /* Seconds without traffic in either direction before a connection is * dropped (streams in flight are aborted); 0 disables. */ unsigned idleTimeoutS = 10; + /* A graceful stop ran. A connection adopted after it (its TLS handshake or + * cleartext preface was pending) sends GOAWAY with its first stream. */ + bool stopping = false; /* Output buffer lent to whichever connection is inside a socket event; * see Http2Connection::out. */ @@ -790,22 +809,24 @@ struct Http2Context { forEachConnection([](Http2Connection *conn) { if (!conn->goawaySent) conn->writeGoaway(http2::ERR_NO_ERROR); conn->flush(); - us_socket_close(conn->s, 0, nullptr); + us_socket_close_now(conn->s); }); } /* Close connections with nothing in flight (after a GOAWAY). With * `closeWhenIdle`, busy connections also get GOAWAY so no new streams * start and they close once their last stream retires (graceful stop); - * without it they are left alone. */ + * without it they are left alone. A connection with unsent bytes closes + * from the epilogue of the writable event that drains them. */ size_t closeIdle(bool closeWhenIdle) { + if (closeWhenIdle) stopping = true; size_t closedNow = 0; forEachConnection([&](Http2Connection *conn) { - if (!conn->streams.empty() && !closeWhenIdle) return; + if (conn->finSent || (!conn->streams.empty() && !closeWhenIdle)) return; if (!conn->goawaySent) conn->writeGoaway(http2::ERR_NO_ERROR); conn->flush(); - if (conn->streams.empty()) { - us_socket_close(conn->s, 0, nullptr); + if (conn->drainedAfterGoaway()) { + conn->finish(); closedNow++; } }); @@ -867,7 +888,7 @@ struct Http2Context { conn->returnSharedOut(); conn->busy--; if (!sweepConnection(conn)) continue; - if (conn->drainedAfterGoaway()) us_socket_close(conn->s, 0, nullptr); + if (conn->drainedAfterGoaway()) conn->finish(); } } @@ -904,8 +925,8 @@ struct Http2Context { /* ── socket vtable ────────────────────────────────────────────────── */ static us_socket_t *onData(us_socket_t *s, char *data, int length) { - if (us_socket_is_shut_down(s)) return s; Http2Connection *conn = connection(s); + if (conn->finSent || us_socket_is_shut_down(s)) return s; conn->borrowSharedOut(); conn->onData(data, (size_t) length); return s; @@ -956,6 +977,13 @@ struct Http2Context { * connection): streams in flight are aborted through onClose. */ static us_socket_t *onTimeout(us_socket_t *s) { Http2Connection *conn = connection(s); + if (conn->finSent) return us_socket_close_now(s); + /* Same rule as HttpContext::onTimeout. */ + if (s->flags.last_write_failed && conn->deliveredBytesRecorded + && us_socket_delivered_since(s, &conn->deliveredBytes, (uint64_t) HttpContext::HTTP_RECEIVE_THROUGHPUT_BYTES * conn->idleTimeoutS)) { + us_socket_timeout(s, conn->idleTimeoutS); + return s; + } conn->busy++; std::vector open = conn->streams; for (Http2Response *stream : open) { @@ -968,7 +996,9 @@ struct Http2Context { } if (!conn->goawaySent) conn->writeGoaway(http2::ERR_NO_ERROR); conn->flush(); - return us_socket_close(s, 0, nullptr); + /* With no stream to abort, a TLS close can wait for the peer's close_notify, so a request that crosses the GOAWAY isn't reset. */ + if (conn->streams.empty()) return us_socket_close(s, 0, nullptr); + return us_socket_close_now(s); } static us_socket_t *onEnd(us_socket_t *s) { @@ -978,7 +1008,7 @@ struct Http2Context { } /* After user code ran from a socket event: push bytes out, free what - * retired, close a drained going-away connection. */ + * retired, finish a drained going-away connection. */ static us_socket_t *epilogue(Http2Connection *conn, us_socket_t *s) { if (conn->closed) { conn->returnSharedOut(); @@ -990,7 +1020,7 @@ struct Http2Context { conn->busy--; conn->returnSharedOut(); if (!sweepConnection(conn)) return s; - if (conn->drainedAfterGoaway()) return us_socket_close(s, 0, nullptr); + if (conn->drainedAfterGoaway()) conn->finish(); return s; } @@ -1030,7 +1060,12 @@ inline void Http2Connection::writeSettings() { } inline void Http2Connection::touch() { + if (finSent) return; us_socket_timeout(s, idleTimeoutS); + /* See HttpResponse::resetTimeout. */ + if (s->flags.last_write_failed) [[unlikely]] { + deliveredBytesRecorded = idleTimeoutS && us_socket_delivered_bytes(s, &deliveredBytes) == 0; + } } inline void Http2Connection::recomputeIdleTimeout() { @@ -1299,7 +1334,7 @@ inline void Http2Connection::onData(const char *data, size_t length) { return; } if (avail - consumed < http2::FRAME_HEADER_SIZE + flen) break; - if (!handleFrame(type, flags, streamId, h + http2::FRAME_HEADER_SIZE, flen) || closed) { + if (!handleFrame(type, flags, streamId, h + http2::FRAME_HEADER_SIZE, flen) || closed || finSent) { busy--; Http2Context::epilogue(this, s); return; @@ -1313,8 +1348,9 @@ inline void Http2Connection::onData(const char *data, size_t length) { in.append((const char *) p + consumed, avail - consumed); } /* Only frames that move a stream count as activity: a peer holding - * streams open at a zero window can't keep the connection alive on PINGs. */ - if (progressed || streams.empty()) touch(); + * streams open at a zero window can't keep the connection alive on PINGs. + * After a graceful stop, neither can a peer with no stream. */ + if (progressed || (streams.empty() && !ctx->stopping)) touch(); progressed = false; busy--; Http2Context::epilogue(this, s); @@ -1648,6 +1684,7 @@ inline bool Http2Connection::handleHeaderBlock(uint32_t streamId, uint8_t flags, return streamError(streamId, nullptr, http2::ERR_REFUSED_STREAM); } lastProcessedStreamId = streamId; + if (ctx->stopping) writeGoaway(http2::ERR_NO_ERROR); if (tooLarge) { Http2Response *stream = new Http2Response(this, streamId, peerInitialWindowSize); stream->remoteClosed = endStream; @@ -1723,7 +1760,7 @@ inline bool Http2Connection::handleHeaderBlock(uint32_t streamId, uint8_t flags, inline bool Http2Connection::dispatchRequest(Http2Response *stream, const us_quic_header_t *headers, unsigned count) { Http2Request req(headers, count); - if (!(ctx->parentFlags && ctx->parentFlags->usingCustomExpectHandler) && req.getHeader("expect") == "100-continue") { + if (!(ctx->parentFlags && ctx->parentFlags->usingCustomExpectHandler) && isExpect100Continue(req.getHeader("expect"))) { stream->writeContinue(); } ctx->dispatchDepth++; @@ -1933,7 +1970,7 @@ inline bool Http2Response::drain() { inline Http2Response *Http2Response::pause() { if (paused) return this; paused = true; - /* Like HTTP/1: a request the app has paused doesn't idle out. */ + /* A request the app has paused doesn't idle out. */ pausedTimeoutS = timeoutS; timeoutS = 0; conn->recomputeIdleTimeout(); diff --git a/packages/bun-uws/src/Http3App.h b/packages/bun-uws/src/Http3App.h index bde4a4828e01..0c93321d13ed 100644 --- a/packages/bun-uws/src/Http3App.h +++ b/packages/bun-uws/src/Http3App.h @@ -55,6 +55,12 @@ struct H3App { } /* GOAWAY + drain. The engine itself is torn down in the destructor. */ void close() { http3Context->shutdown(); } + /* See us_quic_socket_context_on_released. */ + void onReleased(void (*cb)(void *), void *user) { + http3Context->getContextData()->onReleased = cb; + http3Context->getContextData()->onReleasedUser = user; + } + bool isReleased() { return us_quic_socket_context_is_released((us_quic_socket_context_t *) http3Context) != 0; } bool addServerNameWithOptions(const char *hostname, SocketContextOptions options) { us_bun_socket_context_options_t raw; memcpy(&raw, &options, sizeof(raw)); diff --git a/packages/bun-uws/src/Http3Context.h b/packages/bun-uws/src/Http3Context.h index 5dfa7c4af8bb..58b9ef27747f 100644 --- a/packages/bun-uws/src/Http3Context.h +++ b/packages/bun-uws/src/Http3Context.h @@ -42,7 +42,23 @@ struct Http3Context { } Http3Request req(s); - if (req.getHeader("expect") == "100-continue") res->writeContinue(); + /* HttpRouter drops the URL's first byte without looking at it and + * the URL parser strips HTAB, so a malformed :path could reach a + * route it doesn't name. */ + if (!isToken(req.getCaseSensitiveMethod()) || + !validPathForMethod(req.getCaseSensitiveMethod(), req.getFullUrl()) || + req.getHeader("host").empty()) { + res->writeStatus("400 Bad Request")->end(); + return; + } + std::string_view method = req.getMethod(); + if (Bun__HTTPMethod__from(method.data(), method.size()) == -1) { + /* RFC 9110 §15.6.2. The router would dispatch it to the "any" + * handler, which cannot represent the method and would report GET. */ + res->writeStatus("501 Not Implemented")->end(); + return; + } + if (isExpect100Continue(req.getHeader("expect"))) res->writeContinue(); cd->router.getUserData() = {res, &req}; if (!cd->router.route(req.getMethod(), req.getUrl())) { res->writeStatus("404 Not Found")->end(); @@ -74,6 +90,11 @@ struct Http3Context { rd->~Http3ResponseData(); }); + us_quic_socket_context_on_released(ctx, [](us_quic_socket_context_t *ctx) { + Http3ContextData *cd = (Http3ContextData *) us_quic_socket_context_ext(ctx); + if (cd->onReleased) cd->onReleased(cd->onReleasedUser); + }); + return (Http3Context *) ctx; } diff --git a/packages/bun-uws/src/Http3ContextData.h b/packages/bun-uws/src/Http3ContextData.h index 77a555e09038..207b8f5c454c 100644 --- a/packages/bun-uws/src/Http3ContextData.h +++ b/packages/bun-uws/src/Http3ContextData.h @@ -14,6 +14,9 @@ struct Http3ContextData { Http3Request *httpRequest; }; HttpRouter router; + /* H3App::onReleased */ + void (*onReleased)(void *user) = nullptr; + void *onReleasedUser = nullptr; }; } diff --git a/packages/bun-uws/src/Http3Request.h b/packages/bun-uws/src/Http3Request.h index ecb9dc270502..e563f57a3685 100644 --- a/packages/bun-uws/src/Http3Request.h +++ b/packages/bun-uws/src/Http3Request.h @@ -56,6 +56,8 @@ struct Http3Request { return getDecodedQueryValue(key, query); } + std::string_view getCaseSensitiveMethod() { return method; } + /* HttpRequest::getMethod() lowercases in place; we own no writable * buffer, so write into a per-request scratch instead. */ std::string_view getMethod() { diff --git a/packages/bun-uws/src/Http3Response.h b/packages/bun-uws/src/Http3Response.h index a0e6eca1b561..e2241b62d828 100644 --- a/packages/bun-uws/src/Http3Response.h +++ b/packages/bun-uws/src/Http3Response.h @@ -73,19 +73,23 @@ struct Http3Response { bool write(std::string_view data, size_t *writtenPtr = nullptr) { Http3ResponseData *d = getHttpResponseData(); flushHeaders(); - if (d->backpressure.length() != 0) { + if (d->headersDeferred || d->backpressure.length() != 0) { d->backpressure.append(data.data(), data.length()); if (writtenPtr) *writtenPtr = 0; - us_quic_stream_want_write((us_quic_stream_t *) this, 1); + wantWrite(); return false; } int w = us_quic_stream_write((us_quic_stream_t *) this, data.data(), (unsigned) data.length()); - if (w < 0) w = 0; + if (w < 0) { + failStream(); + if (writtenPtr) *writtenPtr = 0; + return false; + } d->offset += (uint64_t) w; if (writtenPtr) *writtenPtr = (size_t) w; if ((size_t) w < data.length()) { d->backpressure.append(data.data() + w, data.length() - (size_t) w); - us_quic_stream_want_write((us_quic_stream_t *) this, 1); + wantWrite(); return false; } return true; @@ -106,7 +110,9 @@ struct Http3Response { !(d->state & Http3ResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER)) { writeHeader("content-length", (uint64_t) *reportedContentLength); } - if (d->state & Http3ResponseData::HTTP_WRITE_CALLED) { + if (d->headersDeferred) { + d->endAfterDrain = true; + } else if (d->state & Http3ResponseData::HTTP_WRITE_CALLED) { us_quic_stream_shutdown((us_quic_stream_t *) this); } else { writeStatus("200 OK"); @@ -119,9 +125,9 @@ struct Http3Response { bool sendTerminatingChunk(bool /*closeConnection*/ = false) { Http3ResponseData *d = getHttpResponseData(); flushHeaders(); - if (d->backpressure.length() != 0) { + if (d->headersDeferred || d->backpressure.length() != 0) { d->endAfterDrain = true; - us_quic_stream_want_write((us_quic_stream_t *) this, 1); + wantWrite(); return false; } us_quic_stream_shutdown((us_quic_stream_t *) this); @@ -137,8 +143,8 @@ struct Http3Response { void overrideWriteOffset(uint64_t o) { getHttpResponseData()->offset = o; } size_t getBufferedAmount() { return getHttpResponseData()->backpressure.length(); } - Http3Response *pause() { us_quic_stream_want_read((us_quic_stream_t *) this, 0); return this; } - Http3Response *resume() { us_quic_stream_want_read((us_quic_stream_t *) this, 1); return this; } + Http3Response *pause() { us_quic_stream_hold_window((us_quic_stream_t *) this, 1); return this; } + Http3Response *resume() { us_quic_stream_hold_window((us_quic_stream_t *) this, 0); return this; } Http3Response *cork(MoveOnlyFunction &&fn) { fn(); return this; } void *getSocketData() { return getHttpResponseData()->socketData; } @@ -179,10 +185,19 @@ struct Http3Response { /* Called from Http3Context's on_stream_writable. */ bool drain() { Http3ResponseData *d = getHttpResponseData(); + if (d->headersDeferred) { + d->headersDeferred = false; + sendBufferedHeaders(d, false); + if (d->headersDeferred) return false; + } while (d->backpressure.length() != 0) { int w = us_quic_stream_write((us_quic_stream_t *) this, d->backpressure.data(), (unsigned) d->backpressure.length()); - if (w <= 0) return false; + if (w < 0) { + failStream(); + return true; + } + if (w == 0) return false; d->offset += (uint64_t) w; d->backpressure.erase((unsigned) w); } @@ -209,13 +224,38 @@ struct Http3Response { h.name = base + (uintptr_t) h.name; h.value = base + (uintptr_t) h.value; } - if (us_quic_stream_send_headers((us_quic_stream_t *) this, - d->hdrs.mutableSpan().data(), (unsigned) d->hdrs.size(), endStream) != 0) { - /* Later writes fail, and on_stream_close reports the abort as it does for a reset by the peer. */ - us_quic_stream_reset_internal_error((us_quic_stream_t *) this); + int r = us_quic_stream_send_headers((us_quic_stream_t *) this, + d->hdrs.mutableSpan().data(), (unsigned) d->hdrs.size(), endStream); + if (r == US_QUIC_SEND_HEADERS_RETRY) { + /* Back to offsets: hdrBuf can still grow before drain() resends the block. */ + for (auto &h : d->hdrs) { + h.name = (const char *) (uintptr_t) (h.name - base); + h.value = (const char *) (uintptr_t) (h.value - base); + } + d->headersDeferred = true; + /* endStream is only a shutdown after the send. */ + if (endStream) d->endAfterDrain = true; + wantWrite(); + return; } d->hdrBuf.shrink(0); d->hdrs.shrink(0); + if (r != 0) failStream(); + } + + /* A -1 from lsquic isn't backpressure: the send half is gone for good and no + * on_stream_writable follows. on_stream_close reports the abort, as it does + * for a reset by the peer. */ + void failStream() { + Http3ResponseData *d = getHttpResponseData(); + d->backpressure.clear(); + d->endAfterDrain = false; + us_quic_stream_reset_internal_error((us_quic_stream_t *) this); + } + + /* lsquic refuses once it has answered the peer's STOP_SENDING with RESET_STREAM. */ + void wantWrite() { + if (us_quic_stream_want_write((us_quic_stream_t *) this, 1) < 0) failStream(); } bool internalEnd(std::string_view data, uint64_t totalSize, bool optional, @@ -238,26 +278,31 @@ struct Http3Response { d->state |= Http3ResponseData::HTTP_WRITE_CALLED; } - if (d->backpressure.length() != 0) { + if (d->headersDeferred || d->backpressure.length() != 0) { if (optional) return false; d->backpressure.append(data.data(), data.length()); d->endAfterDrain = true; - us_quic_stream_want_write((us_quic_stream_t *) this, 1); + wantWrite(); return false; } int w = data.empty() ? 0 : us_quic_stream_write((us_quic_stream_t *) this, data.data(), (unsigned) data.length()); - if (w < 0) w = 0; + if (w < 0) { + failStream(); + if (optional) return false; + markDone(d); + return true; + } d->offset += (uint64_t) w; if ((size_t) w < data.length()) { if (optional) { - us_quic_stream_want_write((us_quic_stream_t *) this, 1); + wantWrite(); return false; } d->backpressure.append(data.data() + w, data.length() - (size_t) w); d->endAfterDrain = true; - us_quic_stream_want_write((us_quic_stream_t *) this, 1); + wantWrite(); return false; } @@ -266,7 +311,7 @@ struct Http3Response { markDone(d); return true; } - us_quic_stream_want_write((us_quic_stream_t *) this, 1); + wantWrite(); return false; } diff --git a/packages/bun-uws/src/Http3ResponseData.h b/packages/bun-uws/src/Http3ResponseData.h index 1009bd7d2683..7fca2ea17ec3 100644 --- a/packages/bun-uws/src/Http3ResponseData.h +++ b/packages/bun-uws/src/Http3ResponseData.h @@ -58,6 +58,9 @@ struct Http3ResponseData { /* Body bytes the QUIC stream couldn't accept yet. */ BackPressure backpressure; bool endAfterDrain = false; + /* lsquic refused the block in hdrBuf/hdrs because the 100 Continue block is + * still unsent. drain() sends it, and body bytes wait behind it. */ + bool headersDeferred = false; uint64_t offset = 0; uint8_t state = 0; @@ -86,6 +89,7 @@ struct Http3ResponseData { hdrs.shrink(0); backpressure.clear(); endAfterDrain = false; + headersDeferred = false; offset = 0; state = HTTP_RESPONSE_PENDING; } diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index 14c58b249928..52a5009acb7e 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -31,14 +31,19 @@ #include #include "MoveOnlyFunction.h" #include "HttpParser.h" +#include "Utilities.h" #include #include #include +#include #include extern "C" void Bun__NodeHTTP__onReadsResumable(int ssl, struct us_socket_t *s); extern "C" void Bun__NodeHTTP__onReadParsed(int ssl, struct us_socket_t *s); +extern "C" void Bun__NodeHTTP__onRawRead(int ssl, struct us_socket_t *s, const char *data, int length); +extern "C" void Bun__NodeHTTP__addBytesRead(void *socketData, int length); +extern "C" void Bun__NodeHTTP__halfCloseAfterDrain(int ssl, struct us_socket_t *s); namespace uWS { @@ -110,15 +115,15 @@ struct HttpContext { us_socket_group_t group{}; HttpContextData data; - /* Minimum allowed receive throughput per second (clients uploading less than 16kB/sec get dropped) */ - static constexpr int HTTP_RECEIVE_THROUGHPUT_BYTES = 16 * 1024; - /* Not constexpr — the ordinals are linked from `src/uws_sys/SocketKind.rs` * so a reorder there can't silently mis-route us. Only ever read * at runtime (listen/adopt). */ static unsigned char socketKind() { return SSL ? US_SOCKET_KIND_UWS_HTTP_TLS : US_SOCKET_KIND_UWS_HTTP; } public: + /* Minimum allowed throughput per second, either way (clients that upload, or take a blocked response, at less than 16kB/sec get dropped) */ + static constexpr int HTTP_RECEIVE_THROUGHPUT_BYTES = 16 * 1024; + us_socket_group_t *getSocketGroup() { return &group; } @@ -155,7 +160,7 @@ struct HttpContext { if(rejectUnauthorized) { if(!success || verify_error.error != 0) { // we failed to handshake, close the socket - us_socket_close(s, 0, nullptr); + us_socket_close_now(s); return; } } @@ -171,8 +176,13 @@ struct HttpContext { httpResponseData->peerCertVerified = success && verify_error.error == 0; httpResponseData->peerCertVerifyErrorCode = verify_error.code; - /* Any connected socket should timeout until it has a request */ - ((HttpResponse *) s)->resetTimeout(); + if (fromSocket(s)->isNodeHttp()) { + /* node:http closes a connection that has no request yet at headersTimeout. */ + ((HttpResponse *) s)->setTimeout(0); + } else { + /* Any connected socket should timeout until it has a request */ + ((HttpResponse *) s)->resetTimeout(); + } /* Call filter */ httpResponseData->filteredOpen = true; @@ -198,8 +208,13 @@ struct HttpContext { } else { new (us_socket_ext(s)) HttpResponseData; } - /* Any connected socket should timeout until it has a request */ - ((HttpResponse *) s)->resetTimeout(); + if constexpr (IsNodeHttp && !SSL) { + /* node:http closes a connection that has no request yet at headersTimeout. */ + ((HttpResponse *) s)->setTimeout(0); + } else { + /* Any connected socket should timeout until it has a request, or until its TLS handshake completes */ + ((HttpResponse *) s)->resetTimeout(); + } HttpContextData *httpContextData = getSocketContextDataS(s); @@ -353,6 +368,10 @@ struct HttpContext { HttpContextData *httpContextData = getSocketContextDataS(s); HttpResponseData *httpResponseData = (HttpResponseData *) us_socket_ext(s); + if constexpr (IsNodeHttp) { + Bun__NodeHTTP__addBytesRead(httpResponseData->socketData, length); + } + /* Do not accept any data while in shutdown state. node:http is half-open: a tunnel and the body of the request in flight still read after socket.end(). */ bool isHalfOpenTunnel = false; if constexpr (IsNodeHttp) isHalfOpenTunnel = httpResponseData->isConnectRequest; @@ -411,6 +430,16 @@ struct HttpContext { } } + if constexpr (IsNodeHttp) { + if ((httpResponseData->state & HttpResponseData::HTTP_NODE_RAW_READS) && !httpResponseData->isConnectRequest) [[unlikely]] { + Bun__NodeHTTP__onRawRead(SSL, s, data, length); + /* That ran JavaScript: a closed or upgraded socket no longer has an HttpResponseData. */ + if (us_socket_is_closed(s) || us_socket_kind(s) != socketKind()) { + return s; + } + } + } + /* node:http compat: HTTP parsing stopped on this connection (a parse error * was already delivered to 'clientError', or the JS layer freed the * parser); ignore further request bytes. CONNECT/Upgrade tunnels are not @@ -438,6 +467,11 @@ struct HttpContext { const bool wasIdle = httpResponseData->isIdle; httpResponseData->isIdle = false; + /* Bun.serve: a connection has one response slot. While it's taken, the parser parks the next request instead of parsing it. */ + if constexpr (!IsNodeHttp) { + httpResponseData->parkAtNextBoundary = (httpResponseData->state & HttpResponseData::HTTP_RESPONSE_PENDING) != 0; + } + /* node:http compat: maintain the headers/request timeout window (see * the requestHandler/dataHandler hooks and the post-parse check). */ const bool trackNodeHttpTimings = IsNodeHttp && !httpResponseData->isConnectRequest; @@ -500,9 +534,7 @@ struct HttpContext { nodeHttpResponseData->headersCompleted = true; } - /* Are we not ready for another request yet? Terminate the connection. - * Important for denying async pipelining until, if ever, we want to support it. - * Otherwise requests can get mixed up on the same connection. We still support sync pipelining. */ + /* Is the previous response on this connection still in flight? */ bool queueBehindEarlierResponse = false; if constexpr (IsNodeHttp) { /* node:http also queues behind responses that were dispatched but @@ -520,8 +552,10 @@ struct HttpContext { } if ((httpResponseData->state & HttpResponseData::HTTP_RESPONSE_PENDING) || queueBehindEarlierResponse) { if constexpr (!IsNodeHttp) { - /* Responses that completed earlier in this read can still - * sit in the cork buffer. close() sends them first. */ + /* The parser parks a request that arrives while a response is pending, so this + * only guards against interleaved responses. Responses that completed earlier in + * this read can still sit in the cork buffer. close() sends them first. */ + ASSERT_NOT_REACHED(); ((AsyncSocket *) s)->close(); return nullptr; } else { @@ -570,6 +604,13 @@ struct HttpContext { httpResponseData->state |= HttpResponseData::HTTP_CONNECTION_CLOSE; } + /* The parser treats everything after a CONNECT as tunnel data, and Bun.serve has no tunnel to give it to. */ + if constexpr (!IsNodeHttp) { + if (httpResponseData->isConnectRequest) [[unlikely]] { + httpResponseData->state |= HttpResponseData::HTTP_CONNECTION_CLOSE; + } + } + /* Per-response trailer fields must not leak into the next response * on this keep-alive connection (the flag itself was cleared above). */ if constexpr (IsNodeHttp) { @@ -590,11 +631,12 @@ struct HttpContext { /* Route the method and URL */ httpContextData->router.getUserData() = {(HttpResponse *) s, httpRequest}; + httpRequest->setYield(false); 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(); - return nullptr; + /* Same answers as HTTP/2 and HTTP/3. No handler reads this request's body, so the connection closes after the response. + * A handler that ran and yielded may have lowercased the method in place (getMethod()), so check the yield flag first. */ + bool knownMethod = httpRequest->getYield() || std::ranges::binary_search(detail::supportedHttpMethods, httpRequest->getCaseSensitiveMethod()); + ((HttpResponse *) s)->writeStatus(knownMethod ? "404 Not Found" : "501 Not Implemented")->end({}, true); } /* First of all we need to check if this socket was deleted due to upgrade */ @@ -629,6 +671,11 @@ struct HttpContext { ((HttpResponse *) s)->resetTimeout(); } + /* Derived here too, because a Content-Length: 0 head completed from the fallback buffer gets no data callback. */ + if constexpr (!IsNodeHttp) { + httpResponseData->parkAtNextBoundary = (httpResponseData->state & HttpResponseData::HTTP_RESPONSE_PENDING) != 0; + } + /* Continue parsing */ return s; @@ -675,8 +722,13 @@ struct HttpContext { /* Todo: can this handle timeout for non-post as well? */ if (fin) { - /* If we just got the last chunk (or empty chunk), disable timeout */ - us_socket_timeout((struct us_socket_t *) user, 0); + /* If we just got the last chunk (or empty chunk), disable timeout. Keep it while a Bun.serve response + * write is blocked, because nothing else closes a connection whose peer never reads. */ + if (!IsNodeHttp && ((struct us_socket_t *) user)->flags.last_write_failed) [[unlikely]] { + ((HttpResponse *) user)->resetTimeout(); + } else { + us_socket_timeout((struct us_socket_t *) user, 0); + } } else { /* We still have some more data coming in later, so reset timeout */ /* Only reset timeout if we got enough bytes (16kb/sec) since last time we reset here */ @@ -734,6 +786,12 @@ struct HttpContext { return nullptr; } } + /* The handler can complete the response from this body callback. */ + if constexpr (!IsNodeHttp) { + if (fin) { + httpResponseData->parkAtNextBoundary = (httpResponseData->state & HttpResponseData::HTTP_RESPONSE_PENDING) != 0; + } + } return user; }); @@ -779,7 +837,10 @@ struct HttpContext { * them first. */ ((AsyncSocket *) s)->uncork(); /* For errors, we only deliver them "at most once". We don't care if they get halfways delivered or not. */ - us_socket_write(s, httpErrorResponses[httpErrorStatusCode].data(), (int) httpErrorResponses[httpErrorStatusCode].length()); + /* RFC 9112 9.6: nothing follows a complete response that closes the connection. */ + if (!httpResponseData->isDrainingBeforeClose()) { + us_socket_write(s, httpErrorResponses[httpErrorStatusCode].data(), (int) httpErrorResponses[httpErrorStatusCode].length()); + } us_socket_shutdown(s); /* Close any socket on HTTP errors */ us_socket_close(s, 0, nullptr); @@ -839,6 +900,17 @@ struct HttpContext { ((HttpResponse *) s)->resetTimeout(); } + /* Reads stay paused while requests are parked, so a connection parks at most one read. markDone() requests + * the replay, unless the response completed before the park. */ + if constexpr (!IsNodeHttp) { + if (!httpResponseData->parkedRequestBytes.isEmpty()) [[unlikely]] { + ((AsyncSocket *) s)->pause(); + if ((httpResponseData->state & HttpResponseData::HTTP_RESPONSE_PENDING) == 0) { + us_socket_request_writable(s); + } + } + } + /* We need to check if we should close this socket here now */ if (httpResponseData->shouldCloseConnection()) { if ((httpResponseData->state & HttpResponseData::HTTP_RESPONSE_PENDING) == 0) { @@ -951,9 +1023,11 @@ struct HttpContext { * nothing in AsyncSocketData::buffer). A retry that moves zero bytes * after the peer's FIN is EPIPE; close instead of spinning. Except * on libuv, where the retry can stall while the TLS layer's spill - * is still blocked on a healthy socket; there the kernel is asked. */ + * is still blocked on a healthy socket; there the kernel is asked. + * A file body never moves offset. Its failed sendfile() or write() closes the socket. */ if ((httpResponseData->state & HttpResponseData::HTTP_NODE_RECEIVED_FIN) && (httpResponseData->state & HttpResponseData::HTTP_RESPONSE_PENDING) + && (httpResponseData->state & HttpResponseData::HTTP_END_CALLED) && httpResponseData->offset == offsetBefore && asyncSocket->hasFullyDrained() && us_socket_stalled_write_means_peer_gone((us_socket_t *) asyncSocket)) { @@ -1003,6 +1077,16 @@ struct HttpContext { if (httpResponseData->state & HttpResponseData::HTTP_NODE_CLOSE_AFTER_DRAIN) { responseDone = true; } + /* socket.end() issued while bytes were queued: Node's FIN follows them and the socket stays open for the peer's. + * A re-armed onWritable means a fresh pinned write, as above. */ + if (!responseDone + && (httpResponseData->state & HttpResponseData::HTTP_NODE_SHUTDOWN_AFTER_DRAIN) + && httpResponseData->onWritable == nullptr + && !us_socket_is_shut_down(s) + && asyncSocket->hasFullyDrained()) { + Bun__NodeHTTP__halfCloseAfterDrain(SSL, s); + return s; + } } if (responseDone && asyncSocket->hasFullyDrained()) { asyncSocket->shutdown(); @@ -1015,9 +1099,43 @@ struct HttpContext { /* Expect another writable event, or another request within the timeout */ reinterpret_cast *>(s)->resetTimeout(); + if constexpr (!IsNodeHttp) { + return replayParkedRequestBytes(s); + } return s; } + /* Bun.serve: once the response ahead has completed and drained, parses the requests parked behind it. + * Runs at the end of onWritable, where nothing of that response is on the stack and the close gate has run. */ + static us_socket_t *replayParkedRequestBytes(us_socket_t *s) { + if (us_socket_is_closed(s) || us_socket_is_shut_down(s)) { + return s; + } + auto *httpResponseData = reinterpret_cast *>(us_socket_ext(s)); + if (httpResponseData->parkedRequestBytes.isEmpty() + || (httpResponseData->state & HttpResponseData::HTTP_RESPONSE_PENDING) + || !reinterpret_cast *>(s)->hasFullyDrained()) { + return s; + } + reinterpret_cast *>(s)->resume(); + /* us_socket_resume() closes a socket that the kernel doesn't take back. */ + if (us_socket_is_closed(s)) { + return s; + } + /* This frame owns the buffer during the parse, because a dispatch can close or upgrade the socket and destruct httpResponseData. */ + WTF::Vector replayed = std::exchange(httpResponseData->parkedRequestBytes, {}); + size_t start = std::exchange(httpResponseData->parkedRequestBytesStart, 0); + size_t length = replayed.size() - start; + /* The parser writes a fence behind its input. */ + replayed.grow(replayed.size() + LIBUS_RECV_BUFFER_PADDING); + httpResponseData->replayedRequestBytes = &replayed; + us_socket_t *returned = onData(s, replayed.mutableSpan().data() + start, (int) length); + if (!us_socket_is_closed(s) && us_socket_kind(s) == socketKind()) { + httpResponseData->replayedRequestBytes = nullptr; + } + return returned; + } + template static us_socket_t *onEnd(us_socket_t *s) { auto *asyncSocket = reinterpret_cast *>(s); @@ -1111,18 +1229,18 @@ struct HttpContext { * the connection shuts down (from the existing shouldCloseConnection() * gates), not be discarded by the close() below. Only a response that * is fully determined qualifies: a tryEnd tail (content-length path - * sets HTTP_END_CALLED while offset < total keeps HTTP_RESPONSE_PENDING) - * or a completed response that has not fully drained. A streaming body - * the application is still producing (HTTP_END_CALLED clear, - * HTTP_RESPONSE_PENDING set) closes here so onAborted / request.signal - * fires on client disconnect. */ + * sets HTTP_END_CALLED while offset < total keeps HTTP_RESPONSE_PENDING), + * a file body (HTTP_FIXED_LENGTH_FILE_BODY), or a completed response + * that has not fully drained. A streaming body the application is still + * producing (neither bit, HTTP_RESPONSE_PENDING set) closes here so + * onAborted / request.signal fires on client disconnect. */ HttpResponseData *httpResponseData = (HttpResponseData *) us_socket_ext(s); uint32_t state = httpResponseData->state; - bool tryEndTail = (state & HttpResponseData::HTTP_END_CALLED) + bool determinedTail = (state & (HttpResponseData::HTTP_END_CALLED | HttpResponseData::HTTP_FIXED_LENGTH_FILE_BODY)) && (state & HttpResponseData::HTTP_RESPONSE_PENDING); bool doneButBuffered = !(state & HttpResponseData::HTTP_RESPONSE_PENDING) && !asyncSocket->hasFullyDrained(); - if (tryEndTail || doneButBuffered) { + if (determinedTail || doneButBuffered) { httpResponseData->state |= HttpResponseData::HTTP_NODE_RECEIVED_FIN; return s; } @@ -1140,6 +1258,13 @@ struct HttpContext { // Node.js by default closes the connection but they emit the timeout event before that HttpResponseData *httpResponseData = reinterpret_cast *>(asyncSocket->getAsyncSocketData()); + /* A peer that took a blocked response at the rate onData demands of an upload is slow, not idle. */ + if (s->flags.last_write_failed && (httpResponseData->state & HttpResponseData::HTTP_DELIVERED_BYTES_RECORDED) + && us_socket_delivered_since(s, &httpResponseData->deliveredBytes, (uint64_t) HTTP_RECEIVE_THROUGHPUT_BYTES * httpResponseData->idleTimeout)) { + asyncSocket->timeout(httpResponseData->idleTimeout); + return s; + } + if (httpResponseData->onTimeout) { httpResponseData->onTimeout((HttpResponse *)s, httpResponseData->userData); } @@ -1221,9 +1346,12 @@ struct HttpContext { if (!httpContextData->flags.usingCustomExpectHandler) { /* Middleware? Automatically respond to expectations */ - std::string_view expect = user.httpRequest->getHeader("expect"); - if (expect.length() && expect == "100-continue") { - user.httpResponse->writeContinue(); + if (isExpect100Continue(user.httpRequest->getHeader("expect"))) { + /* RFC 9110 10.1.1: answer with the final status alone when the head already decides it. */ + std::string_view contentLength = user.httpRequest->getHeader("content-length"); + if (contentLength.empty() || HttpParser::toUnsignedInteger(contentLength) <= httpContextData->maxRequestBodySize) { + user.httpResponse->writeContinue(); + } } } @@ -1290,15 +1418,18 @@ struct HttpContext { } /* Listen to port using this HttpContext. ssl_ctx may be nullptr for plain HTTP. */ - us_listen_socket_t *listen(struct ssl_ctx_st *sslCtx, const char *host, int port, int options) { - int error = 0; - return unrefListenSocket(us_socket_group_listen(&group, socketKind(), sslCtx, host, port, tcpListenOptions(options), socketExtSize(), &error)); + us_listen_socket_t *listen(struct ssl_ctx_st *sslCtx, const char *host, int port, int options, int *error, int *dnsError) { + return unrefListenSocket(us_socket_group_listen(&group, socketKind(), sslCtx, host, port, tcpListenOptions(options), socketExtSize(), error, dnsError)); } /* Listen to unix domain socket using this HttpContext */ - us_listen_socket_t *listen_unix(struct ssl_ctx_st *sslCtx, const char *path, size_t pathlen, int options) { - int error = 0; - return unrefListenSocket(us_socket_group_listen_unix(&group, socketKind(), sslCtx, path, pathlen, options, socketExtSize(), &error)); + us_listen_socket_t *listen_unix(struct ssl_ctx_st *sslCtx, const char *path, size_t pathlen, int options, int *error) { + return unrefListenSocket(us_socket_group_listen_unix(&group, socketKind(), sslCtx, path, pathlen, options, socketExtSize(), error)); + } + + /* Listen on a descriptor the caller already bound. On failure the caller keeps it and *error holds the errno. */ + us_listen_socket_t *listen_fd(struct ssl_ctx_st *sslCtx, LIBUS_SOCKET_DESCRIPTOR fd, int backlog, int options, int *error) { + return unrefListenSocket(us_socket_group_listen_fd(&group, socketKind(), sslCtx, fd, backlog, tcpListenOptions(options), socketExtSize(), error)); } }; diff --git a/packages/bun-uws/src/HttpContextData.h b/packages/bun-uws/src/HttpContextData.h index 45cb7957a258..ddf195b7700a 100644 --- a/packages/bun-uws/src/HttpContextData.h +++ b/packages/bun-uws/src/HttpContextData.h @@ -85,6 +85,8 @@ struct alignas(16) HttpContextData { OnClientErrorCallback onClientError = nullptr; uint64_t maxHeaderSize = 0; // 0 means no limit + /* Bun.serve answers 413 to a larger Content-Length, so such a request gets no automatic 100 Continue. */ + uint64_t maxRequestBodySize = UINT64_MAX; uint32_t maxHeadersCount = 0; // node:http server.maxHeadersCount; 0 means not set /* HTTP/2: set by Http2Context::attach(). A connection that negotiated h2 diff --git a/packages/bun-uws/src/HttpParser.h b/packages/bun-uws/src/HttpParser.h index e19c2b07aa55..f7ccb06aa520 100644 --- a/packages/bun-uws/src/HttpParser.h +++ b/packages/bun-uws/src/HttpParser.h @@ -33,6 +33,7 @@ #include #include #include +#include #include #include "MoveOnlyFunction.h" #include "ChunkedEncoding.h" @@ -108,6 +109,10 @@ struct HttpResponseData; HTTP_PARSER_ERROR_TRAILER_CONTENT_LENGTH = 17, /* A byte in the request target that llhttp rejects (HPE_INVALID_URL). node:http compat only. */ HTTP_PARSER_ERROR_INVALID_URL = 18, + /* llhttp's other Content-Length reasons. HTTP_PARSER_ERROR_INVALID_CONTENT_LENGTH is a non-digit in the value. */ + HTTP_PARSER_ERROR_EMPTY_CONTENT_LENGTH = 19, + HTTP_PARSER_ERROR_CONTENT_LENGTH_OVERFLOW = 20, + HTTP_PARSER_ERROR_DUPLICATE_CONTENT_LENGTH = 21, }; @@ -490,6 +495,10 @@ struct HttpResponseData; // Move past comma if present if (pos < value.length() && value[pos] == ',') { + /* llhttp rejects any comma after "chunked", even with nothing behind it ("chunked,"). */ + if (te.chunked) [[unlikely]] { + te.invalid = true; + } pos++; } } @@ -720,7 +729,29 @@ struct HttpResponseData; bool nodeHttpBodyUntilEof = false; /* A request on this connection had Connection: close or was HTTP/1.0, or a Bun.serve response closed it (RFC 9112 9.6). */ bool sawConnectionClose = false; + /* Bun.serve: a response is pending, so the parse loop stops at the next request boundary and parks + * the rest of the read in parkedRequestBytes. HttpContext::onData derives it. */ + bool parkAtNextBoundary = false; + /* Offset of the first parked byte in parkedRequestBytes. */ + unsigned int parkedRequestBytesStart = 0; + WTF::Vector parkedRequestBytes; + /* The buffer HttpContext::replayParkedRequestBytes is parsing, or nullptr. */ + WTF::Vector *replayedRequestBytes = nullptr; private: + void parkRequestBytes(char *data, unsigned int length) { + /* A replay parks the tail of its own buffer. Take the buffer back and move the start, so N parked requests don't cost N copies. */ + if (WTF::Vector *replayed = std::exchange(replayedRequestBytes, nullptr)) { + const uintptr_t begin = (uintptr_t) replayed->mutableSpan().data(); + if (parkedRequestBytes.isEmpty() && (uintptr_t) data >= begin && (uintptr_t) data + length <= begin + replayed->size()) { + parkedRequestBytesStart = (unsigned int) ((uintptr_t) data - begin); + replayed->shrink(parkedRequestBytesStart + length); + parkedRequestBytes = std::exchange(*replayed, {}); + return; + } + } + parkedRequestBytes.append(std::span(data, length)); + } + /* This guy really has only 30 bits since we reserve two highest bits to chunked encoding parsing state */ uint64_t remainingStreamingBytes = 0; @@ -739,6 +770,7 @@ struct HttpResponseData; * personality so a client cannot stream unbounded extension bytes. */ static const uint64_t MAX_CHUNK_EXTENSION_SIZE = 16 * 1024; + public: /* Returns UINT64_MAX on error. Maximum 999999999 is allowed. */ static uint64_t toUnsignedInteger(std::string_view str) { /* We assume at least 64-bit integer giving us safely 999999999999999999 (18 number of 9s) */ @@ -757,6 +789,7 @@ struct HttpResponseData; return unsignedIntegerValue; } + private: static inline uint64_t hasLess(uint64_t x, uint64_t n) { return (((x)-~0ULL/255*(n))&~(x)&~0ULL/255*128); } @@ -1291,13 +1324,21 @@ struct HttpResponseData; } } } - /* Must stay below the tunnel check, the park and the CR/LF skip, like llhttp's closed state. */ + /* Must stay below the tunnel check and the CR/LF skip, like llhttp's closed state. */ if (sawConnectionClose) { if constexpr (IsNodeHttp) { return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_CLOSED_CONNECTION); } return HttpParserResult::success(consumedTotal + length, user); } + /* Stays below the sawConnectionClose check, because parking pauses reads and closing over unread bytes + * resets the connection. Bytes that arrive while others are parked queue behind them. */ + if constexpr (!IsNodeHttp) { + if (parkAtNextBoundary || !parkedRequestBytes.isEmpty()) [[unlikely]] { + parkRequestBytes(data, length); + return HttpParserResult::success(consumedTotal + length, user); + } + } bool isConnectRequestLine = false; auto result = getHeaders(data, data + length, req->headers, req->ancientHttp, isConnectRequestLine, useStrictMethodValidation, useInsecureHTTPParser, maxHeaderSize, maxHeaderFields); if(result.isError()) { @@ -1321,8 +1362,10 @@ struct HttpResponseData; /* Add all headers to bloom filter */ req->bf.reset(); + unsigned int hostHeaderCount = 0; for (HttpRequest::Header *h = req->headers; (++h)->key.length(); ) { req->bf.add(h->key); + hostHeaderCount += h->key.length() == 4 && !strncasecmp(h->key.data(), "host", 4); } if (req->isAncient() || req->hasConnectionClose(IsNodeHttp)) { sawConnectionClose = true; @@ -1343,12 +1386,12 @@ struct HttpResponseData; if (h->key.length() == 14 && !strncasecmp(h->key.data(), "content-length", 14)) { if (contentLengthString.data() == nullptr) { if (h->value.length() == 0) { - return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_INVALID_CONTENT_LENGTH); + return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_EMPTY_CONTENT_LENGTH); } contentLengthString = h->value; } else if (h->value.length() != contentLengthString.length() || strncmp(h->value.data(), contentLengthString.data(), contentLengthString.length())) { - return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_INVALID_CONTENT_LENGTH); + return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_DUPLICATE_CONTENT_LENGTH); } } } @@ -1444,11 +1487,16 @@ struct HttpResponseData; * detected while llhttp parses the headers, whereas the Host requirement is a * post-completion check, so on doubly-invalid input the framing error wins (Node * reports e.g. HPE_INVALID_TRANSFER_ENCODING for such requests). */ - if (!req->ancientHttp && requireHostHeader && !req->getHeader("host").data() + if (!req->ancientHttp && requireHostHeader && hostHeaderCount == 0 && !(IsNodeHttp && (isConnectRequestLine || req->getHeader("upgrade").data()))) { return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_MISSING_HOST_HEADER); } + /* RFC 9112 3.2: more than one Host line is a 400. node:http follows llhttp and keeps the first. */ + if (!IsNodeHttp && hostHeaderCount > 1) [[unlikely]] { + return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_INVALID_REQUEST); + } + /* Parse query */ const char *querySeparatorPtr = (const char *) memchr(req->headers->value.data(), '?', req->headers->value.length()); req->querySeparator = (unsigned int) ((querySeparatorPtr ? querySeparatorPtr : req->headers->value.data() + req->headers->value.length()) - req->headers->value.data()); @@ -1462,7 +1510,8 @@ struct HttpResponseData; * Content-Length value must never reach a flag bit. UINT64_MAX (parse error) is * also caught by this since UINT64_MAX > STATE_SIZE_MASK. */ if (remainingStreamingBytes > STATE_SIZE_MASK) [[unlikely]] { - return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_INVALID_CONTENT_LENGTH); + const bool allDigits = contentLengthString.find_first_not_of("0123456789") == std::string_view::npos; + return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, allDigits ? HTTP_PARSER_ERROR_CONTENT_LENGTH_OVERFLOW : HTTP_PARSER_ERROR_INVALID_CONTENT_LENGTH); } } @@ -1547,7 +1596,7 @@ struct HttpResponseData; if constexpr (!ConsumeMinimally) { /* Go ahead and parse it (todo: better heuristics for emitting FIN to the app level) */ std::string_view dataToConsume(data, length); - for (auto chunk : uWS::ChunkIterator(&dataToConsume, &remainingStreamingBytes, false, chunkedExtensionsByteCount, nodeHttpRequestTrailers, maxBufferedHeaderSize)) { + for (auto chunk : uWS::ChunkIterator(&dataToConsume, &remainingStreamingBytes, chunkedExtensionsByteCount, nodeHttpRequestTrailers, maxBufferedHeaderSize)) { /* llhttp errors at the offending extension byte, before any body bytes from * that chunk reach the application; check before every dispatch. */ if (*chunkedExtensionsByteCount > MAX_CHUNK_EXTENSION_SIZE) [[unlikely]] { @@ -1588,7 +1637,8 @@ struct HttpResponseData; consumedTotal += consumed; } } else if (contentLengthStringLen) { - if constexpr (!ConsumeMinimally) { + /* The caller streams the body after a ConsumeMinimally pass, but only while remainingStreamingBytes isn't 0. Content-Length: 0 has to get its fin here. */ + if (!ConsumeMinimally || remainingStreamingBytes == 0) { unsigned int emittable = (unsigned int) std::min(remainingStreamingBytes, length); bool fin = emittable == remainingStreamingBytes; /* Account for the chunk before the handler runs, like the chunked @@ -1647,7 +1697,7 @@ struct HttpResponseData; } else if (isParsingChunkedEncoding(remainingStreamingBytes)) { /* It's either chunked or with a content-length */ std::string_view dataToConsume(data, length); - for (auto chunk : uWS::ChunkIterator(&dataToConsume, &remainingStreamingBytes, false, chunkedExtensionsByteCount, nodeHttpRequestTrailers, maxFallbackSize)) { + for (auto chunk : uWS::ChunkIterator(&dataToConsume, &remainingStreamingBytes, chunkedExtensionsByteCount, nodeHttpRequestTrailers, maxFallbackSize)) { if (*chunkedExtensionsByteCount > MAX_CHUNK_EXTENSION_SIZE) [[unlikely]] { return HttpParserResult::error(HTTP_ERROR_413_PAYLOAD_TOO_LARGE, HTTP_PARSER_ERROR_CHUNK_EXTENSIONS_OVERFLOW); } @@ -1763,7 +1813,7 @@ struct HttpResponseData; } else if (isParsingChunkedEncoding(remainingStreamingBytes)) { /* It's either chunked or with a content-length */ std::string_view dataToConsume(data, length); - for (auto chunk : uWS::ChunkIterator(&dataToConsume, &remainingStreamingBytes, false, chunkedExtensionsByteCount, nodeHttpRequestTrailers, maxFallbackSize)) { + for (auto chunk : uWS::ChunkIterator(&dataToConsume, &remainingStreamingBytes, chunkedExtensionsByteCount, nodeHttpRequestTrailers, maxFallbackSize)) { if (*chunkedExtensionsByteCount > MAX_CHUNK_EXTENSION_SIZE) [[unlikely]] { return HttpParserResult::error(HTTP_ERROR_413_PAYLOAD_TOO_LARGE, HTTP_PARSER_ERROR_CHUNK_EXTENSIONS_OVERFLOW); } diff --git a/packages/bun-uws/src/HttpResponse.h b/packages/bun-uws/src/HttpResponse.h index 9b4a0e1087af..6e86b6c33f03 100644 --- a/packages/bun-uws/src/HttpResponse.h +++ b/packages/bun-uws/src/HttpResponse.h @@ -63,6 +63,12 @@ struct HttpResponse : public AsyncSocket { auto* data = getHttpResponseData(); Super::timeout(data->idleTimeout); + /* The kernel drains a blocked write at the peer's pace, and a writable event only comes once a good part of the + * send buffer is free. Record how far the kernel got, so onTimeout can tell a slow reader from a stalled one. */ + if (((us_socket_t *) this)->flags.last_write_failed) [[unlikely]] { + data->setFlag(HttpResponseData::HTTP_DELIVERED_BYTES_RECORDED, + data->idleTimeout && us_socket_delivered_bytes((us_socket_t *) this, &data->deliveredBytes) == 0); + } } /* The chunk-size line of a chunk. Returns its length. */ static constexpr size_t CHUNK_HEAD_MAX = 10; @@ -93,6 +99,36 @@ struct HttpResponse : public AsyncSocket { getHttpResponseData()->state |= HttpResponseData::HTTP_WROTE_DATE_HEADER; } + /* Writes the headers uWS adds unless the caller wrote them: Date, Transfer-Encoding: chunked for a chunked + * body, and Connection: close when the connection closes after this response (RFC 9112 9.6). A client that + * doesn't see "close" puts the socket back in its pool and loses the next request on it. */ + void writeOwnedHeaders(bool chunked) { + HttpResponseData *httpResponseData = getHttpResponseData(); + writeMark(); + if (chunked && !(httpResponseData->state & HttpResponseData::HTTP_WROTE_TRANSFER_ENCODING_HEADER)) { + writeHeader("Transfer-Encoding", "chunked"); + } + if (httpResponseData->closesAfterResponse() && !(httpResponseData->state & HttpResponseData::HTTP_WROTE_CONNECTION_HEADER)) [[unlikely]] { + writeHeader("Connection", "close"); + /* Nothing may follow a response that says "close", so a graceful stop's mark becomes a hard close. */ + httpResponseData->state |= HttpResponseData::HTTP_WROTE_CONNECTION_HEADER | HttpResponseData::HTTP_CONNECTION_CLOSE; + } + } + + /* Ends the header section. Every HTTP/1 response goes through here or through internalEnd()'s Content-Length path. */ + void terminateHeaders(bool chunked = false) { + writeOwnedHeaders(chunked); + Super::write("\r\n", 2); + } + + /* The close mark has to be set before the header section ends, or the response can't say "close". */ + void markCloseConnection(bool closeConnection) { + HttpResponseData *httpResponseData = getHttpResponseData(); + if (closeConnection && !(httpResponseData->state & HttpResponseData::HTTP_END_CALLED)) { + httpResponseData->state |= HttpResponseData::HTTP_CONNECTION_CLOSE; + } + } + /* Shutdown+close when the connection is marked to close (Connection: * close, peer FIN, close-when-idle), the response is complete and every * outgoing byte has been flushed. Returns true when the socket was closed. */ @@ -171,6 +207,10 @@ struct HttpResponse : public AsyncSocket { data->state |= HttpResponseData::HTTP_CLOSE_WHEN_IDLE; idle = false; } + } else if (idle && !Super::hasFullyDrained()) { + /* Bun.serve: close() would drop the bytes of a finished response that are still in the backpressure buffer. + * Not idle until they are out: the graceful stop() marks the connection, and onWritable closes it. */ + idle = false; } if (!idle) { return false; @@ -184,16 +224,18 @@ struct HttpResponse : public AsyncSocket { return true; } - /* node:http: an idle tunnel is at read EOF and has nothing left to send. Like a libuv handle in that state, it does not hold - * the event loop. The filter hears -3 when a tunnel becomes idle and +3 when it has bytes to send again. */ - void setNodeHttpTunnelIdle(bool idle) { + /* node:http: an idle tunnel doesn't read (reads stopped, or at EOF) and has nothing left to send. Like a libuv handle in that state, + * it does not hold the event loop. The filter hears -3 when a tunnel becomes idle. When that ends it hears +3 if only bytes to send + * can be the reason, and +4 if the tunnel can also be reading again (canRead). + * A parked request's connection (NodeHTTPResponse::update_parked) counts as one until it reads or has bytes to send again. */ + void setNodeHttpTunnelIdle(bool idle, bool canRead = false) { HttpResponseData *httpResponseData = getHttpResponseData(); if (!httpResponseData->filteredAccept || httpResponseData->filteredIdleTunnel == idle) { return; } httpResponseData->filteredIdleTunnel = idle; for (auto &f : HttpContext::getSocketContextDataS((us_socket_t *) this)->filterHandlers) { - f(this, idle ? -3 : 3); + f(this, idle ? -3 : canRead ? 4 : 3); } } @@ -226,22 +268,7 @@ struct HttpResponse : public AsyncSocket { } /* In some cases, such as when refusing huge data we want to close the connection when drained */ - if (closeConnection) { - /* We can only write the header once */ - if (!(httpResponseData->state & (HttpResponseData::HTTP_END_CALLED))) { - - /* HTTP 1.1 must send this back unless the client already sent it to us. - * It is a connection close when either of the two parties say so but the - * one party must tell the other one so. - * - * This check also serves to limit writing the header only once. */ - if ((httpResponseData->state & HttpResponseData::HTTP_CONNECTION_CLOSE) == 0 && !(httpResponseData->state & (HttpResponseData::HTTP_WRITE_CALLED))) { - writeHeader("Connection", "close"); - } - - httpResponseData->state |= HttpResponseData::HTTP_CONNECTION_CLOSE; - } - } + markCloseConnection(closeConnection); /* if write was called and there was previously no Content-Length header set. * node:http compat: pending response trailers (addTrailers) also force chunked @@ -254,11 +281,7 @@ struct HttpResponse : public AsyncSocket { /* Trailers-only end with no body chunk: write() below would early-return on * empty data, so terminate the header section and enter chunked mode here. */ if (!(httpResponseData->state & HttpResponseData::HTTP_WRITE_CALLED) && data.empty()) [[unlikely]] { - writeMark(); - if (!(httpResponseData->state & HttpResponseData::HTTP_WROTE_TRANSFER_ENCODING_HEADER)) { - writeHeader("Transfer-Encoding", "chunked"); - } - Super::write("\r\n", 2); + terminateHeaders(true); httpResponseData->state |= HttpResponseData::HTTP_WRITE_CALLED; } @@ -311,8 +334,7 @@ struct HttpResponse : public AsyncSocket { * header bytes into the body. The connection close delimits * the message instead. */ if (!(httpResponseData->state & (HttpResponseData::HTTP_WRITE_CALLED))) { - /* Write mark, this propagates to WebSockets too */ - writeMark(); + writeOwnedHeaders(false); /* WebSocket upgrades does not allow content-length */ if (allowContentLength) { @@ -385,6 +407,7 @@ struct HttpResponse : public AsyncSocket { ->writeHeader("Upgrade", "websocket") ->writeHeader("Connection", "Upgrade") ->writeHeader("Sec-WebSocket-Accept", secWebSocketAccept); + getHttpResponseData()->state |= HttpResponseData::HTTP_WROTE_CONNECTION_HEADER; /* Select first subprotocol if present */ if (secWebSocketProtocol.length()) { @@ -430,8 +453,7 @@ struct HttpResponse : public AsyncSocket { } } - writeMark(); - Super::write("\r\n", 2); + terminateHeaders(); return adoptWebSocket(std::forward(userData), perMessageDeflate, compressOptions, webSocketContext); } @@ -473,6 +495,12 @@ struct HttpResponse : public AsyncSocket { * that takes over the socket), and the cork stays so the handshake * batches with the first frames written from open(). */ auto* responseData = getHttpResponseData(); + + /* Bytes parked behind this handshake are frames from a client that didn't wait for the 101 (RFC 6455 4.1). + * Take them before markDone() requests a replay and before their owner is destructed. */ + WTF::Vector earlyFrames = std::exchange(responseData->parkedRequestBytes, {}); + size_t earlyFramesStart = std::exchange(responseData->parkedRequestBytesStart, 0); + responseData->state |= HttpResponseData::HTTP_END_CALLED; responseData->markDone(this); @@ -560,6 +588,20 @@ struct HttpResponse : public AsyncSocket { webSocketContextData->openHandler(webSocket); } + if (!earlyFrames.isEmpty()) [[unlikely]] { + /* Parking paused reads, and us_socket_adopt() keeps the pause. Resuming can close the socket, which is an ordinary WebSocket close by now. */ + us_socket_resume(usSocket); + if (!us_socket_is_closed(usSocket) && !us_socket_is_shut_down(usSocket)) { + /* The frame parser writes on both sides of its input, like it can in the loop's padded receive buffer. */ + size_t length = earlyFrames.size() - earlyFramesStart; + WTF::Vector padded; + padded.grow(LIBUS_RECV_BUFFER_PADDING + length + LIBUS_RECV_BUFFER_PADDING); + char *frames = padded.mutableSpan().data() + LIBUS_RECV_BUFFER_PADDING; + memcpy(frames, earlyFrames.span().data() + earlyFramesStart, length); + us_dispatch_data(usSocket, frames, (int) length); + } + } + /* Uncork here. An enclosing HttpResponse::cork() can't uncork a WebSocket, and after an await there isn't one. */ ((AsyncSocket *) webSocket)->uncork(); @@ -575,16 +617,22 @@ struct HttpResponse : public AsyncSocket { using Super::getRemoteAddressAsText; using Super::getNativeHandle; - /* Throttle reads and writes */ + /* Throttle reads and writes. The idle timeout keeps running, because a blocked response write has nothing else to end it. */ HttpResponse *pause() { Super::pause(); - Super::timeout(0); return this; } HttpResponse *resume() { - Super::resume(); + /* While requests are parked, the replay owns the pause. Reading on would queue more behind them. */ + if (getHttpResponseData()->parkedRequestBytes.isEmpty()) [[likely]] { + Super::resume(); + } this->resetTimeout(); + /* A parked request's connection reads again. A tunnel reports its own state (JSNodeHTTPServerSocket::updateTunnelIdle). */ + if (!getHttpResponseData()->isConnectRequest) { + setNodeHttpTunnelIdle(false); + } return this; } @@ -662,6 +710,7 @@ struct HttpResponse : public AsyncSocket { /* End the response with an optional data chunk. Always starts a timeout. */ void end(std::string_view data = {}, bool closeConnection = false) { HttpResponseData *httpResponseData = getHttpResponseData(); + markCloseConnection(closeConnection); /* 204/304 responses carry no body framing at all: no Content-Length, * no chunked framing and no terminating chunk, even when an explicit @@ -690,8 +739,7 @@ struct HttpResponse : public AsyncSocket { !(httpResponseData->state & (HttpResponseData::HTTP_WRITE_CALLED | HttpResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER | HttpResponseData::HTTP_ANCIENT_REQUEST | HttpResponseData::HTTP_NO_BODY_STATUS))) { writeStatus(HTTP_200_OK); - writeMark(); - Super::write("\r\n", 2); + terminateHeaders(); httpResponseData->state |= HttpResponseData::HTTP_WRITE_CALLED; } @@ -718,14 +766,9 @@ struct HttpResponse : public AsyncSocket { bool sendTerminatingChunk(bool closeConnection = false) { writeStatus(HTTP_200_OK); HttpResponseData *httpResponseData = getHttpResponseData(); + markCloseConnection(closeConnection); if (!(httpResponseData->state & (HttpResponseData::HTTP_WRITE_CALLED | HttpResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER))) { - /* Write mark on first call to write */ - writeMark(); - - if (!(httpResponseData->state & HttpResponseData::HTTP_WROTE_TRANSFER_ENCODING_HEADER)) { - writeHeader("Transfer-Encoding", "chunked"); - } - Super::write("\r\n", 2); + terminateHeaders(true); httpResponseData->state |= HttpResponseData::HTTP_WRITE_CALLED; } @@ -750,19 +793,12 @@ struct HttpResponse : public AsyncSocket { * header the user removed; their body is raw, so take the else path. */ if (!(httpResponseData->state & (HttpResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER | HttpResponseData::HTTP_ANCIENT_REQUEST | HttpResponseData::HTTP_CLOSE_DELIMITED | HttpResponseData::HTTP_NO_BODY_STATUS))) { if (!(httpResponseData->state & HttpResponseData::HTTP_WRITE_CALLED)) { - /* Write mark on first call to write */ - writeMark(); - - if (!(httpResponseData->state & HttpResponseData::HTTP_WROTE_TRANSFER_ENCODING_HEADER)) { - writeHeader("Transfer-Encoding", "chunked"); - } - Super::write("\r\n", 2); + terminateHeaders(true); httpResponseData->state |= HttpResponseData::HTTP_WRITE_CALLED; } } else if (!(httpResponseData->state & HttpResponseData::HTTP_WRITE_CALLED)) { - writeMark(); - Super::write("\r\n", 2); + terminateHeaders(); httpResponseData->state |= HttpResponseData::HTTP_WRITE_CALLED; } if (flushImmediately) { @@ -827,21 +863,14 @@ struct HttpResponse : public AsyncSocket { * write raw bytes with no chunk framing, like the else path. */ if (!(httpResponseData->state & (HttpResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER | HttpResponseData::HTTP_ANCIENT_REQUEST | HttpResponseData::HTTP_CLOSE_DELIMITED))) { if (!(httpResponseData->state & HttpResponseData::HTTP_WRITE_CALLED)) { - /* Write mark on first call to write */ - writeMark(); - - if (!(httpResponseData->state & HttpResponseData::HTTP_WROTE_TRANSFER_ENCODING_HEADER)) { - writeHeader("Transfer-Encoding", "chunked"); - } - Super::write("\r\n", 2); + terminateHeaders(true); httpResponseData->state |= HttpResponseData::HTTP_WRITE_CALLED; } chunkHeadLength = chunkHead(chunkHeadBuffer, (unsigned int) data.length()); chunkTail = isLast ? "\r\n0\r\n\r\n" : "\r\n"; } else if (!(httpResponseData->state & HttpResponseData::HTTP_WRITE_CALLED)) { - writeMark(); - Super::write("\r\n", 2); + terminateHeaders(); httpResponseData->state |= HttpResponseData::HTTP_WRITE_CALLED; } auto [written, failed] = Super::writeFramed({chunkHeadBuffer, chunkHeadLength}, data.data(), length, chunkTail); @@ -872,17 +901,12 @@ struct HttpResponse : public AsyncSocket { writeStatus(HTTP_200_OK); if (chunked) { if (!(httpResponseData->state & HttpResponseData::HTTP_WRITE_CALLED)) { - writeMark(); - if (!(httpResponseData->state & HttpResponseData::HTTP_WROTE_TRANSFER_ENCODING_HEADER)) { - writeHeader("Transfer-Encoding", "chunked"); - } - Super::write("\r\n", 2); + terminateHeaders(true); httpResponseData->state |= HttpResponseData::HTTP_WRITE_CALLED; } chunkHeadLength = chunkHead(chunkHeadBuffer, (unsigned int) data.length()); } else if (!(httpResponseData->state & HttpResponseData::HTTP_WRITE_CALLED)) { - writeMark(); - Super::write("\r\n", 2); + terminateHeaders(); httpResponseData->state |= HttpResponseData::HTTP_WRITE_CALLED; } } diff --git a/packages/bun-uws/src/HttpResponseData.h b/packages/bun-uws/src/HttpResponseData.h index bb1afd7e34ed..f02e3237d16b 100644 --- a/packages/bun-uws/src/HttpResponseData.h +++ b/packages/bun-uws/src/HttpResponseData.h @@ -61,8 +61,14 @@ struct HttpResponseData : AsyncSocketData, HttpParser { HttpResponseData *httpResponseData = uwsRes->getHttpResponseData(); /* A queued pipelined response (node:http) still owes output on this - * connection, so it is not idle between the responses. */ - httpResponseData->isIdle = httpResponseData->nodeHttpQueuedPipelinedCount == 0; + * connection, and a parked request (Bun.serve) is still owed a dispatch, + * so it is not idle between the responses. */ + httpResponseData->isIdle = httpResponseData->nodeHttpQueuedPipelinedCount == 0 && this->parkedRequestBytes.isEmpty(); + + /* HttpContext::onWritable replays the parked requests. Every caller of markDone() is still tearing this response down. */ + if (!this->parkedRequestBytes.isEmpty()) [[unlikely]] { + us_socket_request_writable((us_socket_t *) uwsRes); + } } /* Caller of onWritable. It is possible onWritable calls markDone so we need to borrow it. */ @@ -171,6 +177,18 @@ struct HttpResponseData : AsyncSocketData, HttpParser { HTTP_NODE_PEER_ENDED = 1 << 22, /* node:http socket.end() or destroySoon() ended the write side. Until the socket closes, the request in flight still gets its body. */ HTTP_NODE_HALF_CLOSED = 1 << 23, + /* Bun.serve is sending a file of known length itself (sendfile(), or read() and write()). uWS never holds + * the rest of the body, so onEnd needs this bit to let the response finish after the peer's FIN. */ + HTTP_FIXED_LENGTH_FILE_BODY = 1 << 24, + /* node:http: the JS socket has a 'data' or 'readable' listener, so it gets every read before the parser does. */ + HTTP_NODE_RAW_READS = 1 << 25, + /* node:http: socket.end() found outgoing bytes still queued. The FIN follows them, even if the response in flight never ends. */ + HTTP_NODE_SHUTDOWN_AFTER_DRAIN = 1 << 26, + /* The caller wrote this response's Connection header (a Response or route header, node:http's writeHead, the 101 of upgrade()), so uWS adds none. */ + HTTP_WROTE_CONNECTION_HEADER = 1 << 27, + /* The idle timer was armed while a write was blocked, and deliveredBytes holds us_socket_delivered_bytes() from that moment. + * Stays set after the write drains, so it only counts while the socket's last_write_failed is set. */ + HTTP_DELIVERED_BYTES_RECORDED = 1 << 28, /* Bits that describe the connection rather than the response in flight. * There is one HttpResponseData per socket, reused by every request on a @@ -182,7 +200,7 @@ struct HttpResponseData : AsyncSocketData, HttpParser { HTTP_CONNECTION_SCOPED = HTTP_NODE_PARSING_STOPPED | HTTP_NODE_READS_PAUSED | HTTP_NODE_TUNNEL_AFTER_BODY | HTTP_NODE_RECEIVED_FIN | HTTP_CLOSE_WHEN_IDLE | HTTP_NODE_CLOSE_AFTER_MESSAGE | HTTP_NODE_CLOSE_AFTER_DRAIN | HTTP_NODE_PEER_ENDED - | HTTP_NODE_HALF_CLOSED, + | HTTP_NODE_HALF_CLOSED | HTTP_NODE_RAW_READS | HTTP_NODE_SHUTDOWN_AFTER_DRAIN, }; /* Begin a new response on this connection. Clearing the word in one go is @@ -223,6 +241,8 @@ struct HttpResponseData : AsyncSocketData, HttpParser { OnTimeoutCallback onTimeout = nullptr; /* Outgoing offset */ uint64_t offset = 0; + /* Valid while HTTP_DELIVERED_BYTES_RECORDED is set. */ + uint64_t deliveredBytes = 0; /* Let's track number of bytes since last timeout reset in data handler */ unsigned int received_bytes_per_timeout = 0; @@ -251,11 +271,17 @@ struct HttpResponseData : AsyncSocketData, HttpParser { * flood). */ uint32_t nodeHttpQueuedPipelinedCount = 0; + /* Returns true if the response in flight is the last one on this connection. A graceful stop still answers a parked request. */ + bool closesAfterResponse() const { + return (state & HTTP_CONNECTION_CLOSE) || ((state & HTTP_CLOSE_WHEN_IDLE) && this->parkedRequestBytes.isEmpty()); + } + /* Whether the connection should be torn down once the in-flight response (if - * any) has completed and all buffered outgoing data has been flushed. */ + * any) has completed and all buffered outgoing data has been flushed. A peer + * that sent its FIN still gets answers to the requests it sent before it. */ bool shouldCloseConnection() const { return (state & (HTTP_CONNECTION_CLOSE | HTTP_NODE_CLOSE_AFTER_DRAIN)) - || ((state & HTTP_NODE_RECEIVED_FIN) && nodeHttpQueuedPipelinedCount == 0) + || ((state & HTTP_NODE_RECEIVED_FIN) && nodeHttpQueuedPipelinedCount == 0 && this->parkedRequestBytes.isEmpty()) || ((state & HTTP_CLOSE_WHEN_IDLE) && this->isIdle); } diff --git a/packages/bun-uws/src/HttpRouter.h b/packages/bun-uws/src/HttpRouter.h index 8a48cd6b8ea7..b1defba57b63 100644 --- a/packages/bun-uws/src/HttpRouter.h +++ b/packages/bun-uws/src/HttpRouter.h @@ -269,8 +269,8 @@ struct HttpRouter { } } - /* Always test any route last (this check should not be necessary if we always have at least one handler) */ - if (root.children.empty()) [[unlikely]] { + /* Always test any route last. remove() erases the any-method node while it's empty, and then back() is another method's tree. */ + if (root.children.empty() || root.children.back()->name != ANY_METHOD_TOKEN) [[unlikely]] { return false; } return executeHandlers(root.children.back().get(), 0, userData); diff --git a/packages/bun-uws/src/Utilities.h b/packages/bun-uws/src/Utilities.h index 39798cd21143..19e7a03dcdf9 100644 --- a/packages/bun-uws/src/Utilities.h +++ b/packages/bun-uws/src/Utilities.h @@ -34,6 +34,10 @@ static inline bool asciiIEquals(std::string_view a, const char *lower) { for (size_t i = 0; i < a.size(); i++) if ((a[i] | 0x20) != lower[i]) return false; return true; } +/* RFC 9110 §10.1.1: the Expect field value is case-insensitive. */ +static inline bool isExpect100Continue(std::string_view expect) { + return expect.size() == 12 && asciiIEquals(expect, "100-continue"); +} /* RFC 9110 §5.6.2 tchar. */ static inline bool isTokenByte(unsigned char c) { if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) return true; @@ -45,19 +49,27 @@ static inline bool isTokenByte(unsigned char c) { return false; } -/* RFC 9113 §8.3.1 / RFC 9114 §4.3.1 request-target rules shared by the h2 - * and h3 request validators: :method is a token; :path is origin-form (or - * "*" for OPTIONS) and carries no byte the HTTP/1 request line could not - * (controls, SP); CONNECT carries no :path; there is an authority, Host +/* RFC 9110 §9.1: method = token. */ +static inline bool isToken(std::string_view s) { + if (s.empty()) return false; + for (unsigned char c : s) if (!isTokenByte(c)) return false; + return true; +} + +/* RFC 9113 §8.3.1 / RFC 9114 §4.3.1: :path is origin-form (or "*" for OPTIONS) + * and carries no byte the HTTP/1 request line could not (controls, SP). + * CONNECT carries no :path. */ +static inline bool validPathForMethod(std::string_view method, std::string_view path) { + if (method == "CONNECT") return path.empty(); + if (!(path.size() && path[0] == '/') && !(path == "*" && method == "OPTIONS")) return false; + for (unsigned char c : path) if (c <= 0x20) return false; + return true; +} + +/* HTTP/2 request-target rules: the two above, there is an authority, Host * doesn't contradict :authority, and :authority has no userinfo. */ static inline bool validPseudoHeaderTarget(std::string_view method, std::string_view path, std::string_view authority, std::string_view host) { - if (method.empty()) return false; - for (unsigned char c : method) if (!isTokenByte(c)) return false; - bool isConnect = method == "CONNECT"; - if (!isConnect) { - if (!(path.size() && path[0] == '/') && !(path == "*" && method == "OPTIONS")) return false; - for (unsigned char c : path) if (c <= 0x20) return false; - } + if (!isToken(method) || !validPathForMethod(method, path)) return false; if (authority.empty() && host.empty()) return false; if (!authority.empty() && !host.empty() && authority != host) return false; if (authority.find('@') != std::string_view::npos) return false; diff --git a/patches/lsqpack/accept-empty-field-section.patch b/patches/lsqpack/accept-empty-field-section.patch new file mode 100644 index 000000000000..0fa81d4c0044 --- /dev/null +++ b/patches/lsqpack/accept-empty-field-section.patch @@ -0,0 +1,14 @@ +--- a/lsqpack.c ++++ b/lsqpack.c +@@ -4046,7 +4046,10 @@ parse_header_prefix (struct lsqpack_dec *dec, + read_ctx->hbrc_parse = parse_header_data; + read_ctx->hbrc_parse_ctx_u.data.state + = DATA_STATE_NEXT_INSTRUCTION; +- if (end - buf) ++ /* bun: a field section can end at its prefix (RFC 9204 Section ++ * 4.5). qdec_read_header() turns LQRHS_NEED into LQRHS_ERROR ++ * there, so let parse_header_data() finish the block. */ ++ if (end - buf || read_ctx->hbrc_size == 0) + return parse_header_data(dec, read_ctx, buf, end - buf); + else + return LQRHS_NEED; diff --git a/patches/lsquic/abort-error-code-zero.patch b/patches/lsquic/abort-error-code-zero.patch new file mode 100644 index 000000000000..275a32cf4828 --- /dev/null +++ b/patches/lsquic/abort-error-code-zero.patch @@ -0,0 +1,39 @@ +--- a/src/liblsquic/lsquic_full_conn_ietf.c ++++ b/src/liblsquic/lsquic_full_conn_ietf.c +@@ -261,7 +261,7 @@ enum send_flags + ABORT_WITH_FLAG(conn, LSQ_LOG_WARN, IFC_ERROR, __VA_ARGS__) + + #define CONN_ERR(app_error_, code_) (struct conn_err) { \ +- .app_error = (app_error_), .u.err = (code_), } ++ .app_error = (app_error_), .recorded = 1, .u.err = (code_), } + + /* Use this for protocol errors; they do not need to be as loud as our own + * internal errors. +@@ -294,6 +294,9 @@ struct http_ctl_stream_in + struct conn_err + { + int app_error; ++ /* bun: set by CONN_ERR. Code 0 is a valid code, so "u.err != 0" can't ++ * mean that an error is recorded. */ ++ int recorded; + union + { + enum trans_error_code tec; +@@ -4235,7 +4238,7 @@ immediate_close (struct ietf_full_conn *conn) + conn_err = CONN_ERR(0, TEC_APPLICATION_ERROR); + error_reason = NULL; + } +- else if (conn->ifc_error.u.err != 0) ++ else if (conn->ifc_error.recorded) + { + conn_err = conn->ifc_error; + error_reason = conn->ifc_errmsg; +@@ -9027,7 +9030,7 @@ ietf_full_conn_ci_abort_error (struct lsquic_conn *lconn, int is_app, + const char *err_str, *percent; + char err_buf[0x100]; + +- if (conn->ifc_error.u.err != 0) ++ if (conn->ifc_error.recorded) + return; + percent = strchr(fmt, '%'); + if (percent) diff --git a/patches/lsquic/cubic-llp64-overflow.patch b/patches/lsquic/cubic-llp64-overflow.patch new file mode 100644 index 000000000000..30295a758419 --- /dev/null +++ b/patches/lsquic/cubic-llp64-overflow.patch @@ -0,0 +1,25 @@ +--- a/src/liblsquic/lsquic_cubic.c ++++ b/src/liblsquic/lsquic_cubic.c +@@ -205,10 +205,11 @@ + LSQ_DEBUG("%s(cubic)", __func__); + cubic->cu_epoch_start = 0; + if (FAST_CONVERGENCE && cubic->cu_cwnd < cubic->cu_last_max_cwnd) +- cubic->cu_last_max_cwnd = cubic->cu_cwnd * TWO_MINUS_BETA_OVER_TWO / 1024; ++ cubic->cu_last_max_cwnd = (uint64_t) cubic->cu_cwnd ++ * TWO_MINUS_BETA_OVER_TWO / 1024; + else + cubic->cu_last_max_cwnd = cubic->cu_cwnd; +- cubic->cu_cwnd = cubic->cu_cwnd * ONE_MINUS_BETA / 1024; ++ cubic->cu_cwnd = (uint64_t) cubic->cu_cwnd * ONE_MINUS_BETA / 1024; + cubic->cu_tcp_cwnd = cubic->cu_cwnd; + cubic->cu_ssthresh = cubic->cu_cwnd; + LSQ_INFO("loss detected, last_max_cwnd: %lu, cwnd: %lu", +@@ -266,7 +267,7 @@ + srtt = lsquic_rtt_stats_get_srtt(cubic->cu_rtt_stats); + if (srtt == 0) + srtt = 50000; +- bandwidth = cubic->cu_cwnd * 1000000 / srtt; ++ bandwidth = (uint64_t) cubic->cu_cwnd * 1000000 / srtt; + if (in_slow_start(cubic)) + pacing_rate = bandwidth * 2; + else if (in_recovery) diff --git a/patches/lsquic/datagram-ids.patch b/patches/lsquic/datagram-ids.patch new file mode 100644 index 000000000000..a377a7370460 --- /dev/null +++ b/patches/lsquic/datagram-ids.patch @@ -0,0 +1,236 @@ +--- a/include/lsquic.h ++++ b/include/lsquic.h +@@ -257,12 +257,14 @@ struct lsquic_stream_if { + const struct sockaddr *peer_sa, + uint64_t error_code); + /** +- * Optional callback fired when a packet carrying DATAGRAM frames is +- * acknowledged by the peer (`acked` is 1) or declared lost (`acked` is +- * 0). `count` is the number of DATAGRAM frames the packet carried. +- * bun addition. ++ * Optional callback fired once for each DATAGRAM frame when its packet ++ * is acknowledged, when its packet is declared lost, or when the frame ++ * is dropped before its packet is sent. `dgram_id` is the value that ++ * on_dg_write gave lsquic_conn_set_datagram_id() for that frame (0 if it ++ * gave none). `status` is one of enum lsquic_dg_status. bun addition. + */ +- void (*on_datagram_status)(lsquic_conn_t *c, unsigned count, int acked); ++ void (*on_datagram_status)(lsquic_conn_t *c, uint64_t dgram_id, ++ int status); + /** + * Optional callback fired on the client the moment the server rejects + * its early data (before any of the 0-RTT streams are torn down). +@@ -2269,6 +2271,25 @@ lsquic_engine_set_idle_timeout_ms (lsquic_engine_t *, unsigned ms); + int + lsquic_conn_datagram_early (const lsquic_conn_t *); + ++/** ++ * Name the DATAGRAM frame that the on_dg_write callback is writing. Valid ++ * only during that callback. The id stays with the frame and comes back in ++ * on_datagram_status. ++ */ ++void ++lsquic_conn_set_datagram_id (lsquic_conn_t *, uint64_t dgram_id); ++ ++/** What became of a DATAGRAM frame (see on_datagram_status). */ ++enum lsquic_dg_status ++{ ++ /** The peer acknowledged the packet that carried the frame. */ ++ LSQ_DG_ACKED, ++ /** The packet that carried the frame was sent and declared lost. */ ++ LSQ_DG_LOST, ++ /** The frame was dropped before its packet was sent. */ ++ LSQ_DG_UNSENT, ++}; ++ + /** + * Abort the connection without sending CONNECTION_CLOSE. The peer + * discovers the death via stateless reset or idle timeout. +--- a/src/liblsquic/lsquic_full_conn_ietf.c ++++ b/src/liblsquic/lsquic_full_conn_ietf.c +@@ -534,12 +534,16 @@ struct ietf_full_conn + * (calloc'd conns default to not set, falling back to engine settings). + */ + lsquic_time_t ifc_ping_us_override; +- int ifc_ping_us_override_set; ++ /* bun: the id on_dg_write gave the DATAGRAM frame that write_datagram is ++ * generating. The three flags below are one byte each so that this ++ * field doesn't grow the struct. */ ++ uint64_t ifc_dg_id; ++ unsigned char ifc_ping_us_override_set; + /* bun: the DATAGRAM frame currently being delivered arrived in a 0-RTT + * packet (valid only during the on_datagram callback). */ +- int ifc_datagram_early; ++ unsigned char ifc_datagram_early; + /* bun: lsquic_conn_abort_silent — die without a CONNECTION_CLOSE. */ +- int ifc_abort_silent; ++ unsigned char ifc_abort_silent; + /* bun: PING frames received, for session stats. */ + uint64_t ifc_pings_in; + lsquic_time_t ifc_last_tick; +@@ -6963,6 +6967,20 @@ lsquic_conn_datagram_early (const struct lsquic_conn *lconn) + } + + ++/* bun: write_datagram keeps the id in the frame record, so it stays with the ++ * frame whatever happens to its packet. */ ++void ++lsquic_conn_set_datagram_id (struct lsquic_conn *lconn, uint64_t dgram_id) ++{ ++ struct ietf_full_conn *conn; ++ ++ if (!(lconn->cn_flags & LSCONN_IETF) || (lconn->cn_flags & LSCONN_MINI)) ++ return; ++ conn = (struct ietf_full_conn *) lconn; ++ conn->ifc_dg_id = dgram_id; ++} ++ ++ + typedef unsigned (*process_frame_f)( + struct ietf_full_conn *, struct lsquic_packet_in *, + const unsigned char *p, size_t); +@@ -8499,6 +8517,10 @@ write_datagram (struct ietf_full_conn *conn) + if (!(conn->ifc_conn.cn_flags & LSCONN_HANDSHAKE_DONE)) + packet_out->po_header_type = HETY_0RTT; + ++ /* bun */ ++ _Static_assert(sizeof(uintptr_t) >= sizeof(conn->ifc_dg_id), ++ "a frame record holds a whole datagram id"); ++ conn->ifc_dg_id = 0; + w = conn->ifc_conn.cn_pf->pf_gen_datagram_frame( + packet_out->po_data + packet_out->po_data_sz, + lsquic_packet_out_avail(packet_out), conn->ifc_min_dg_sz, +@@ -8509,8 +8531,8 @@ write_datagram (struct ietf_full_conn *conn) + LSQ_DEBUG("could not generate DATAGRAM frame"); + return 0; + } +- if (0 != lsquic_packet_out_add_frame(packet_out, conn->ifc_pub.mm, 0, +- QUIC_FRAME_DATAGRAM, packet_out->po_data_sz, w)) ++ if (0 != lsquic_packet_out_add_frame(packet_out, conn->ifc_pub.mm, ++ conn->ifc_dg_id, QUIC_FRAME_DATAGRAM, packet_out->po_data_sz, w)) + { + ABORT_ERROR("adding DATAGRAME frame to packet failed: %d", errno); + return 0; +--- a/src/liblsquic/lsquic_packet_out.c ++++ b/src/liblsquic/lsquic_packet_out.c +@@ -401,20 +401,6 @@ lsquic_packet_out_ack_streams (lsquic_packet_out_t *packet_out) + } + + +-unsigned +-lsquic_packet_out_count_datagram_frames (lsquic_packet_out_t *packet_out) +-{ +- struct packet_out_frec_iter pofi; +- struct frame_rec *frec; +- unsigned count = 0; +- for (frec = lsquic_pofi_first(&pofi, packet_out); frec; +- frec = lsquic_pofi_next(&pofi)) +- if (QUIC_FRAME_DATAGRAM == frec->fe_frame_type) +- ++count; +- return count; +-} +- +- + void + lsquic_packet_out_zero_pad (lsquic_packet_out_t *packet_out) + { +--- a/src/liblsquic/lsquic_packet_out.h ++++ b/src/liblsquic/lsquic_packet_out.h +@@ -376,9 +376,6 @@ lsquic_pofi_first (struct packet_out_frec_iter *pofi, lsquic_packet_out_t *); + struct frame_rec * + lsquic_pofi_next (struct packet_out_frec_iter *pofi); + +-unsigned +-lsquic_packet_out_count_datagram_frames (lsquic_packet_out_t *); +- + lsquic_packet_out_t * + lsquic_packet_out_new (struct lsquic_mm *, struct malo *, int use_cid, + const struct lsquic_conn *, enum packno_bits, +--- a/src/liblsquic/lsquic_send_ctl.c ++++ b/src/liblsquic/lsquic_send_ctl.c +@@ -1164,6 +1164,30 @@ send_ctl_record_loss (struct lsquic_send_ctl *ctl, + } + + ++/* bun: report each DATAGRAM frame in the packet under the id that ++ * write_datagram stored in its frame record. DATAGRAM frames are never ++ * retransmitted (RFC 9221 Section 5), so each caller is the last place that ++ * can name them. ++ */ ++static void ++send_ctl_report_datagrams (struct lsquic_send_ctl *ctl, ++ struct lsquic_packet_out *packet_out, enum lsquic_dg_status status) ++{ ++ struct packet_out_frec_iter pofi; ++ struct frame_rec *frec; ++ ++ if (!(packet_out->po_frame_types & QUIC_FTBIT_DATAGRAM) ++ || !ctl->sc_enpub->enp_stream_if->on_datagram_status) ++ return; ++ ++ for (frec = lsquic_pofi_first(&pofi, packet_out); frec; ++ frec = lsquic_pofi_next(&pofi)) ++ if (QUIC_FRAME_DATAGRAM == frec->fe_frame_type) ++ ctl->sc_enpub->enp_stream_if->on_datagram_status( ++ ctl->sc_conn_pub->lconn, frec->fe_u.data, status); ++} ++ ++ + static struct lsquic_packet_out * + send_ctl_handle_regular_lost_packet (struct lsquic_send_ctl *ctl, + lsquic_packet_out_t *packet_out, struct lsquic_packet_out **next) +@@ -1174,15 +1198,9 @@ send_ctl_handle_regular_lost_packet (struct lsquic_send_ctl *ctl, + assert(ctl->sc_n_in_flight_all); + packet_sz = packet_out_sent_sz(packet_out); + +- /* DATAGRAM frames are never retransmitted (RFC 9221 Section 5): even if +- * the packet's other frames are rescheduled, the datagrams it carried +- * are gone. Report them as lost. +- */ +- if ((packet_out->po_frame_types & QUIC_FTBIT_DATAGRAM) +- && ctl->sc_enpub->enp_stream_if->on_datagram_status) +- ctl->sc_enpub->enp_stream_if->on_datagram_status( +- ctl->sc_conn_pub->lconn, +- lsquic_packet_out_count_datagram_frames(packet_out), 0); ++ /* bun: even if the packet's other frames are rescheduled, the datagrams ++ * it carried are gone. */ ++ send_ctl_report_datagrams(ctl, packet_out, LSQ_DG_LOST); + + ++ctl->sc_loss_count; + #if LSQUIC_CONN_STATS +@@ -1517,12 +1535,7 @@ lsquic_send_ctl_got_ack (lsquic_send_ctl_t *ctl, + packet_sz = packet_out_sent_sz(packet_out); + send_ctl_unacked_remove(ctl, packet_out, packet_sz); + lsquic_packet_out_ack_streams(packet_out); +- if ((packet_out->po_frame_types & QUIC_FTBIT_DATAGRAM) +- && ctl->sc_enpub->enp_stream_if->on_datagram_status) +- ctl->sc_enpub->enp_stream_if->on_datagram_status( +- ctl->sc_conn_pub->lconn, +- lsquic_packet_out_count_datagram_frames(packet_out), +- 1); ++ send_ctl_report_datagrams(ctl, packet_out, LSQ_DG_ACKED); + LSQ_DEBUG("acking via regular record #%"PRIu64, + packet_out->po_packno); + } +@@ -2304,6 +2317,10 @@ lsquic_send_ctl_next_packet_to_send (struct lsquic_send_ctl *ctl, + + if (packet_out->po_flags & PO_REPACKNO) + { ++ /* bun: a scheduled packet that gets a new number loses its DATAGRAM ++ * frames: it is dropped below, or update_for_resending chops them ++ * out with the other regenerated frames. */ ++ send_ctl_report_datagrams(ctl, packet_out, LSQ_DG_UNSENT); + if (packet_out->po_regen_sz < packet_out->po_data_sz + && packet_out->po_frame_types != QUIC_FTBIT_PADDING) + { +@@ -2893,6 +2910,10 @@ lsquic_send_ctl_squeeze_sched (lsquic_send_ctl_t *ctl) + send_ctl_sched_remove(ctl, packet_out); + LSQ_DEBUG("Dropping packet #%"PRIu64" from scheduled queue", + packet_out->po_packno); ++ /* bun: a delayed packet that holds nothing but regenerated ++ * frames is not sent later, and a DATAGRAM frame counts as ++ * one (write_datagram adds it to po_regen_sz). */ ++ send_ctl_report_datagrams(ctl, packet_out, LSQ_DG_UNSENT); + send_ctl_destroy_chain(ctl, packet_out, NULL); + send_ctl_destroy_packet(ctl, packet_out); + ++dropped; diff --git a/patches/lsquic/delayed-uni-streams.patch b/patches/lsquic/delayed-uni-streams.patch new file mode 100644 index 000000000000..acf5cf7d7203 --- /dev/null +++ b/patches/lsquic/delayed-uni-streams.patch @@ -0,0 +1,85 @@ +--- a/src/liblsquic/lsquic_full_conn_ietf.c ++++ b/src/liblsquic/lsquic_full_conn_ietf.c +@@ -2778,16 +2778,28 @@ maybe_create_delayed_streams (struct ietf_full_conn *conn) + } + + +-/* bun: drain locally-initiated uni streams once MAX_STREAMS(uni) credit +- * arrives (counterpart of maybe_create_delayed_streams for SD_UNI). */ + static int + handshake_done_or_doing_sess_resume (const struct ietf_full_conn *); + ++/* bun: until this is true, lsquic_conn_make_uni_stream() counts the request in ++ * ifc_n_delayed_uni_streams, as ifc_n_delayed_streams does for bidi. */ ++static int ++can_create_uni_stream (const struct ietf_full_conn *conn) ++{ ++ return handshake_done_or_doing_sess_resume(conn) ++ /* HTTP mode: on_settings_frame() creates the QPACK encoder stream. A ++ * stream created before that takes its stream ID, and its credit when ++ * the peer only allows the three HTTP/3 streams. */ ++ && (conn->ifc_flags & (IFC_HTTP|IFC_HAVE_PEER_SET)) != IFC_HTTP ++ && avail_streams_count(conn, conn->ifc_flags & IFC_SERVER, SD_UNI) > 0; ++} ++ ++ ++/* bun: counterpart of maybe_create_delayed_streams for SD_UNI. */ + static void + maybe_create_delayed_uni_streams (struct ietf_full_conn *conn) + { +- while (conn->ifc_n_delayed_uni_streams +- && avail_streams_count(conn, conn->ifc_flags & IFC_SERVER, SD_UNI)) ++ while (conn->ifc_n_delayed_uni_streams && can_create_uni_stream(conn)) + { + if (0 != create_uni_stream_out(conn, LSQUIC_STREAM_DEFAULT_PRIO, + conn->ifc_enpub->enp_stream_if, +@@ -8980,24 +8992,15 @@ ietf_full_conn_ci_make_stream (struct lsquic_conn *lconn) + + /* Bun patch: locally initiate a unidirectional stream using the engine's + * stream_if (raw QUIC, no HTTP). Upstream lsquic_conn_make_stream is +- * bidi-only; node:quic's createUnidirectionalStream needs this. Unlike the +- * bidi path there is no delayed-stream queue for uni: the caller is +- * expected to wait for the handshake. */ ++ * bidi-only; node:quic's createUnidirectionalStream needs this. The stream ++ * is created now, or the request is counted and the stream is created later. ++ * A connection that is going away doesn't refuse it: GOAWAY only limits ++ * requests (RFC 9114, Section 5.2). */ + void + lsquic_conn_make_uni_stream (struct lsquic_conn *lconn) + { + struct ietf_full_conn *const conn = (struct ietf_full_conn *) lconn; +- if (!handshake_done_or_doing_sess_resume(conn) +- || either_side_going_away(conn)) +- { +- (void) conn->ifc_enpub->enp_stream_if->on_new_stream( +- conn->ifc_enpub->enp_stream_if_ctx, NULL); +- return; +- } +- /* Honor the peer's MAX_STREAMS(uni) credit: delay creation (the +- * caller's stream stays pending) until credit arrives, mirroring the +- * bidi delayed-stream queue. */ +- if (0 == avail_streams_count(conn, conn->ifc_flags & IFC_SERVER, SD_UNI)) ++ if (!can_create_uni_stream(conn)) + { + ++conn->ifc_n_delayed_uni_streams; + LSQ_DEBUG("delayed uni stream creation. Backlog size: %u", +@@ -9011,6 +9014,19 @@ lsquic_conn_make_uni_stream (struct lsquic_conn *lconn) + } + + ++/* bun: lsquic_conn_cancel_pending_streams() for uni stream requests. */ ++unsigned ++lsquic_conn_cancel_pending_uni_streams (struct lsquic_conn *lconn, unsigned n) ++{ ++ struct ietf_full_conn *const conn = (struct ietf_full_conn *) lconn; ++ if (n > conn->ifc_n_delayed_uni_streams) ++ conn->ifc_n_delayed_uni_streams = 0; ++ else ++ conn->ifc_n_delayed_uni_streams -= n; ++ return conn->ifc_n_delayed_uni_streams; ++} ++ ++ + static void + ietf_full_conn_ci_internal_error (struct lsquic_conn *lconn, + const char *format, ...) diff --git a/patches/lsquic/going-away-tickable.patch b/patches/lsquic/going-away-tickable.patch new file mode 100644 index 000000000000..543e13687db0 --- /dev/null +++ b/patches/lsquic/going-away-tickable.patch @@ -0,0 +1,14 @@ +--- a/src/liblsquic/lsquic_full_conn_ietf.c ++++ b/src/liblsquic/lsquic_full_conn_ietf.c +@@ -3387,6 +3387,11 @@ ietf_full_conn_ci_going_away (struct lsq + end: + conn->ifc_flags |= IFC_GOING_AWAY; + maybe_close_conn(conn); ++ /* bun: a server connection that never carried a request skips the GOAWAY ++ * write above, which was the only call that made the connection tickable. ++ * maybe_close_conn() just scheduled its CONNECTION_CLOSE, and only a tick ++ * sends that. */ ++ lsquic_engine_add_conn_to_tickable(conn->ifc_enpub, lconn); + } + + diff --git a/patches/lsquic/hold-stream-window.patch b/patches/lsquic/hold-stream-window.patch new file mode 100644 index 000000000000..642189c56fe1 --- /dev/null +++ b/patches/lsquic/hold-stream-window.patch @@ -0,0 +1,69 @@ +--- a/include/lsquic.h ++++ b/include/lsquic.h +@@ -1758,6 +1758,20 @@ int + lsquic_stream_wantread (lsquic_stream_t *s, int is_want); + + /** ++ * bun: receive backpressure that leaves no data in the stream. While the ++ * window is held, reads still credit the connection's window, but the stream's ++ * receive limit stays put and the peer gets no new MAX_STREAM_DATA. An update ++ * queued before the hold still goes out, so the peer can send up to one window ++ * past the read offset. Releasing the hold queues the update it held back, ++ * unless the stream was reset or read to its end. ++ * ++ * With lsquic_stream_wantread(s, 0) the unread data also uses up the ++ * connection's window, and it is dropped when the connection goes away. ++ */ ++void ++lsquic_stream_hold_window (lsquic_stream_t *s, int hold); ++ ++/** + * Read up to @param len bytes from stream into @param buf. Returns number + * of bytes read or -1 on error, in which case errno is set. Possible + * errno values: +--- a/src/liblsquic/lsquic_sfcw.c ++++ b/src/liblsquic/lsquic_sfcw.c +@@ -94,6 +94,10 @@ lsquic_sfcw_fc_offsets_changed (struct l + { + lsquic_time_t since_last_update, srtt, now; + ++ /* bun: lsquic_stream_hold_window() */ ++ if (fc->sf_hold) ++ return 0; ++ + if (fc->sf_recv_off - fc->sf_read_off >= fc->sf_max_recv_win / 2) + { + LSQ_DEBUG("recv_off has not changed, still at %"PRIu64, +--- a/src/liblsquic/lsquic_sfcw.h ++++ b/src/liblsquic/lsquic_sfcw.h +@@ -20,6 +20,7 @@ typedef struct lsquic_sfcw { + struct lsquic_conn_public + *sf_conn_pub; + unsigned sf_max_recv_win; /* Maximum receive window */ ++ unsigned sf_hold; /* bun: keep sf_recv_off where it is */ + lsquic_stream_id_t sf_stream_id; /* Used for logging */ + } lsquic_sfcw_t; + +--- a/src/liblsquic/lsquic_stream.c ++++ b/src/liblsquic/lsquic_stream.c +@@ -1530,6 +1530,20 @@ stream_consumed_bytes (struct lsquic_str + } + + ++/* bun: see lsquic.h */ ++void ++lsquic_stream_hold_window (struct lsquic_stream *stream, int hold) ++{ ++ if (stream->fc.sf_hold == (unsigned) !!hold) ++ return; ++ stream->fc.sf_hold = !!hold; ++ /* After a reset, sf_read_off is the final size, not read_offset. An update ++ * would take the credit for the unread bytes back from the connection. */ ++ if (!hold && !(stream->stream_flags & (STREAM_RST_RECVD|STREAM_U_READ_DONE))) ++ stream_consumed_bytes(stream); ++} ++ ++ + static ssize_t + read_data_frames (struct lsquic_stream *stream, int do_filtering, + size_t (*readf)(void *, const unsigned char *, size_t, int), void *ctx) diff --git a/patches/lsquic/local-transport-error.patch b/patches/lsquic/local-transport-error.patch new file mode 100644 index 000000000000..fd24e6dcaecd --- /dev/null +++ b/patches/lsquic/local-transport-error.patch @@ -0,0 +1,45 @@ +--- a/include/lsquic.h ++++ b/include/lsquic.h +@@ -2648,6 +2648,17 @@ enum LSQUIC_CONN_STATUS + enum LSQUIC_CONN_STATUS + lsquic_conn_status (lsquic_conn_t *, char *errbuf, size_t bufsz); + ++/** ++ * bun: the transport error code this side recorded for its CONNECTION_CLOSE ++ * frame. lsquic_conn_status() has no code, and it reports an error that fails ++ * the handshake (a TLS alert, say) as LSCONN_ST_VERNEG_FAILURE. ++ * ++ * @retval 1 `code' is set. ++ * @retval 0 No error is recorded, or it's an application error. ++ */ ++int ++lsquic_conn_local_transport_error (const lsquic_conn_t *, uint64_t *code); ++ + extern const char *const + lsquic_ver2str[N_LSQVER]; + +--- a/src/liblsquic/lsquic_full_conn_ietf.c ++++ b/src/liblsquic/lsquic_full_conn_ietf.c +@@ -8980,6 +8980,22 @@ ietf_full_conn_ci_status (struct lsquic_conn *lconn, char *errbuf, size_t bufsz) + } + + ++/* bun: see lsquic.h */ ++int ++lsquic_conn_local_transport_error (const struct lsquic_conn *lconn, ++ uint64_t *code) ++{ ++ const struct ietf_full_conn *const conn ++ = (const struct ietf_full_conn *) lconn; ++ ++ if ((lconn->cn_flags & (LSCONN_MINI|LSCONN_IETF)) != LSCONN_IETF ++ || !conn->ifc_error.recorded || conn->ifc_error.app_error) ++ return 0; ++ *code = conn->ifc_error.u.err; ++ return 1; ++} ++ ++ + static void + ietf_full_conn_ci_stateless_reset (struct lsquic_conn *lconn) + { diff --git a/patches/lsquic/merge-acks-contained-range.patch b/patches/lsquic/merge-acks-contained-range.patch new file mode 100644 index 000000000000..247e07a509f7 --- /dev/null +++ b/patches/lsquic/merge-acks-contained-range.patch @@ -0,0 +1,16 @@ +--- a/src/liblsquic/lsquic_parse_gquic_common.c ++++ b/src/liblsquic/lsquic_parse_gquic_common.c +@@ -680,7 +680,12 @@ lsquic_merge_acks (struct ack_info *dst, const struct ack_info *src) + } + + if ((*p)->high + 1 >= out->low) +- out->low = (*p)->low; ++ { ++ /* bun: one frame's range can lie inside the other's, as [15-12] ++ * does in [16-8]. Only a lower end extends `out'. */ ++ if ((*p)->low < out->low) ++ out->low = (*p)->low; ++ } + else if (out + 1 < out_end) + *++out = **p; + else diff --git a/patches/lsquic/mini-conn-ctx.patch b/patches/lsquic/mini-conn-ctx.patch new file mode 100644 index 000000000000..03f6865e2963 --- /dev/null +++ b/patches/lsquic/mini-conn-ctx.patch @@ -0,0 +1,170 @@ +--- a/include/lsquic.h ++++ b/include/lsquic.h +@@ -249,13 +249,27 @@ struct lsquic_stream_if { + */ + void (*on_hsk_confirmed)(lsquic_conn_t *c); + /** +- * Optional callback fired when a server mini connection is destroyed +- * without promoting (handshake failure or client abandonment). The +- * peer address identifies which connection attempt died. bun addition. +- */ +- void (*on_mini_conn_failed)(void *stream_if_ctx, +- const struct sockaddr *peer_sa, +- uint64_t error_code); ++ * Optional callback fired when a server mini connection is created for ++ * a first packet, before that packet is processed and so before the TLS ++ * handshake starts. The return value becomes the connection's context: ++ * lsquic_conn_get_ctx() returns it during the handshake (in the TLS ++ * callbacks), on_new_conn() finds it on the promoted connection, and ++ * on_mini_conn_failed() receives it if the handshake dies first. The ++ * callback may refuse the connection with lsquic_conn_abort_error(). ++ * The peer's next first packet is then offered again. After the ++ * callback returns, only lsquic_conn_get_ctx(), lsquic_conn_set_ctx() ++ * and lsquic_conn_get_sockaddr() are valid on the mini connection. bun ++ * addition. ++ */ ++ lsquic_conn_ctx_t *(*on_new_mini_conn)(void *stream_if_ctx, ++ lsquic_conn_t *c); ++ /** ++ * Optional callback fired when a server mini connection that has a ++ * context is destroyed without promoting (handshake failure or client ++ * abandonment). The context is already off the connection. bun ++ * addition. ++ */ ++ void (*on_mini_conn_failed)(lsquic_conn_ctx_t *ctx, uint64_t error_code); + /** + * Optional callback fired once for each DATAGRAM frame when its packet + * is acknowledged, when its packet is declared lost, or when the frame +@@ -1313,6 +1327,13 @@ struct lsquic_engine_settings { + * maxIdleTimeout transport parameter is specified in milliseconds). + */ + unsigned es_idle_timeout_ms; ++ ++ /** ++ * bun: when nonzero, @ref es_handshake_to bounds the whole handshake of ++ * a server connection. By default a mini connection that has read a ++ * Handshake packet may live until the idle timeout. ++ */ ++ int es_handshake_to_strict; + }; + + /* Initialize `settings' to default values */ +--- a/src/liblsquic/lsquic_engine.c ++++ b/src/liblsquic/lsquic_engine.c +@@ -986,14 +986,15 @@ destroy_conn (struct lsquic_engine *engine, struct lsquic_conn *conn, + + engine->mini_conns_count -= !!(conn->cn_flags & LSCONN_MINI); + /* bun: a server mini-conn dying WITHOUT promotion means the handshake +- * failed or the client abandoned it; tell the application (which may +- * have announced a session at Initial receipt) so it isn't orphaned. ++ * failed or the client abandoned it; hand its context back to the ++ * application, so that it isn't orphaned. + * The error code mirrors imico_generate_conn_close's choice. */ + if ((conn->cn_flags & (LSCONN_MINI|LSCONN_PROMOTED)) == LSCONN_MINI + && (conn->cn_flags & LSCONN_IETF) ++ && conn->cn_conn_ctx + && engine->pub.enp_stream_if->on_mini_conn_failed) + { +- const struct sockaddr *local_sa, *peer_sa; ++ lsquic_conn_ctx_t *const conn_ctx = conn->cn_conn_ctx; + const struct ietf_mini_conn *imc = (struct ietf_mini_conn *) conn; + uint64_t error_code; + if (imc->imc_flags & IMC_ABORT_ERROR) +@@ -1010,9 +1011,8 @@ destroy_conn (struct lsquic_engine *engine, struct lsquic_conn *conn, + error_code = (1ULL << 63) | imc->imc_close_code; + else + error_code = TEC_NO_ERROR; +- if (0 == lsquic_conn_get_sockaddr(conn, &local_sa, &peer_sa)) +- engine->pub.enp_stream_if->on_mini_conn_failed( +- engine->pub.enp_stream_if_ctx, peer_sa, error_code); ++ conn->cn_conn_ctx = NULL; ++ engine->pub.enp_stream_if->on_mini_conn_failed(conn_ctx, error_code); + } + if (engine->purga && !(conn->cn_flags & LSCONN_NO_BL) + /* Blacklist all CIDs except for promoted mini connections */ +@@ -1497,12 +1497,14 @@ lsquic_engine_sreset_stats (const lsquic_engine_t *engine, + static lsquic_conn_t * + find_or_create_conn (lsquic_engine_t *engine, lsquic_packet_in_t *packet_in, + struct packin_parse_state *ppstate, const struct sockaddr *sa_local, +- const struct sockaddr *sa_peer, void *peer_ctx, size_t packet_in_size) ++ const struct sockaddr *sa_peer, void *peer_ctx, size_t packet_in_size, ++ int *created) + { + struct lsquic_hash_elem *el; + struct purga_el *puel; + lsquic_conn_t *conn; + ++ *created = 0; + if (!(packet_in->pi_flags & PI_CONN_ID)) + { + LSQ_DEBUG("packet header does not have connection ID: discarding"); +@@ -1693,6 +1695,7 @@ find_or_create_conn (lsquic_engine_t *engine, lsquic_packet_in_t *packet_in, + conn->cn_flags |= LSCONN_HASHED; + eng_hist_inc(&engine->history, packet_in->pi_received, sl_new_mini_conns); + conn->cn_last_sent = engine->last_sent; ++ *created = 1; + return conn; + } + +@@ -1827,6 +1830,7 @@ process_packet_in (lsquic_engine_t *engine, lsquic_packet_in_t *packet_in, + { + lsquic_conn_t *conn; + const unsigned char *packet_in_data; ++ int created = 0; + + if (lsquic_packet_in_is_gquic_prst(packet_in) + && !engine->pub.enp_settings.es_honor_prst) +@@ -1839,7 +1843,7 @@ process_packet_in (lsquic_engine_t *engine, lsquic_packet_in_t *packet_in, + if (engine->flags & ENG_SERVER) + { + conn = find_or_create_conn(engine, packet_in, ppstate, sa_local, +- sa_peer, peer_ctx, packet_in_size); ++ sa_peer, peer_ctx, packet_in_size, &created); + if (!engine->curr_conn) + engine->curr_conn = conn; + } +@@ -1885,6 +1889,20 @@ process_packet_in (lsquic_engine_t *engine, lsquic_packet_in_t *packet_in, + } + packet_in->pi_path_id = lsquic_conn_record_sockaddr(conn, peer_ctx, + sa_local, sa_peer); ++ /* bun: the application's context is on the mini conn before its first ++ * packet is processed, so the TLS callbacks of the handshake can reach it ++ * and no later step has to find it again by address. A conn that the ++ * callback aborts leaves no purgatory entry, so the peer's next first ++ * packet is a new attempt and not a stateless reset. */ ++ if (created && (conn->cn_flags & LSCONN_IETF) ++ && engine->pub.enp_stream_if->on_new_mini_conn) ++ { ++ conn->cn_conn_ctx = engine->pub.enp_stream_if->on_new_mini_conn( ++ engine->pub.enp_stream_if_ctx, conn); ++ if (((const struct ietf_mini_conn *) conn)->imc_flags ++ & IMC_ABORT_ERROR) ++ conn->cn_flags |= LSCONN_NO_BL; ++ } + lsquic_packet_in_upref(packet_in); + #if LOG_PACKET_CHECKSUM + log_packet_checksum(lsquic_conn_log_cid(conn), "in", packet_in->pi_data, +--- a/src/liblsquic/lsquic_full_conn_ietf.c ++++ b/src/liblsquic/lsquic_full_conn_ietf.c +@@ -1692,6 +1692,10 @@ lsquic_ietf_full_conn_server_new (struct lsquic_engine_public *enpub, + if (0 != handshake_ok(&conn->ifc_conn)) + goto err3; + ++ /* bun: the context on_new_mini_conn gave the mini conn moves here, past ++ * the last failure exit, so exactly one of the two conns has it. */ ++ conn->ifc_conn.cn_conn_ctx = mini_conn->cn_conn_ctx; ++ mini_conn->cn_conn_ctx = NULL; + LSQ_DEBUG("Calling on_new_conn callback"); + conn->ifc_conn.cn_conn_ctx = conn->ifc_enpub->enp_stream_if->on_new_conn( + conn->ifc_enpub->enp_stream_if_ctx, &conn->ifc_conn); +--- a/src/liblsquic/lsquic_mini_conn_ietf.c ++++ b/src/liblsquic/lsquic_mini_conn_ietf.c +@@ -1524,7 +1524,7 @@ ignore_init (struct ietf_mini_conn *conn) + ? (lsquic_time_t) conn->imc_enpub->enp_settings.es_idle_timeout_ms * 1000 + : (lsquic_time_t) conn->imc_enpub->enp_settings.es_idle_timeout * 1000000) + - (int64_t) conn->imc_enpub->enp_settings.es_handshake_to; +- if (ext_to > 0) ++ if (ext_to > 0 && !conn->imc_enpub->enp_settings.es_handshake_to_strict) + { + conn->imc_expire += ext_to; + LSQ_DEBUG("extend mini-conn expire time to %d seconds", diff --git a/patches/lsquic/peer-stream-limits.patch b/patches/lsquic/peer-stream-limits.patch new file mode 100644 index 000000000000..b94b1c162c87 --- /dev/null +++ b/patches/lsquic/peer-stream-limits.patch @@ -0,0 +1,43 @@ +--- a/src/liblsquic/lsquic_full_conn_ietf.c ++++ b/src/liblsquic/lsquic_full_conn_ietf.c +@@ -1402,16 +1402,21 @@ lsquic_ietf_full_conn_client_new (struct lsquic_engine_public *enpub, + /* Do not infer anything about server limits before processing its + * transport parameters. + */ +- conn->ifc_max_streams_in[SD_BIDI] = enpub->enp_settings.es_max_streams_in; +- conn->ifc_max_allowed_stream_id[SIT_BIDI_SERVER] = +- enpub->enp_settings.es_max_streams_in << SIT_SHIFT; ++ /* bun: the limit for the streams the server opens, and the window that ++ * sizes MAX_STREAMS credit, have to be what gen_trans_params() advertises. ++ * Upstream read es_max_streams_in, the gQUIC setting, for both. */ ++ conn->ifc_max_streams_in[SD_BIDI] ++ = enpub->enp_settings.es_init_max_streams_bidi; ++ conn->ifc_max_allowed_stream_id[SIT_BIDI_SERVER] ++ = (lsquic_stream_id_t) conn->ifc_max_streams_in[SD_BIDI] << SIT_SHIFT; + + if (flags & IFC_HTTP) + conn->ifc_max_streams_in[SD_UNI] = 3; + else +- conn->ifc_max_streams_in[SD_UNI] = enpub->enp_settings.es_max_streams_in; ++ conn->ifc_max_streams_in[SD_UNI] ++ = enpub->enp_settings.es_init_max_streams_uni; + conn->ifc_max_allowed_stream_id[SIT_UNI_SERVER] +- = conn->ifc_max_streams_in[SD_UNI] << SIT_SHIFT; ++ = (lsquic_stream_id_t) conn->ifc_max_streams_in[SD_UNI] << SIT_SHIFT; + + init_ver_neg(conn, versions, &ver); + assert(ver == conn->ifc_u.cli.ifcli_ver_neg.vn_ver); +@@ -1583,11 +1588,11 @@ lsquic_ietf_full_conn_server_new (struct lsquic_engine_public *enpub, + conn->ifc_max_streams_in[SD_BIDI] + = enpub->enp_settings.es_init_max_streams_bidi; + conn->ifc_max_allowed_stream_id[SIT_BIDI_CLIENT] +- = conn->ifc_max_streams_in[SD_BIDI] << SIT_SHIFT; ++ = (lsquic_stream_id_t) conn->ifc_max_streams_in[SD_BIDI] << SIT_SHIFT; + conn->ifc_max_streams_in[SD_UNI] + = enpub->enp_settings.es_init_max_streams_uni; + conn->ifc_max_allowed_stream_id[SIT_UNI_CLIENT] +- = conn->ifc_max_streams_in[SD_UNI] << SIT_SHIFT; ++ = (lsquic_stream_id_t) conn->ifc_max_streams_in[SD_UNI] << SIT_SHIFT; + conn->ifc_conn.cn_version = mini_conn->cn_version; + conn->ifc_conn.cn_flags |= LSCONN_VER_SET; + conn->ifc_conn.cn_pf = mini_conn->cn_pf; diff --git a/patches/lsquic/stop-sending-tickable.patch b/patches/lsquic/stop-sending-tickable.patch new file mode 100644 index 000000000000..f42e80425ffb --- /dev/null +++ b/patches/lsquic/stop-sending-tickable.patch @@ -0,0 +1,13 @@ +--- a/src/liblsquic/lsquic_stream.c ++++ b/src/liblsquic/lsquic_stream.c +@@ -1830,6 +1830,10 @@ handle_early_read_shutdown_ietf (struct lsquic_stream *stream) + TAILQ_INSERT_TAIL(&stream->conn_pub->sending_streams, stream, + next_send_stream); + stream->sm_qflags |= SMQF_SEND_STOP_SENDING|SMQF_WAIT_FIN_OFF; ++ /* bun: only a tick writes the frame. lsquic_stream_shutdown() makes the ++ * connection tickable only `if (how && ...)', so a read shutdown outside ++ * a tick left the frame queued until the next alarm or packet. */ ++ maybe_conn_to_tickable_if_writeable(stream, 1); + } + + diff --git a/patches/lsquic/stream-is-finished.patch b/patches/lsquic/stream-is-finished.patch new file mode 100644 index 000000000000..8f4e7a67ba10 --- /dev/null +++ b/patches/lsquic/stream-is-finished.patch @@ -0,0 +1,34 @@ +--- a/include/lsquic.h ++++ b/include/lsquic.h +@@ -2299,6 +2299,14 @@ lsquic_conn_abort_error (lsquic_conn_t *, int is_app, unsigned error_code, + uint64_t + lsquic_stream_get_error_code (const lsquic_stream_t *); + ++/** ++ * True if both sides are closed and all data is acknowledged. Inside ++ * on_close(), false means the stream was torn down with its connection or ++ * closed with frames still in flight. ++ */ ++int ++lsquic_stream_is_finished (const lsquic_stream_t *); ++ + /** + * Send RESET_STREAM(error_code) regardless of FIN state; the read side ends + * only if the peer FIN'd/RST'd. maybe_reset skips RST when FIN was sent. +--- a/src/liblsquic/lsquic_stream.c ++++ b/src/liblsquic/lsquic_stream.c +@@ -4513,6 +4513,14 @@ lsquic_stream_get_error_code (const lsquic_stream_t *stream) + } + + ++/* bun */ ++int ++lsquic_stream_is_finished (const lsquic_stream_t *stream) ++{ ++ return (stream->stream_flags & STREAM_FINISHED) != 0; ++} ++ ++ + /* bun: send RESET_STREAM(code) regardless of FIN state. + * lsquic_stream_maybe_reset skips RST when FIN_SENT and falls back to + * shutdown_read (which sends STOP_SENDING(HEC_NO_ERROR)), losing the diff --git a/patches/lsquic/stream-send-limit-by-initiator.patch b/patches/lsquic/stream-send-limit-by-initiator.patch new file mode 100644 index 000000000000..dd47dccfe31f --- /dev/null +++ b/patches/lsquic/stream-send-limit-by-initiator.patch @@ -0,0 +1,96 @@ +--- a/src/liblsquic/lsquic_full_conn_ietf.c ++++ b/src/liblsquic/lsquic_full_conn_ietf.c +@@ -452,7 +452,9 @@ struct ietf_full_conn + lsquic_packno_t ifc_max_ack_packno[N_PNS]; + lsquic_packno_t ifc_max_non_probing; + struct { +- uint64_t max_stream_send; ++ /* bun: named as in litespeedtech/lsquic#629. See apply_trans_params(). */ ++ uint64_t max_stream_send_bidi_local; /* Streams the peer opens */ ++ uint64_t max_stream_send_bidi_remote; /* Streams this side opens */ + uint8_t ack_exp; + } ifc_cfg; + int (*ifc_process_incoming_packet)( +@@ -1140,7 +1142,7 @@ create_bidi_stream_out (struct ietf_full_conn *conn) + conn->ifc_enpub->enp_stream_if, + conn->ifc_enpub->enp_stream_if_ctx, + conn->ifc_settings->es_init_max_stream_data_bidi_local, +- conn->ifc_cfg.max_stream_send, flags); ++ conn->ifc_cfg.max_stream_send_bidi_remote, flags); + if (!stream) + return -1; + if (!lsquic_hash_insert(conn->ifc_pub.all_streams, &stream->id, +@@ -3667,6 +3669,33 @@ apply_trans_params (struct ietf_full_conn *conn, + + conn->ifc_max_stream_data_uni = params->tp_init_max_stream_data_uni; + ++ /* bun: RFC 9000 Section 18.2 picks a bidirectional stream's initial send ++ * limit by who opens it: the peer's bidi_remote for a stream this side ++ * opens, its bidi_local for a stream the peer opens. Upstream kept one ++ * limit, picked by the connection's role, which is right only for a stream ++ * the client opens. On a stream the server opens, each side sent up to ++ * the wrong window and the receiver closed with FLOW_CONTROL_ERROR. ++ */ ++ conn->ifc_cfg.max_stream_send_bidi_local ++ = params->tp_init_max_stream_data_bidi_local; ++ conn->ifc_cfg.max_stream_send_bidi_remote ++ = params->tp_init_max_stream_data_bidi_remote; ++ if (conn->ifc_flags & IFC_HTTP) ++ { ++ /* bun: HTTP/3 has no server-opened bidirectional streams, so their ++ * limit keeps the value it had. The transport parameter check only ++ * bounds the other limit from below, and with a smaller one ++ * stream_write_avail_with_frames() fails its assert on a stream that an ++ * HTTP/3 server opens anyway. ++ */ ++ if (conn->ifc_flags & IFC_SERVER) ++ conn->ifc_cfg.max_stream_send_bidi_remote ++ = conn->ifc_cfg.max_stream_send_bidi_local; ++ else ++ conn->ifc_cfg.max_stream_send_bidi_local ++ = conn->ifc_cfg.max_stream_send_bidi_remote; ++ } ++ + if (params->tp_init_max_data < conn->ifc_pub.conn_cap.cc_sent) + { + ABORT_WARN("peer specified init_max_data=%"PRIu64" bytes, which is " +@@ -3682,10 +3711,14 @@ apply_trans_params (struct ietf_full_conn *conn, + el = lsquic_hash_next(conn->ifc_pub.all_streams)) + { + stream = lsquic_hashelem_getdata(el); +- if (is_our_stream(conn, stream)) +- limit = params->tp_init_max_stream_data_bidi_remote; ++ /* bun: streams exist here only after 0-RTT. Upstream gave a ++ * unidirectional stream a bidirectional stream's limit. */ ++ if (SD_UNI == ((stream->id >> SD_SHIFT) & 1)) ++ limit = conn->ifc_max_stream_data_uni; ++ else if (is_our_stream(conn, stream)) ++ limit = conn->ifc_cfg.max_stream_send_bidi_remote; + else +- limit = params->tp_init_max_stream_data_bidi_local; ++ limit = conn->ifc_cfg.max_stream_send_bidi_local; + if (0 != lsquic_stream_set_max_send_off(stream, limit)) + { + ABORT_WARN("cannot set peer-supplied max_stream_data=%"PRIu64 +@@ -3694,12 +3727,6 @@ apply_trans_params (struct ietf_full_conn *conn, + } + } + +- if (conn->ifc_flags & IFC_SERVER) +- conn->ifc_cfg.max_stream_send +- = params->tp_init_max_stream_data_bidi_local; +- else +- conn->ifc_cfg.max_stream_send +- = params->tp_init_max_stream_data_bidi_remote; + conn->ifc_cfg.ack_exp = params->tp_ack_delay_exponent; + + /* bun: es_idle_timeout_ms (ms-granular) takes precedence over the +@@ -5441,7 +5468,7 @@ new_stream (struct ietf_full_conn *conn, lsquic_stream_id_t stream_id, + + stream = lsquic_stream_new(stream_id, &conn->ifc_pub, + iface, stream_ctx, initial_window, +- conn->ifc_cfg.max_stream_send, flags); ++ conn->ifc_cfg.max_stream_send_bidi_local, flags); + if (stream) + { + if (conn->ifc_bpus) diff --git a/patches/lsquic/tick-once-per-pass.patch b/patches/lsquic/tick-once-per-pass.patch new file mode 100644 index 000000000000..b61d2f811896 --- /dev/null +++ b/patches/lsquic/tick-once-per-pass.patch @@ -0,0 +1,70 @@ +--- a/src/liblsquic/lsquic_engine.c ++++ b/src/liblsquic/lsquic_engine.c +@@ -2249,6 +2249,43 @@ force_close_conn (lsquic_engine_t *engin + } + + ++/* bun: lsquic_engine_packet_in() queues a connection even while the current ++ * pass is ticking it or has already ticked it. That happens when a callback ++ * runs the application's event loop and a packet is read mid-pass. Leave those ++ * connections on the queue for the next pass and return the first one this ++ * pass hasn't ticked. If too many come up in a row, end the pass. Not ++ * inlined, to keep it out of the tick loop's conn_iter_next_tickable(). ++ */ ++#if __GNUC__ || __clang__ ++__attribute__((noinline)) ++#endif ++static lsquic_conn_t * ++conn_iter_skip_ticked (struct lsquic_engine *engine, lsquic_conn_t *conn) ++{ ++ lsquic_conn_t *ticked[8]; ++ unsigned n_ticked = 0; ++ ++ do ++ { ++ ticked[n_ticked++] = conn; ++ if (n_ticked < sizeof(ticked) / sizeof(ticked[0])) ++ conn = lsquic_mh_pop(&engine->conns_tickable); ++ else ++ conn = NULL; ++ } ++ while (conn && (conn->cn_flags & LSCONN_TICKED)); ++ ++ while (n_ticked > 0) ++ { ++ --n_ticked; ++ lsquic_mh_insert(&engine->conns_tickable, ticked[n_ticked], ++ ticked[n_ticked]->cn_last_ticked); ++ } ++ ++ return conn; ++} ++ ++ + /* Iterator for tickable connections (those on the Tickable Queue). Before + * a connection is returned, it is removed from the Advisory Tick Time queue + * if necessary. +@@ -2270,6 +2307,9 @@ conn_iter_next_tickable (struct lsquic_e + else + conn = lsquic_mh_pop(&engine->conns_tickable); + ++ if (conn && (conn->cn_flags & LSCONN_TICKED)) /* bun */ ++ conn = conn_iter_skip_ticked(engine, conn); ++ + if (conn) + conn = engine_decref_conn(engine, conn, LSCONN_TICKABLE); + if (conn && (conn->cn_flags & LSCONN_ATTQ)) +@@ -3229,6 +3269,13 @@ process_connections (lsquic_engine_t *en + while ((conn = next_conn(engine)) + || (conn = next_new_full_conn(&new_full_conns))) + { ++ /* bun: never tick a connection twice in one pass. A second tick would ++ * link it on ticked_conns or closed_conns again. This is a promoted ++ * connection that was also on the Tickable Queue, or one that was ++ * queued again after the tick that closed it. ++ */ ++ if (conn->cn_flags & (LSCONN_TICKED|LSCONN_CLOSING)) ++ continue; + tick_st = conn->cn_if->ci_tick(conn, now); + #if LSQUIC_CONN_STATS + if (conn == engine->busy.current) diff --git a/patches/lsquic/tickable-per-connection.patch b/patches/lsquic/tickable-per-connection.patch new file mode 100644 index 000000000000..0145050b76a4 --- /dev/null +++ b/patches/lsquic/tickable-per-connection.patch @@ -0,0 +1,108 @@ +--- a/src/liblsquic/lsquic_conn.h ++++ b/src/liblsquic/lsquic_conn.h +@@ -53,7 +53,7 @@ enum lsquic_conn_flags { + LSCONN_SEND_BLOCKED = (1 <<15), /* Send connection blocked frame */ + LSCONN_PROMOTED = (1 <<16), /* Promoted. Only set if LSCONN_MINI is set */ + LSCONN_NEVER_TICKABLE = (1 <<17), /* Do not put onto the Tickable Queue */ +- LSCONN_UNUSED_18 = (1 <<18), ++ LSCONN_TICKING = (1 <<18), /* bun: inside ci_tick() */ + LSCONN_ATTQ = (1 <<19), + LSCONN_SKIP_ON_PROC = (1 <<20), + LSCONN_UNUSED_21 = (1 <<21), +--- a/src/liblsquic/lsquic_engine.c ++++ b/src/liblsquic/lsquic_engine.c +@@ -1716,8 +1716,13 @@ void + lsquic_engine_add_conn_to_tickable (struct lsquic_engine_public *enpub, + lsquic_conn_t *conn) + { +- if (0 == (enpub->enp_flags & ENPUB_PROC) && +- 0 == (conn->cn_flags & (LSCONN_TICKABLE|LSCONN_NEVER_TICKABLE))) ++ /* bun: upstream dropped every request made while ENPUB_PROC was set. That ++ * flag is engine-wide, so one connection's callback couldn't queue another ++ * connection, and what it wrote there waited for that connection's next ++ * alarm or packet. ++ */ ++ if (0 == (conn->cn_flags & (LSCONN_TICKABLE|LSCONN_NEVER_TICKABLE)) && ++ !lsquic_engine_has_conn_in_hand(enpub, conn)) + { + lsquic_engine_t *engine = (lsquic_engine_t *) enpub; + lsquic_mh_insert(&engine->conns_tickable, conn, conn->cn_last_ticked); +@@ -3142,7 +3147,9 @@ check_tickable_conns_again (struct lsqui + { + conn = lsquic_hashelem_getdata(el); + if (!(conn->cn_flags & LSCONN_TICKABLE) +- && conn->cn_if->ci_is_tickable(conn)) ++ && conn->cn_if->ci_is_tickable(conn) ++ /* bun: see the end of process_connections() */ ++ && !(conn->cn_flags & LSCONN_TICKABLE)) + { + lsquic_mh_insert(&engine->conns_tickable, conn, + conn->cn_last_ticked); +@@ -3276,7 +3283,9 @@ process_connections (lsquic_engine_t *en + */ + if (conn->cn_flags & (LSCONN_TICKED|LSCONN_CLOSING)) + continue; ++ conn->cn_flags |= LSCONN_TICKING; + tick_st = conn->cn_if->ci_tick(conn, now); ++ conn->cn_flags &= ~LSCONN_TICKING; + #if LSQUIC_CONN_STATS + if (conn == engine->busy.current) + maybe_log_conn_stats(engine, conn, now); +@@ -3346,7 +3355,12 @@ process_connections (lsquic_engine_t *en + TAILQ_REMOVE(&ticked_conns, conn, cn_next_ticked); + engine_decref_conn(engine, conn, LSCONN_TICKED); + if (!(conn->cn_flags & LSCONN_TICKABLE) +- && conn->cn_if->ci_is_tickable(conn)) ++ && conn->cn_if->ci_is_tickable(conn) ++ /* bun: ci_is_tickable() parses unread stream data, and that can ++ * queue the connection (a window update, a QPACK decoder write, ++ * an HTTP/3 error). Check the flag again before inserting. ++ */ ++ && !(conn->cn_flags & LSCONN_TICKABLE)) + { + /* Floyd heapification is not faster, don't bother. */ + lsquic_mh_insert(&engine->conns_tickable, conn, conn->cn_last_ticked); +--- a/src/liblsquic/lsquic_engine_public.h ++++ b/src/liblsquic/lsquic_engine_public.h +@@ -100,6 +100,22 @@ void + lsquic_engine_add_conn_to_tickable (struct lsquic_engine_public *, + lsquic_conn_t *); + ++/* bun: true if a request to make `conn' tickable can be dropped, because the ++ * engine looks at the connection again on its own: ++ * - LSCONN_TICKING: it is inside ci_tick(). ++ * - LSCONN_TICKED: this pass ticked it and calls ci_is_tickable() at the end. ++ * - LSCONN_CLOSING: the engine closed it. ++ * - not hashed while the engine is processing: the engine is creating it, ++ * and will queue it, or is destroying it. ++ * The three flags are only set while the engine is processing, so outside of ++ * that nothing is dropped, same as upstream. A macro, because most files that ++ * include this header only see an incomplete struct lsquic_conn. ++ */ ++#define lsquic_engine_has_conn_in_hand(enpub, conn) ( \ ++ ((conn)->cn_flags & (LSCONN_TICKING|LSCONN_TICKED|LSCONN_CLOSING)) \ ++ || (((enpub)->enp_flags & ENPUB_PROC) \ ++ && !((conn)->cn_flags & LSCONN_HASHED))) ++ + /* Put connection onto Advisory Tick Time Queue if it is not already on it. + */ + void +--- a/src/liblsquic/lsquic_stream.c ++++ b/src/liblsquic/lsquic_stream.c +@@ -260,10 +260,15 @@ sm_history_append (lsquic_stream_t *stre + #endif + + ++/* bun: true if a tick request for the stream's connection can be dropped. ++ * Upstream tested the engine-wide ENPUB_PROC, which is also set inside every ++ * other connection's callbacks. ++ */ + static int + stream_inside_callback (const lsquic_stream_t *stream) + { +- return stream->conn_pub->enpub->enp_flags & ENPUB_PROC; ++ return lsquic_engine_has_conn_in_hand(stream->conn_pub->enpub, ++ stream->conn_pub->lconn); + } + + diff --git a/patches/lsquic/zero-rtt-header-type.patch b/patches/lsquic/zero-rtt-header-type.patch new file mode 100644 index 000000000000..da0c9f57008e --- /dev/null +++ b/patches/lsquic/zero-rtt-header-type.patch @@ -0,0 +1,18 @@ +--- a/src/liblsquic/lsquic_packet_out.c ++++ b/src/liblsquic/lsquic_packet_out.c +@@ -264,7 +264,14 @@ lsquic_packet_out_new (struct lsquic_mm *mm, struct malo *malo, int use_cid, + packet_out->po_header_type = HETY_INITIAL; + } + else +- packet_out->po_header_type = HETY_HANDSHAKE; ++ /* bun: Initial and Handshake packets get their type from ++ * send_ctl_allocate_packet() and the mini conn. This default is ++ * left for a client's application packets before its handshake is ++ * done, which are sealed with the 0-RTT keys. Upstream typed them ++ * Handshake and fixed that up only where it wrote a STREAM frame, ++ * so the server couldn't open a packet with only RESET_STREAM, ++ * STOP_SENDING or a BLOCKED frame. */ ++ packet_out->po_header_type = HETY_0RTT; + } + packet_out->po_path = path; + diff --git a/scripts/build/deps/lsqpack.ts b/scripts/build/deps/lsqpack.ts index 0915b2275a0a..5749cba2e1c5 100644 --- a/scripts/build/deps/lsqpack.ts +++ b/scripts/build/deps/lsqpack.ts @@ -25,6 +25,9 @@ export const lsqpack: Dependency = { // The decoder asks dhi_prepare_decode() for room for the bytes it has in // hand, so the buffer of a long name or value grows as it arrives. "patches/lsqpack/decode-room-for-bytes-in-hand.patch", + // The decoder failed a field section with no field lines, which is valid + // (RFC 9204 section 4.5), and lsquic then closed the connection. + "patches/lsqpack/accept-empty-field-section.patch", ], // lsqpack.c is compiled inside the lsquic dep because lsquic feeds it a diff --git a/scripts/build/deps/lsquic.ts b/scripts/build/deps/lsquic.ts index 371975b8d1e7..fbedfb05f9bb 100644 --- a/scripts/build/deps/lsquic.ts +++ b/scripts/build/deps/lsquic.ts @@ -153,6 +153,95 @@ export const lsquic: Dependency = { // and failed for a larger one. Such a block gets a heap buffer, sized from // the header list. "patches/lsquic/large-header-block.patch", + // lsquic_engine_packet_in() queues a connection even while the current pass + // is ticking it, or already has. The pass then ticked it again and linked + // it on its ticked list twice: an endless loop, or a failed assert in debug + // builds. It takes a handler that runs the event loop mid-pass (Bun.build() + // with an async plugin setup). The connection now waits for the next pass. + "patches/lsquic/tick-once-per-pass.patch", + // lsquic dropped every request to make a connection tickable while the + // engine was processing (ENPUB_PROC), no matter which connection it was + // for. When one connection's handler wrote to another connection's stream, + // the data waited for that connection's next alarm or packet, and was lost + // if the idle timeout came first. Only drop the request for a connection + // the engine looks at again anyway. Applies on top of tick-once-per-pass. + // On an lsquic bump, check that ci_tick() still has one call site and that + // every insert into conns_tickable still tests LSCONN_TICKABLE first. + "patches/lsquic/tickable-per-connection.patch", + // lsquic_stream_hold_window(): receive backpressure that keeps reading the + // stream and stops raising its receive limit. Data that + // lsquic_stream_wantread(s, 0) leaves in a stream also uses up the + // connection's window, and is dropped when the connection goes away. + "patches/lsquic/hold-stream-window.patch", + // ci_going_away() skips the GOAWAY write for a server connection that never + // carried a request, and with it the call that makes the connection + // tickable. Its CONNECTION_CLOSE then waited for the next alarm or packet, + // and so did a graceful server.stop(). + "patches/lsquic/going-away-tickable.patch", + // Cubic keeps its windows in unsigned long, which is 32 bits on Windows, and + // multiplies them without widening. The pacing rate (cwnd * 10^6 / srtt) + // wrapped for every window (litespeedtech/lsquic#391). + "patches/lsquic/cubic-llp64-overflow.patch", + // immediate_close() and ci_abort_error() took "error code != 0" to mean + // that an error is recorded. A close with code 0 from + // lsquic_conn_abort_error() (an application close, or a transport close + // with a reason) went out as transport INTERNAL_ERROR. + "patches/lsquic/abort-error-code-zero.patch", + // lsquic_stream_is_finished(): inside on_close(), tells a stream that + // completed on its own from one that was torn down with its connection. + "patches/lsquic/stream-is-finished.patch", + // lsquic_conn_make_uni_stream() dropped a request made before the handshake + // finished. It now counts it, as it does for one that waits for stream + // credit. lsquic_conn_cancel_pending_uni_streams() takes a request back. + // Applies on top of node-quic-accessors. + "patches/lsquic/delayed-uni-streams.patch", + // on_datagram_status() reported how many DATAGRAM frames of a packet were + // acknowledged or lost, which doesn't say which ones. on_dg_write now names + // its frame with lsquic_conn_set_datagram_id(), the id stays in the frame + // record, and each frame is reported under it, also when lsquic drops the + // frame before the send. Applies on top of node-quic-accessors. + "patches/lsquic/datagram-ids.patch", + // lsquic_merge_acks() merges the ACK frames of one batch of packets. When one + // frame's range lay inside the other's, it cut the outer range short: [16-8] + // merged with [15-12][10-9] gave [16-12][10-9]. A late packet that only the + // newer frame acknowledged was then declared lost. + "patches/lsquic/merge-acks-contained-range.patch", + // A read shutdown queued STOP_SENDING without making the connection + // tickable. On a stream whose send side was done, the frame waited for the + // connection's next alarm or packet. + "patches/lsquic/stop-sending-tickable.patch", + // A resumed client seals the application packets it builds before its + // handshake is done with the 0-RTT keys. lsquic typed them Handshake, and + // fixed that up only where it wrote a STREAM frame. The server couldn't open + // a packet with only RESET_STREAM, STOP_SENDING or a BLOCKED frame, and the + // client kept resending it that way. + "patches/lsquic/zero-rtt-header-type.patch", + // A bidirectional stream's initial send limit was picked by the connection's + // role, not by who opened the stream (RFC 9000 section 18.2). The two agree + // only for a stream the client opens, the one kind HTTP/3 has. On a stream a + // node:quic server opens, each side sent up to the wrong window, and the + // receiver closed the connection with FLOW_CONTROL_ERROR. Also gives a + // unidirectional stream opened in 0-RTT the unidirectional limit when the + // handshake completes. litespeedtech/lsquic#629 (a draft) has both changes. + "patches/lsquic/stream-send-limit-by-initiator.patch", + // A client enforced a limit of es_max_streams_in (gQUIC's setting, 100) on the + // streams its server opens, and sized MAX_STREAMS credit from it, whatever + // limit it advertised. Below 50 it never returned credit. Above 100 it closed + // the connection on a stream it had allowed. Both roles also computed the + // limit in 32 bits, so 2^30 streams wrapped to 0. + "patches/lsquic/peer-stream-limits.patch", + // A server handshake runs in a mini conn, which had no context, so node:quic + // had to find a handshake's session again by peer address or by SSL address. + // on_new_mini_conn gives the mini conn its context before its first packet, + // promotion moves it to the full conn, and on_mini_conn_failed hands it back. + // es_handshake_to_strict makes es_handshake_to bound the whole handshake. + // Applies on top of node-quic-accessors and connection-close-pns. + "patches/lsquic/mini-conn-ctx.patch", + // lsquic_conn_local_transport_error(): lsquic_conn_status() has no error + // code, and it reports an error that fails the handshake, such as a TLS + // alert, as a version negotiation failure. Applies on top of + // abort-error-code-zero. + "patches/lsquic/local-transport-error.patch", ], fetchDeps: ["zlib", "lshpack", "lsqpack", "boringssl"], diff --git a/src/base64/lib.rs b/src/base64/lib.rs index 7c81c369325c..c60932d60aa7 100644 --- a/src/base64/lib.rs +++ b/src/base64/lib.rs @@ -119,6 +119,17 @@ pub fn decode_alloc(input: &[u8]) -> Result, DecodeAllocError> { Ok(dest) } +/// https://infra.spec.whatwg.org/#forgiving-base64-decode, without [`decode`]'s lenient fallback. +pub fn decode_forgiving_alloc(input: &[u8]) -> Result, DecodeAllocError> { + let mut dest = vec![0u8; decode_lenient_len(input.len())]; + let result = simdutf::base64::decode(input, &mut dest, false); + if !result.is_successful() { + return Err(DecodeAllocError::DecodingFailed); + } + dest.truncate(result.count); + Ok(dest) +} + pub use bun_core::base64::encode; /// [`encode`] appended to `out` (reserving the room itself); returns the number of bytes appended. diff --git a/src/boringssl_sys/boringssl.rs b/src/boringssl_sys/boringssl.rs index 6b3b1a4dac25..39f1367eccc4 100644 --- a/src/boringssl_sys/boringssl.rs +++ b/src/boringssl_sys/boringssl.rs @@ -182,6 +182,27 @@ pub struct EVP_MD_CTX { // All-zero is exactly the state `EVP_MD_CTX_init` writes (S021). unsafe impl bun_core::ffi::Zeroable for EVP_MD_CTX {} +/// `struct ssl_early_callback_ctx`, valid only inside a `select_certificate_cb`. +#[repr(C)] +pub struct SSL_CLIENT_HELLO { + pub ssl: *mut SSL, + pub client_hello: *const u8, + pub client_hello_len: usize, + pub version: u16, + pub random: *const u8, + pub random_len: usize, + pub session_id: *const u8, + pub session_id_len: usize, + pub dtls_cookie: *const u8, + pub dtls_cookie_len: usize, + pub cipher_suites: *const u8, + pub cipher_suites_len: usize, + pub compression_methods: *const u8, + pub compression_methods_len: usize, + pub extensions: *const u8, + pub extensions_len: usize, +} + /// `struct hmac_ctx_st`. #[repr(C)] #[derive(Copy, Clone)] @@ -877,6 +898,8 @@ pub const SSL_TLSEXT_ERR_ALERT_FATAL: c_int = 2; pub const SSL_TLSEXT_ERR_NOACK: c_int = 3; pub const OPENSSL_NPN_NEGOTIATED: c_int = 1; +/// `enum ssl_select_cert_result_t::ssl_select_cert_success`. +pub const ssl_select_cert_success: c_int = 1; /// `enum ssl_renegotiate_mode_t` — passed to `SSL_set_renegotiate_mode`. pub type ssl_renegotiate_mode_t = c_uint; @@ -1020,10 +1043,15 @@ unsafe extern "C" { name: *const c_char, namelen: usize, ) -> c_int; + pub fn X509_VERIFY_PARAM_set1_ip(param: *mut c_void, ip: *const u8, iplen: usize) -> c_int; pub fn SSL_CTX_set_keylog_callback( ctx: *mut SSL_CTX, cb: Option, ); + pub fn SSL_CTX_set_select_certificate_cb( + ctx: *mut SSL_CTX, + cb: Option c_int>, + ); pub fn SSL_CTX_set_early_data_enabled(ctx: *mut SSL_CTX, enabled: c_int); pub fn SSL_get_SSL_CTX(ssl: *const SSL) -> *mut SSL_CTX; pub fn SSL_get_ex_data(ssl: *const SSL, idx: c_int) -> *mut c_void; diff --git a/src/bun_core/fmt.rs b/src/bun_core/fmt.rs index 9f9c2176d455..f11922c783a1 100644 --- a/src/bun_core/fmt.rs +++ b/src/bun_core/fmt.rs @@ -1307,16 +1307,23 @@ pub enum URLProto { impl Display for URLFormatter<'_> { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - write!( - f, - "{}://", - match self.proto { - URLProto::Http => "http", - URLProto::Https => "https", - URLProto::Unix => "unix", - URLProto::Abstract => "abstract", + match self.proto { + URLProto::Http => f.write_str("http://")?, + URLProto::Https => f.write_str("https://")?, + URLProto::Unix => { + let path = self.hostname.unwrap_or(b""); + // Without a leading `/`, the URL parser reads the first segment as the host. + let host_len = strings::index_of_char_usize(path, b'/').unwrap_or(path.len()); + f.write_str("unix://")?; + write_socket_path(f, &path[..host_len], true)?; + return write_socket_path(f, &path[host_len..], false); } - )?; + URLProto::Abstract => { + f.write_str("abstract://")?; + write_socket_path(f, self.hostname.unwrap_or(b""), true)?; + return f.write_str("/"); + } + } if let Some(hostname) = self.hostname { let needs_brackets = @@ -1330,10 +1337,6 @@ impl Display for URLFormatter<'_> { f.write_str("localhost")?; } - if self.proto == URLProto::Unix { - return Ok(()); - } - let is_port_optional = self.port.is_none() || (self.proto == URLProto::Https && self.port == Some(443)) || (self.proto == URLProto::Http && self.port == Some(80)); @@ -1345,6 +1348,26 @@ impl Display for URLFormatter<'_> { } } +/// Percent-encodes `%` and every byte the URL parser rejects, strips, encodes or reads as a delimiter. +fn write_socket_path(f: &mut Formatter<'_>, bytes: &[u8], is_host: bool) -> fmt::Result { + for &b in bytes { + let encode = match b { + 0..=b' ' | 0x7F.. | b'#' | b'%' | b'<' | b'>' | b'?' | b'^' => true, + // Forbidden host code points. + b'/' | b':' | b'@' | b'[' | b'\\' | b']' | b'|' => is_host, + // The rest of the path percent-encode set. + b'"' | b'`' | b'{' | b'}' => !is_host, + _ => false, + }; + if encode { + write!(f, "%{b:02X}")?; + } else { + f.write_char(b as char)?; + } + } + Ok(()) +} + // ─────────────────────────────────────────────────────────────────────────── // HostFormatter // ─────────────────────────────────────────────────────────────────────────── @@ -3595,6 +3618,28 @@ fn escape_powershell_impl(str: &[u8], writer: &mut impl fmt::Write) -> fmt::Resu write_bytes(writer, remain) } +/// `parts` joined into one POSIX shell word: `'...'`, with each `'` written as `'\''`. +pub struct QuotePosixShell<'a>(pub(crate) &'a [&'a [u8]]); + +pub fn quote_posix_shell<'a>(parts: &'a [&'a [u8]]) -> QuotePosixShell<'a> { + QuotePosixShell(parts) +} + +impl Display for QuotePosixShell<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.write_str("'")?; + for part in self.0 { + let mut remain = *part; + while let Some(i) = crate::strings::index_of_char_usize(remain, b'\'') { + write!(f, "{}'\\''", bstr::BStr::new(&remain[..i]))?; + remain = &remain[i + 1..]; + } + write!(f, "{}", bstr::BStr::new(remain))?; + } + f.write_str("'") + } +} + /// Escapes C0/C1 control characters, DEL, U+2028-U+202E (line separators, bidirectional overrides) /// and U+2066-U+2069 (bidirectional isolates), so that untrusted text cannot alter our output. pub struct EscapeControlChars(pub T); diff --git a/src/collections/hive_array.rs b/src/collections/hive_array.rs index 51cfe23d8f23..9be6717df2bf 100644 --- a/src/collections/hive_array.rs +++ b/src/collections/hive_array.rs @@ -587,8 +587,7 @@ impl Fallback { /// buffer is left untouched (uninitialized bytes are a valid bit-pattern /// for `MaybeUninit`). /// - /// The returned allocation is leaked — callers stash it in a per-thread - /// static for the process lifetime. + /// The returned allocation is leaked. The caller reclaims it into a `Box`. #[inline] pub fn new_boxed() -> NonNull { let mut boxed = Box::::new_uninit(); diff --git a/src/http/AsyncHTTP.rs b/src/http/AsyncHTTP.rs index 00ec36e05823..5bfb19c9a1cd 100644 --- a/src/http/AsyncHTTP.rs +++ b/src/http/AsyncHTTP.rs @@ -14,7 +14,7 @@ use bun_picohttp as picohttp; use crate::headers::{self, Headers}; use crate::{ FetchRedirect, Flags, HTTPClient, HTTPRequestBody, HTTPVerboseLevel, InternalState, Method, - Signals, ThreadlocalAsyncHTTP, + MethodRef, Signals, ThreadlocalAsyncHTTP, }; use crate::{HTTPClientResult, HTTPClientResultCallback}; @@ -31,7 +31,7 @@ pub struct AsyncHTTP<'a> { pub response: Option>, pub request_headers: headers::EntryList, pub request_body: HTTPRequestBody<'a>, - pub(crate) method: Method, + pub(crate) method: MethodRef, pub url: URL<'a>, // Backref to the JS-thread `real` AsyncHTTP this HTTP-thread copy mirrors. // Cleared in finalize. Same `'a` — the copy never outlives the original. @@ -102,7 +102,7 @@ unsafe fn free_owned_href(href: &'static [u8]) { /// Read the HTTP-thread monotonic timer in nanoseconds. #[inline] -fn http_thread_timer_read() -> u64 { +pub(crate) fn http_thread_timer_read() -> u64 { crate::http_thread().timer.elapsed().as_nanos() as u64 } @@ -151,7 +151,7 @@ pub fn basic_authorization(url: &URL<'_>) -> Option> { /// `HTTPClient` has no `Default` (it has a `Drop` impl with side-effects), so /// this is the single place that enumerates the field set. fn make_client<'a>( - method: Method, + method: MethodRef, url: URL<'a>, header_entries: headers::EntryList, header_buf: &'a [u8], @@ -290,7 +290,7 @@ impl<'a> AsyncHTTP<'a> { /// The method the request was made with. A redirect only ever rewrites the /// HTTP thread's copy (`client.method`), and only to GET. #[inline] - pub fn method(&self) -> Method { + pub fn method(&self) -> MethodRef { self.method } @@ -409,8 +409,32 @@ pub fn preconnect(url: URL<'static>, is_url_owned: bool) { // ────────────────────────────────────────────────────────────────────────── impl<'a> AsyncHTTP<'a> { + /// `method` is a verb, or a [`MethodRef`] whose token is in `headers_buf`. + #[inline] pub fn init( - method: Method, + method: impl Into, + url: URL<'a>, + headers: headers::EntryList, + headers_buf: &'a [u8], + request_body: &'a [u8], + callback: HTTPClientResultCallback, + redirect_type: FetchRedirect, + options: Options<'a>, + ) -> AsyncHTTP<'a> { + Self::init_with_method( + method.into(), + url, + headers, + headers_buf, + request_body, + callback, + redirect_type, + options, + ) + } + + fn init_with_method( + method: MethodRef, url: URL<'a>, headers: headers::EntryList, headers_buf: &'a [u8], diff --git a/src/http/H2Client.rs b/src/http/H2Client.rs index a946e78c8fd4..06e11fb6f0da 100644 --- a/src/http/H2Client.rs +++ b/src/http/H2Client.rs @@ -70,15 +70,12 @@ pub use stream::Stream; // modules call. The real bodies live in lib.rs // (`register_abort_tracker` … `progress_update`) and HTTPContext.rs // (`register_h2` / `unregister_h2`); these now monomorphize the const-generic -// `` callees to `` (HTTP/2 is TLS-only) and erase the -// `picohttp::Request<'_>` borrow back to `'static` so ClientSession can keep -// using `client` after building the request. Kept as inherent methods so the +// `` callees to `` (HTTP/2 is TLS-only). Kept as inherent methods so the // many call sites in `h2_client/*.rs` need no churn. // ═══════════════════════════════════════════════════════════════════════ mod bridge { use crate::http_context::HTTPSocket; use crate::{HTTPClient, NewHTTPContext}; - use bun_picohttp as picohttp; impl HTTPClient<'_> { #[inline] @@ -129,17 +126,6 @@ mod bridge { pub(crate) fn h2_drain_response_body(&mut self, socket: HTTPSocket) { self.drain_response_body::(socket); } - #[inline] - pub(crate) fn h2_build_request(&mut self, body_len: usize) -> picohttp::Request<'static> { - // SAFETY: `build_request` returns a `Request<'_>` whose borrowed - // slices point only at (a) the thread-local - // `SHARED_REQUEST_HEADERS_BUF` static and (b) `self.header_buf`, - // which is itself `&'static [u8]` — neither is tied to the `&mut - // self` borrow. Erasing to `'static` lets - // `ClientSession::attach` re-borrow `client` while the - // `Request` is still live. Same pattern as lib.rs `on_writable`. - unsafe { self.build_request(body_len).detach_lifetime() } - } } impl NewHTTPContext { diff --git a/src/http/HTTPContext.rs b/src/http/HTTPContext.rs index 62cbb44b3e26..717a4f9cd96d 100644 --- a/src/http/HTTPContext.rs +++ b/src/http/HTTPContext.rs @@ -243,8 +243,10 @@ pub struct PooledSocket { pub(crate) h2_session: Option>, /// `PoolOptions::id` of the fetch session that opened it. pub(crate) pool_id: u64, - /// `PoolOptions::idle_timeout_seconds` of the session that parked it. + /// Seconds it can be idle before the timer closes it. pub(crate) idle_timeout_seconds: u32, + /// `find_in` closes the socket instead of reusing it once `http_thread().timer` passes this many nanoseconds. + pub(crate) reuse_deadline_ns: u64, } /// `&mut` access to a pooled / found-slot HTTP/2 session, for the field @@ -687,7 +689,9 @@ impl HTTPContext { proxy_auth_hash, h2_session, pool_id: pool.id, - idle_timeout_seconds: pool.idle_timeout_seconds, + idle_timeout_seconds: pool.idle_timeout_or_default(), + reuse_deadline_ns: crate::async_http::http_thread_timer_read() + .saturating_add(u64::from(pool.idle_timeout_or_default()) * 1_000_000_000), }; let parked = match transport { Transport::Tcp => Self::park(self.pending_sockets.get_or_init(), pooled, pool), @@ -770,17 +774,16 @@ impl HTTPContext { ActiveSocket::::init(slot.addr().as_ptr().cast_const()), ); socket.flush(); - Self::arm_idle_timeout(socket, options.idle_timeout_seconds); + Self::arm_idle_timeout(socket, pooled.idle_timeout_seconds); slot.write(pooled); None } - /// How long a parked socket may sit idle; 0 is the 5 minute default. + /// The timer wheel is coarse, so the timer only closes the socket. `reuse_deadline_ns` is the exact bound on reuse. fn arm_idle_timeout(socket: HTTPSocket, idle_timeout_seconds: u32) { - socket.set_timeout(match idle_timeout_seconds { - 0 => 5 * 60, - seconds => seconds, - }); + socket.set_timeout(crate::normalize_idle_timeout_seconds(u64::from( + idle_timeout_seconds, + ))); } #[allow(clippy::too_many_arguments)] @@ -840,6 +843,7 @@ impl HTTPContext { transport, pool_id, } = *key; + let now_ns = crate::async_http::http_thread_timer_read(); let mut iter = pool.used.iterator::(); while let Some(pending_socket_index) = iter.next() { @@ -921,6 +925,12 @@ impl HTTPContext { continue; } + // The origin is about to close it, and a non-idempotent request that loses that race isn't retried. + if now_ns >= socket.reuse_deadline_ns { + Self::close_socket(http_socket); + continue; + } + // `HTTPThread::drain_events` hands a socket out before the loop // polls it, so input the origin already wrote is unread in the // kernel and invisible to the checks above; reuse answers this @@ -1574,6 +1584,10 @@ impl Handler { HTTPContext::::mark_socket_as_dead(socket); h2::ClientSession::on_timeout(session); } + // An idle keep-alive socket is healthy, so close it with a FIN, not a reset. + if tagged.is::>() { + return HTTPContext::::close_socket(socket); + } HTTPContext::::terminate_socket(socket); } diff --git a/src/http/InternalState.rs b/src/http/InternalState.rs index d2c6cb245acf..b006c72ba372 100644 --- a/src/http/InternalState.rs +++ b/src/http/InternalState.rs @@ -34,7 +34,6 @@ pub struct InternalState<'a> { pub(crate) transfer_encoding: Encoding, pub(crate) encoding: Encoding, - pub(crate) content_encoding_i: u8, pub(crate) chunked_decoder: bun_picohttp::phr_chunked_decoder, pub(crate) decompressor: Decompressor, pub(crate) stage: Stage, @@ -46,6 +45,8 @@ pub struct InternalState<'a> { /// Prefix of `compressed_body` the decoder has already taken. compressed_body_consumed: usize, pub(crate) content_length: Option, + /// How long the connection can be reused after this response, from its `Keep-Alive: timeout=N` header. + pub(crate) keepalive_reuse_seconds: Option, pub(crate) total_body_received: usize, // Self-borrow into `original_request_body.bytes`; `RawSlice` carries the // outlives-holder invariant (the backing `original_request_body` is a @@ -77,7 +78,6 @@ pub struct InternalState<'a> { pub struct InternalStateFlags { pub(crate) allow_keepalive: bool, pub(crate) received_last_chunk: bool, - pub(crate) did_set_content_encoding: bool, pub(crate) is_redirect_pending: bool, pub(crate) is_libdeflate_fast_path_disabled: bool, pub(crate) resend_request_body_on_redirect: bool, @@ -98,6 +98,8 @@ pub struct InternalStateFlags { pub(crate) body_compressed: bool, /// Held input or buffered decoder output remains for `HTTPClient::drain_response_body`. pub(crate) decompress_output_pending: bool, + /// `decoded_body` has chunks that were decoded before a malformed one. Report them before the failure. + pub(crate) has_body_ahead_of_failure: bool, } impl InternalStateFlags { @@ -106,7 +108,6 @@ impl InternalStateFlags { Self { allow_keepalive: true, received_last_chunk: false, - did_set_content_encoding: false, is_redirect_pending: false, is_libdeflate_fast_path_disabled: false, resend_request_body_on_redirect: false, @@ -114,6 +115,7 @@ impl InternalStateFlags { receive_paused: false, body_compressed: false, decompress_output_pending: false, + has_body_ahead_of_failure: false, } } } @@ -126,7 +128,6 @@ impl Default for InternalState<'_> { flags: InternalStateFlags::new(), transfer_encoding: Encoding::Identity, encoding: Encoding::Identity, - content_encoding_i: u8::MAX, chunked_decoder: bun_picohttp::phr_chunked_decoder::default(), decompressor: Decompressor::None, stage: Stage::Pending, @@ -134,6 +135,7 @@ impl Default for InternalState<'_> { compressed_body: MutableString::init_empty(), compressed_body_consumed: 0, content_length: None, + keepalive_reuse_seconds: None, total_body_received: 0, request_body: bun_ptr::RawSlice::EMPTY, original_request_body: HTTPRequestBody::Bytes(b""), diff --git a/src/http/ProxyTunnel.rs b/src/http/ProxyTunnel.rs index b6ea15bf7be0..d84ed2d41ea5 100644 --- a/src/http/ProxyTunnel.rs +++ b/src/http/ProxyTunnel.rs @@ -41,7 +41,6 @@ pub use bun_uws::MaybeAnySocket as Socket; #[derive(bun_ptr::CellRefCounted)] pub struct ProxyTunnel { pub(crate) wrapper: Option, - pub(crate) shutdown_err: Cell, /// active socket is the socket that is currently being used pub(crate) socket: Socket, pub(crate) write_buffer: bun_io::StreamBuffer, @@ -64,7 +63,6 @@ impl Default for ProxyTunnel { fn default() -> Self { Self { wrapper: None, - shutdown_err: Cell::new(crate::Error::ConnectionClosed), socket: Socket::None, write_buffer: bun_io::StreamBuffer::default(), did_have_handshaking_error: false, @@ -113,27 +111,6 @@ impl ProxyTunnel { unsafe { &mut *addr_of_mut!((*this.as_ptr()).write_buffer) } } - /// Shared access to `shutdown_err` (a `Cell`; disjoint from - /// `wrapper`). Callers use `.get()`/`.set()` — no `&mut` needed. - #[inline] - fn shutdown_err_of<'a>(this: NonNull) -> &'a Cell { - // SAFETY: see [`Self::socket_of`]. - unsafe { &*addr_of!((*this.as_ptr()).shutdown_err) } - } - - /// Callback-safe close: sets `shutdown_err` then drives `wrapper.shutdown()`. - /// Takes `NonNull` so the SSLWrapper close callback (which reenters - /// `on_close` and reborrows tunnel fields via the disjoint accessors above) - /// does not alias a held `&mut ProxyTunnel`. - /// - /// Module-level INVARIANT: `this` is a live intrusive-refcounted tunnel and - /// the caller's `&mut HTTPClient`/`&mut ProxyTunnel` borrows are NLL-dead - /// before this call (every callsite in this module follows that shape). - #[inline] - fn close_from_callback(this: NonNull, err: Error) { - Self::close_raw(this, err); - } - #[inline] fn wrapper_ssl(this: NonNull) -> Option> { Self::wrapper_ref(this.as_ptr()).and_then(|w| w.ssl.get()) @@ -174,7 +151,7 @@ impl ProxyTunnel { /// `*mut HTTPClient` registered in [`ProxyTunnel::start`]/`adopt`. The client /// is embedded in its `AsyncHTTP` and outlives the tunnel. Each callback's /// outer `&mut HTTPClient` must be NLL-dead before any reentrant call that -/// re-derives it via raw ptr (`close_from_callback`, `progress_update_*`, +/// re-derives it via raw ptr (`fail_request`, `progress_update_*`, /// `on_writable`); call sites are shaped accordingly. #[inline] fn client_from_ctx<'a, 'c>(ctx: *mut HTTPClient<'c>) -> &'a mut HTTPClient<'c> { @@ -249,19 +226,19 @@ fn on_data(ctx: *mut HTTPClient, decoded_data: &[u8]) { // SAFETY: see on_open. `&mut HTTPClient` is disjoint from the caller's // `&SSLWrapper` (HTTPClient holds the tunnel only by pointer). NLL // ends this borrow before any reentrant call below that re-derives - // `&mut *ctx` (close → on_close, progress_update). + // `&mut *ctx` (fail_request, progress_update). let this = client_from_ctx(ctx); let Some(proxy_nn) = this.proxy_tunnel_ptr() else { return; }; - let _guard = ProxyTunnel::ref_guard(proxy_nn); + let guard = ProxyTunnel::ref_guard(proxy_nn); // While parked waiting for the JS `checkServerIdentity` verdict no request // has been written through the tunnel, so any decrypted application data // arriving here is unexpected. if this.state.flags.is_waiting_for_cert_check { scoped_log!(http_proxy_tunnel, "ProxyTunnel onData while parked"); - // SAFETY: `this` dead (NLL); reenter via raw ptr. - ProxyTunnel::close_from_callback(proxy_nn, crate::Error::UnexpectedData); + // `this` dead (NLL); reborrow via `client_from_ctx` inside. + fail_request(ctx, guard, crate::Error::UnexpectedData); return; } match this.state.response_stage { @@ -273,10 +250,8 @@ fn on_data(ctx: *mut HTTPClient, decoded_data: &[u8]) { let report_progress = match this.handle_response_body(decoded_data, false) { Ok(v) => v, Err(err) => { - // `this` is dead (NLL); reenter via raw ptr so on_close's - // fresh `&mut *ctx` / `&mut *proxy_ptr` do not alias us. - // SAFETY: tunnel pinned by ref_raw above. - ProxyTunnel::close_from_callback(proxy_nn, err); + // `this` dead (NLL); reborrow via `client_from_ctx` inside. + fail_request(ctx, guard, err); return; } }; @@ -295,8 +270,8 @@ fn on_data(ctx: *mut HTTPClient, decoded_data: &[u8]) { let report_progress = match this.handle_response_body_chunked_encoding(decoded_data) { Ok(v) => v, Err(err) => { - // SAFETY: see Body arm. - ProxyTunnel::close_from_callback(proxy_nn, err); + // `this` dead (NLL); see Body arm. + fail_request(ctx, guard, err); return; } }; @@ -327,8 +302,8 @@ fn on_data(ctx: *mut HTTPClient, decoded_data: &[u8]) { } _ => { scoped_log!(http_proxy_tunnel, "ProxyTunnel onData unexpected data"); - // SAFETY: `this` dead (NLL); reenter via raw ptr. - ProxyTunnel::close_from_callback(proxy_nn, crate::Error::UnexpectedData); + // `this` dead (NLL); reborrow via `client_from_ctx` inside. + fail_request(ctx, guard, crate::Error::UnexpectedData); } } } @@ -345,7 +320,7 @@ fn on_handshake( }; scoped_log!(http_proxy_tunnel, "ProxyTunnel onHandshake"); // Do NOT form `&mut ProxyTunnel` (see ALIASING NOTE). - let _guard = ProxyTunnel::ref_guard(proxy_nn); + let guard = ProxyTunnel::ref_guard(proxy_nn); // Not the first report: a renegotiation, with the request already on its way. let first_report = this.state.request_stage == HTTPStage::ProxyHandshake; if first_report { @@ -361,9 +336,8 @@ fn on_handshake( // only reject the connection if reject_unauthorized == true if this.flags.reject_unauthorized && this.flags.did_have_handshaking_error { let err = crate::get_cert_error_from_no(handshake_error.error_no); - // SAFETY: `this` dead (NLL); reenter via raw ptr so on_close's - // fresh `&mut *ctx` does not alias us. - ProxyTunnel::close_from_callback(proxy_nn, err); + // `this` dead (NLL); reborrow via `client_from_ctx` inside. + fail_request(ctx, guard, err); return; } if !first_report { @@ -440,8 +414,8 @@ fn on_handshake( } else { crate::Error::TLSHandshakeFailed }; - // SAFETY: `this` dead (NLL); reenter via raw ptr. - ProxyTunnel::close_from_callback(proxy_nn, err); + // `this` dead (NLL); reborrow via `client_from_ctx` inside. + fail_request(ctx, guard, err); return; } } @@ -486,10 +460,6 @@ pub(crate) fn write_encrypted(ctx: *mut HTTPClient, encoded_data: &[u8]) { } fn on_close(ctx: *mut HTTPClient) { - // on_close is fired from inside SSLWrapper::shutdown (via close_raw) whose - // caller may itself be a callback that already held `&mut *ctx`; that - // outer borrow is required to be NLL-dead before close_raw is invoked - // (see on_data/on_handshake), so this fresh `&mut` is sole. let this = client_from_ctx(ctx); scoped_log!( http_proxy_tunnel, @@ -500,6 +470,7 @@ fn on_close(ctx: *mut HTTPClient) { "tunnel exists" } ); + // `fail()` detaches the tunnel before it shuts the wrapper down, so a close the client started returns here. let Some(proxy_nn) = this.proxy_tunnel_ptr() else { return; }; @@ -525,24 +496,25 @@ fn on_close(ctx: *mut HTTPClient) { } } - // Otherwise, treat as failure. `close_and_fail` de-tags the outer socket - // before `fail()` frees the AsyncHTTP that embeds `self` (the uSockets ext - // still points here until then). - let err = fail_err.unwrap_or_else(|| ProxyTunnel::shutdown_err_of(proxy_nn).get()); - match ProxyTunnel::socket_of(proxy_nn) { - &Socket::Ssl(socket) => { - this.close_and_fail::(err, socket); - } - &Socket::Tcp(socket) => { - this.close_and_fail::(err, socket); - } - Socket::None => { - if fail_err.is_some() { - this.fail(err); - } - } + // `this` dead (NLL); reborrow via `client_from_ctx` inside. + fail_request( + ctx, + keepalive, + fail_err.unwrap_or(crate::Error::ConnectionClosed), + ); +} + +/// Fails the request. The client may be freed when this returns. +fn fail_request(ctx: *mut HTTPClient, keepalive: RefPtr, err: Error) { + let proxy = keepalive.as_non_null(); + let this = client_from_ctx(ctx); + // `close_and_fail` de-tags the outer socket before `fail()` frees the client. + match ProxyTunnel::socket_of(proxy) { + &Socket::Ssl(socket) => this.close_and_fail::(err, socket), + &Socket::Tcp(socket) => this.close_and_fail::(err, socket), + Socket::None => this.fail(err), } - ProxyTunnel::set_socket(proxy_nn, Socket::None); + ProxyTunnel::set_socket(proxy, Socket::None); crate::http_thread().schedule_proxy_deref(keepalive); } @@ -661,29 +633,6 @@ impl ProxyTunnel { } } - /// Raw-pointer close: sets `shutdown_err` then drives `wrapper.shutdown()`. - /// Takes `NonNull` so the SSLWrapper close callback (which reenters - /// on_close and reborrows tunnel fields via raw projection) does not alias - /// a held `&mut ProxyTunnel`. - /// - /// All field access goes through the disjoint-field accessors - /// ([`Self::shutdown_err_of`], [`Self::wrapper_ref`]), which already - /// encode the module INVARIANT that `this` is a live intrusive-refcounted - /// tunnel and no whole-struct `&mut ProxyTunnel` is held across the call. - /// Callers satisfy that by construction (see [`Self::close_from_callback`]). - pub(crate) fn close_raw(this: NonNull, err: Error) { - // `shutdown_err` is a `Cell` disjoint from `wrapper`; safe set. - Self::shutdown_err_of(this).set(err); - // shutdown() fires on_close synchronously, which accesses only - // disjoint tunnel fields via `addr_of!` (see on_close), so the - // `&SSLWrapper` from `wrapper_ref` is never aliased by a `&mut` - // across the reentrant call. - if let Some(wrapper) = ProxyTunnel::wrapper_ref(this.as_ptr()) { - // fast shutdown the connection - let _ = wrapper.shutdown(true); - } - } - pub(crate) fn shutdown(this: NonNull) { // SAFETY: module INVARIANT — `this` is a live intrusive-refcounted // tunnel and the caller's `&mut` borrows are NLL-dead before this call. diff --git a/src/http/SendFile.rs b/src/http/SendFile.rs index 2c7ed2d2236d..2ceb629e686b 100644 --- a/src/http/SendFile.rs +++ b/src/http/SendFile.rs @@ -1,4 +1,4 @@ -#[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))] +#[cfg(target_os = "freebsd")] use core::ptr; use bun_core::feature_flags; @@ -22,6 +22,45 @@ impl SendFile { url.is_http() && url.href.len() > 0 } + /// Moves the cursor back to the first byte so a redirect can send the body again. + pub(crate) fn rewind(&mut self) { + self.offset -= self.content_size - self.remain; + self.remain = self.content_size; + } + + /// `pread` + `send`, for a connection `sendfile(2)` can't write to. Nothing here touches `self` after `send` fails. + pub(crate) fn write_copy( + &mut self, + mut send: impl FnMut(&[u8]) -> crate::Result, + ) -> Status { + let buf = crate::file_body_copy_buffer(); + // Start small and double. A writable event can mean very little room. + let mut chunk: usize = 16 * 1024; + loop { + let want = chunk.min(self.remain); + if want == 0 { + return Status::Done; + } + let offset = i64::try_from(self.offset).expect("int cast"); + let read = match bun_sys::pread(self.fd, &mut buf[..want], offset) { + // The file shrank after it was measured. + Ok(0) => return Status::Done, + Ok(n) => n, + Err(err) => return Status::Err(bun_errno::SystemErrno::from(err).into()), + }; + let wrote = match send(&buf[..read]) { + Ok(n) => n, + Err(err) => return Status::Err(err), + }; + self.offset += wrote; + self.remain -= wrote; + if wrote < read { + return Status::Again; + } + chunk = (chunk * 2).min(buf.len()); + } + } + // Takes the resolved fd directly rather than the socket; callers pass // `socket.fd()`. pub(crate) fn write(&mut self, socket_fd: Fd) -> Status { @@ -93,39 +132,7 @@ impl SendFile { } } - #[cfg(all( - unix, - not(any(target_os = "linux", target_os = "android")), - not(target_os = "freebsd") - ))] - { - let mut sbytes: i64 = i64::try_from(adjusted_count).expect("int cast"); // C off_t - // Same-width signedness flip; `as` is a bit-reinterpret here. - let signed_offset: i64 = self.offset as u64 as i64; - // SAFETY: fds valid; sbytes is a live stack local; hdtr is null (no headers). - let errcode = bun_sys::get_errno(unsafe { - bun_sys::c::sendfile( - self.fd.native(), - socket_fd.native(), - signed_offset, - &raw mut sbytes, - ptr::null_mut(), - 0, - ) - }); - let wrote: u64 = u64::try_from(sbytes).expect("int cast"); - self.offset = (self.offset as u64).saturating_add(wrote) as usize; - self.remain = (self.remain as u64).saturating_sub(wrote) as usize; - if errcode != bun_sys::E::EAGAIN || self.remain == 0 || sbytes == 0 { - if errcode == bun_sys::E::SUCCESS { - return Status::Done; - } - - return Status::Err(bun_errno::from_errno(errcode as i32).into()); - } - } - - #[cfg(windows)] + #[cfg(any(windows, target_os = "macos"))] { let _ = (socket_fd, adjusted_count); } @@ -135,9 +142,7 @@ impl SendFile { } pub(crate) enum Status { - #[cfg(not(windows))] Done, - #[cfg(not(windows))] Err(crate::Error), Again, } diff --git a/src/http/error.rs b/src/http/error.rs index 605c2973b979..9c940f117cbd 100644 --- a/src/http/error.rs +++ b/src/http/error.rs @@ -374,7 +374,8 @@ impl From for Error { fn from(e: bun_picohttp::ParseResponseError) -> Self { match e { bun_picohttp::ParseResponseError::ShortRead => Error::ShortRead, - _ => Error::Picohttp(e), + bun_picohttp::ParseResponseError::TooManyHeaders => Error::ResponseHeadersTooLarge, + bun_picohttp::ParseResponseError::MalformedHttpResponse => Error::Picohttp(e), } } } diff --git a/src/http/h2_client/ClientSession.rs b/src/http/h2_client/ClientSession.rs index d32aa83f1752..49a115916600 100644 --- a/src/http/h2_client/ClientSession.rs +++ b/src/http/h2_client/ClientSession.rs @@ -608,7 +608,8 @@ impl ClientSession { client.fail(e); return; } - let request = client.h2_build_request(client.body_len_for_send()); + let mut header_overflow = Vec::new(); + let request = client.build_request(client.body_len_for_send(), &mut header_overflow); if let Err(err) = encode::write_request(self, client, stream_ref, &request) { // encodeHeader pushes into the HPACK encoder's dynamic table per // call, so a mid-encode failure leaves entries the server will @@ -1207,6 +1208,10 @@ impl ClientSession { if let Some(err) = stream.fatal_error { stream.client = None; client.h2 = None; + if stream.http1_required && stream.status_code == 0 && client.can_retry_over_http1() { + client.retry_over_http1(); + return true; + } // Only transparently retry when the server refused the stream // before producing any of it (REFUSED_STREAM after HEADERS would // be a server bug, but retrying then re-streams a body prefix diff --git a/src/http/h2_client/Stream.rs b/src/http/h2_client/Stream.rs index 5a9ee2bb9108..ea47a47cbe9c 100644 --- a/src/http/h2_client/Stream.rs +++ b/src/http/h2_client/Stream.rs @@ -56,6 +56,8 @@ pub struct Stream { /// or final status arrives. pub(crate) awaiting_continue: bool, pub(crate) fatal_error: Option, + /// The server refused the stream with HTTP_1_1_REQUIRED. Separate from `fatal_error` because a later GOAWAY overwrites that. + pub(crate) http1_required: bool, /// DATA bytes consumed since the last WINDOW_UPDATE for this stream. pub(crate) unacked_bytes: u32, /// Σ DATA payload bytes (post-padding) for §8.1.1 Content-Length check — @@ -144,6 +146,7 @@ impl Stream { headers_end_stream: false, awaiting_continue: false, fatal_error: None, + http1_required: false, unacked_bytes: 0, data_bytes_received: 0, send_window, diff --git a/src/http/h2_client/dispatch.rs b/src/http/h2_client/dispatch.rs index 523bd752bf7d..7ff13fbcd71d 100644 --- a/src/http/h2_client/dispatch.rs +++ b/src/http/h2_client/dispatch.rs @@ -517,6 +517,7 @@ fn dispatch_frame( } _ => Some(crate::Error::HTTP2StreamReset), }; + stream.http1_required = code == wire::ErrorCode::HTTP_1_1_REQUIRED.0; } FT_GOAWAY => { if stream_id != 0 { @@ -532,11 +533,13 @@ fn dispatch_frame( wire::UInt31WithReserved::from_bytes(&payload[0..4]).uint31(); let code: u32 = wire::u32_from_bytes(&payload[4..8]); let graceful = code == wire::ErrorCode::NO_ERROR.0; + let http1_required = code == wire::ErrorCode::HTTP_1_1_REQUIRED.0; let last_id = session.goaway_last_stream_id; for &s_ptr in session.streams.values() { // SAFETY: stream pointer valid for session lifetime. let s = stream_mut(s_ptr); if s.id > last_id { + s.http1_required |= http1_required; s.fatal_error = Some(if graceful { crate::Error::HTTP2RefusedStream } else { @@ -547,6 +550,9 @@ fn dispatch_frame( // still complete successfully" — don't discard a // response that already finished in this same read. s.fatal_error = Some(crate::Error::HTTP2GoAway); + // The server may have processed this stream, so only an idempotent request is sent again. + s.http1_required |= + http1_required && s.client_ref().is_some_and(|c| c.method.is_idempotent()); } } } @@ -620,15 +626,9 @@ pub(crate) fn decode_header_block(session: &mut ClientSession, stream: &mut Stre continue; } seen_status = true; - // RFC 9110 §15: status-code is a 3-digit integer. Header values - // are octets, not guaranteed UTF-8. - status = if result.value.len() == 3 { - bun_core::parse_unsigned::(result.value, 10).unwrap_or(0) - } else { - 0 - }; - if status < 100 || status > 999 { - malformed = true; + match bun_http_types::parse_status_pseudo_header(result.value) { + Some(code) => status = u32::from(code), + None => malformed = true, } continue; } diff --git a/src/http/h3_client/AltSvc.rs b/src/http/h3_client/AltSvc.rs index 208498c59e30..6f3e800e03a7 100644 --- a/src/http/h3_client/AltSvc.rs +++ b/src/http/h3_client/AltSvc.rs @@ -124,6 +124,8 @@ pub(crate) fn parse(field_value: &[u8]) -> Result, ParseError> { struct Record { h3_port: u16, expires_at: i64, + /// `lookup()` skips the alternative until then, see `report_unreachable()`. + broken_until: i64, } // module-level mutable state; safe because @@ -200,12 +202,18 @@ pub(crate) fn record(origin_host: &[u8], origin_port: u16, field_value: &[u8]) { return; } } + // The origin repeats the header on every response, which says nothing new about the alternative. + let broken_until = cache() + .get(k) + .filter(|rec| rec.h3_port == entry.port) + .map_or(0, |rec| rec.broken_until); // `StringHashMap::put` dupes the key on insert. let _ = cache().put( k, Record { h3_port: entry.port, expires_at: now + i64::from(entry.ma), + broken_until, }, ); bun_core::scoped_log!( @@ -227,9 +235,28 @@ pub(crate) fn lookup(origin_host: &[u8], origin_port: u16) -> Option { } let k = key(&mut buf, origin_host, origin_port); let rec = *cache().get(k)?; - if timestamp() >= rec.expires_at { + let now = timestamp(); + if now >= rec.expires_at { cache().remove(k); return None; } + if now < rec.broken_until { + return None; + } Some(rec.h3_port) } + +/// Chromium's `kDefaultBrokenAlternativeProtocolDelay`. +const BROKEN_SECS: i64 = 300; + +/// No QUIC connection to the alternative `host:port` could be established, so `lookup()` skips it for `BROKEN_SECS`. +pub(crate) fn report_unreachable(host: &[u8], port: u16) { + let until = timestamp() + BROKEN_SECS; + for (origin, rec) in cache().iter_mut() { + // Alternatives are same-host, so the origin's key is `host:`. + let origin_host = origin.rsplitn(2, |&b| b == b':').nth(1).unwrap_or_default(); + if rec.h3_port == port && bun_url::strip_ipv6_brackets(origin_host) == host { + rec.broken_until = until; + } + } +} diff --git a/src/http/h3_client/ClientContext.rs b/src/http/h3_client/ClientContext.rs index 7daf52ebd362..6987f8eecf53 100644 --- a/src/http/h3_client/ClientContext.rs +++ b/src/http/h3_client/ClientContext.rs @@ -17,6 +17,7 @@ use super::stream::Stream; use crate::HTTPClient; use crate::h3_client as H3; use crate::http_thread::WriteMessageType; +use crate::ssl_config::SSLConfig; use crate::h3_client::h3_client; @@ -38,7 +39,6 @@ pub(crate) struct ClientContext { // uncontended atomic op is free on the single HTTP client thread. static INSTANCE: bun_core::AtomicCell>> = bun_core::AtomicCell::new(None); -static LSQUIC_INIT_ONCE: std::sync::Once = std::sync::Once::new(); impl ClientContext { /// Mutable access to the lsquic client engine. @@ -75,7 +75,6 @@ impl ClientContext { if let Some(i) = INSTANCE.load() { return Some(i); } - LSQUIC_INIT_ONCE.call_once(quic::global_init); // SAFETY: `loop_` is the live HTTP-thread uws loop (NonNull invariant). let qctx = unsafe { quic::Context::create_client( @@ -108,12 +107,19 @@ impl ClientContext { /// `false` leaves `client` on no session, for the caller to fail. pub(crate) fn connect(&mut self, client: &mut HTTPClient, hostname: &[u8], port: u16) -> bool { let reject = client.flags.reject_unauthorized; + // Same gate as `HTTPThread::connect`: `rejectUnauthorized` alone stays on the default context. + let tls_props = client + .tls_props + .clone() + .filter(|tls| tls.get().requires_custom_request_ctx); + let tls_props_ptr = SSLConfig::raw_ptr(tls_props.as_ref()); for &s in self.sessions.iter() { // sessions vec holds live ClientSession pointers; removed via // unregister() before destroy — `session_mut` centralises that // backref upgrade. let s = session_mut(s); - if s.matches(hostname, port, reject, client.pool.id) && s.has_headroom() { + if s.matches(hostname, port, reject, tls_props_ptr, client.pool.id) && s.has_headroom() + { bun_core::scoped_log!( h3_client, "reuse session {}:{}", @@ -134,7 +140,21 @@ impl ClientContext { let mut host_buf = bun_url::strip_ipv6_brackets(hostname).to_vec(); host_buf.push(0); let host_z = std::ffi::CStr::from_bytes_until_nul(&host_buf).expect("nul appended above"); - let session = ClientSession::new(hostname.to_vec(), port, reject, client.pool.id); + let tls_options = tls_props + .as_ref() + .map(|tls| tls.get().as_usockets_for_client_verification()); + let sni = tls_props + .as_ref() + .and_then(|tls| tls.get().server_name_cstr()) + .filter(|name| !name.to_bytes().is_empty()) + .unwrap_or(host_z); + let session = ClientSession::new( + hostname.to_vec(), + port, + reject, + tls_props.clone(), + client.pool.id, + ); let _ = H3::live_sessions.fetch_add(1, Ordering::Relaxed); // `session` was just allocated by ClientSession::new — `session_mut` // upgrades the fresh heap pointer (sole owner) for these set-up writes. @@ -142,9 +162,14 @@ impl ClientContext { self.sessions.push(session); session_mut(session).enqueue(client); - let result = - self.qctx_mut() - .connect(host_z, port, host_z, reject, session.cast::()); + let result = self.qctx_mut().connect( + host_z, + port, + sni, + reject, + tls_options.as_ref(), + session.cast::(), + ); match result { ConnectResult::Socket(qs) => { session_mut(session).qsocket = NonNull::new(qs); diff --git a/src/http/h3_client/ClientSession.rs b/src/http/h3_client/ClientSession.rs index 2e99a6bc3e0a..e49051f8f493 100644 --- a/src/http/h3_client/ClientSession.rs +++ b/src/http/h3_client/ClientSession.rs @@ -17,6 +17,7 @@ use crate::h3_client as H3; use crate::http_thread::WriteMessageType; use crate::internal_state::HTTPStage; use crate::signals::Field as Signal; +use crate::ssl_config::{SSLConfig, SharedPtr}; use crate::{HTTPClient, HeaderResult, Protocol}; use crate::h3_client::h3_client; @@ -34,10 +35,14 @@ pub struct ClientSession { pub(crate) hostname: Vec, pub(crate) port: u16, pub(crate) reject_unauthorized: bool, + /// The interned TLS options the connection was opened with, `None` for the default context. + pub(crate) tls_props: Option, /// The fetch session whose requests may share this connection. pub(crate) pool_id: u64, pub(crate) handshake_done: bool, pub(crate) closed: bool, + /// `close_if_idle` closed the connection, so its close says nothing about the peer. + pub(crate) closed_by_us: bool, pub(crate) registry_index: u32, /// Requests waiting for `onStreamOpen` to hand them a stream. Order is @@ -53,6 +58,7 @@ impl ClientSession { hostname: Vec, port: u16, reject_unauthorized: bool, + tls_props: Option, pool_id: u64, ) -> *mut ClientSession { bun_core::heap::into_raw(Box::new(ClientSession { @@ -61,9 +67,11 @@ impl ClientSession { hostname, port, reject_unauthorized, + tls_props, pool_id, handshake_done: false, closed: false, + closed_by_us: false, registry_index: u32::MAX, pending: Vec::new(), })) @@ -74,12 +82,14 @@ impl ClientSession { hostname: &[u8], port: u16, reject_unauthorized: bool, + tls_props: Option<*const SSLConfig>, pool_id: u64, ) -> bool { !self.closed && self.port == port && self.pool_id == pool_id && self.reject_unauthorized == reject_unauthorized + && SSLConfig::raw_ptr(self.tls_props.as_ref()) == tls_props && strings::eql_long(&self.hostname, hostname, true) } @@ -105,6 +115,7 @@ impl ClientSession { if let Some(qs) = self.qsocket_mut() { // No later request may pick a connection that is going away. self.closed = true; + self.closed_by_us = true; qs.close(); } } @@ -182,13 +193,14 @@ impl ClientSession { let Some(client) = stream.client else { continue; }; - if client_mut(client).async_http_id != async_http_id { + let client = client_mut(client); + if client.async_http_id != async_http_id { continue; } - if core::mem::take(&mut stream.read_paused) { - if let Some(qs) = stream.qstream_mut() { - qs.want_read(true); - } + // The consumer can be paused again by the time this runs. + let paused = client.signals.is_receive_paused(); + if let Some(qs) = stream.qstream_mut() { + qs.hold_window(paused); } return true; } @@ -227,6 +239,9 @@ impl ClientSession { // the stream instead of leaking it on the pooled session. if abort || !request_body_done { qs.reset(); + } else { + // Trailers and the FIN can still arrive, and nothing else releases the hold. + qs.hold_window(false); } } st.qstream = None; @@ -270,6 +285,11 @@ impl ClientSession { // (NonNull → shared deref) reads the Copy `flags` field without // forming `&mut HTTPClient` across the `detach()` below. let client_ref = bun_ptr::ParentRef::from(client_ptr); + // An Alt-Svc upgrade whose connection never came up goes back to TCP (RFC 7838 §2.4). + if !self.handshake_done && client_ref.flags.forced_protocol != Some(Protocol::Http3) { + self.detach_with(stream, true); + return client_mut(client_ptr).h3_upgrade_failed(err); + } // RFC 9110 §9.2.2: the origin may have acted on a request that was written. let replay_safe = !st.headers_sent || st.peer_rejected || client_ref.method.is_idempotent(); if client_ref.flags.h3_retried || st.is_streaming_body || !replay_safe { @@ -300,7 +320,7 @@ impl ClientSession { // detach()'s aliasing write to `client.h3`. let client = client_mut(client_ptr); if !ClientContext::as_mut(ctx).connect(client, &host, port) { - client.fail_from_h2(err); + client.h3_upgrade_failed(err); } // `host` drops here (was `defer bun.default_allocator.free(host)`). } @@ -330,6 +350,27 @@ impl ClientSession { false } + /// The stream closed without a FIN (`deliver` detaches at the FIN), so a body that already started is truncated. + pub(crate) fn on_stream_closed(&mut self, stream: *mut Stream, peer_reset: bool) { + let st = stream_mut(stream); + if !st.headers_delivered { + return self.deliver(stream, true); + } + let aborted = st + .client + .is_some_and(|client| client_mut(client).signals.get(Signal::Aborted)); + self.fail( + stream, + if aborted { + crate::Error::Aborted + } else if peer_reset { + crate::Error::HTTP3StreamReset + } else { + crate::Error::ConnectionClosed + }, + ); + } + /// Runs from inside lsquic's process_conns via on_stream_{headers,data,close}. /// `done` = the lsquic stream is gone; deliver whatever is buffered then /// detach. Mirrors H2's `ClientSession.deliverStream` so the HTTPClient state diff --git a/src/http/h3_client/PendingConnect.rs b/src/http/h3_client/PendingConnect.rs index b605687b7ef7..cd11c277446f 100644 --- a/src/http/h3_client/PendingConnect.rs +++ b/src/http/h3_client/PendingConnect.rs @@ -146,7 +146,7 @@ impl PendingConnect { if let Some(cl) = cl { // HTTPClient outlives its h3 Stream; detach() nulled stream.client // but `cl` is alive — `client_mut` centralises this backref upgrade. - super::client_session::client_mut(cl).fail_from_h2(err); + super::client_session::client_mut(cl).h3_upgrade_failed(err); } } let _ = super::LIVE_SESSIONS.fetch_sub(1, Ordering::Relaxed); diff --git a/src/http/h3_client/Stream.rs b/src/http/h3_client/Stream.rs index 8ca99e8ca6c9..f0c1fa2391b4 100644 --- a/src/http/h3_client/Stream.rs +++ b/src/http/h3_client/Stream.rs @@ -37,7 +37,6 @@ pub struct Stream { pub(crate) request_body_done: bool, pub(crate) is_streaming_body: bool, pub(crate) headers_delivered: bool, - pub(crate) read_paused: bool, /// The origin did not process the request. It ended the stream with /// H3_REQUEST_REJECTED, or the stream is above the id in its GOAWAY. pub(crate) peer_rejected: bool, @@ -60,7 +59,6 @@ impl Stream { request_body_done: false, is_streaming_body: false, headers_delivered: false, - read_paused: false, peer_rejected: false, })) } diff --git a/src/http/h3_client/callbacks.rs b/src/http/h3_client/callbacks.rs index 075903502e13..9d6a8c188d00 100644 --- a/src/http/h3_client/callbacks.rs +++ b/src/http/h3_client/callbacks.rs @@ -144,6 +144,9 @@ extern "C" fn on_conn_close(qs: *mut quic::Socket) { if let Some(ctx) = ClientContext::get() { ClientContext::as_mut(ctx).unregister(session); } + if !session.handshake_done && !session.closed_by_us { + super::alt_svc::report_unreachable(&session.hostname, session.port); + } while !session.pending.is_empty() { // lsquic fires on_stream_close for every bound stream before // on_conn_closed, so anything still here never got a qstream. @@ -226,6 +229,7 @@ extern "C" fn on_stream_headers(s: *mut quic::Stream) { stream.decoded_headers.clear(); stream.decoded_headers.reserve(n as usize); let mut status: u16 = 0; + let mut seen_status = false; let mut i: c_uint = 0; while i < n { let Some(h) = s.header(i) else { @@ -236,7 +240,12 @@ extern "C" fn on_stream_headers(s: *mut quic::Stream) { let value = h.value_bytes(); if name.first() == Some(&b':') { if name == b":status" { - status = bun_core::fmt::parse_int::(value, 10).unwrap_or(0); + // A repeated `:status` is malformed, so a valid one can't replace a bad one. + status = match bun_http_types::parse_status_pseudo_header(value) { + Some(code) if !seen_status => code, + _ => 0, + }; + seen_status = true; } i += 1; continue; @@ -278,17 +287,15 @@ extern "C" fn on_stream_data(s: *mut quic::Stream, data: *const u8, len: c_uint, stream.session_mut().deliver(stream, fin != 0); let Some(stream) = stream_of(s) else { return }; - if fin != 0 || stream.read_paused { + if fin != 0 { return; } let Some(client) = stream.client else { return }; - if super::client_session::client_mut(client) - .signals - .is_receive_paused() - { - stream.read_paused = true; - s.want_read(false); - } + s.hold_window( + super::client_session::client_mut(client) + .signals + .is_receive_paused(), + ); } extern "C" fn on_stream_writable(s: *mut quic::Stream) { @@ -319,11 +326,13 @@ extern "C" fn on_stream_close(s: *mut quic::Stream) { *s.ext::() = None; stream.qstream = None; stream.peer_rejected = s.reset_code() == H3_REQUEST_REJECTED || s.refused_by_goaway(); + let peer_reset = s.reset_received(); bun_core::scoped_log!( h3_client, - "stream_close status={} delivered={}", + "stream_close status={} delivered={} peer_reset={}", stream.status_code, stream.headers_delivered, + peer_reset, ); - stream.session_mut().deliver(stream, true); + stream.session_mut().on_stream_closed(stream, peer_reset); } diff --git a/src/http/h3_client/encode.rs b/src/http/h3_client/encode.rs index 3bad80ab23d4..0963b0ef49c1 100644 --- a/src/http/h3_client/encode.rs +++ b/src/http/h3_client/encode.rs @@ -28,8 +28,6 @@ pub(crate) fn write_request( }; // `stream.client` is a live backref while attached — see `client_mut` doc. let client: &mut HTTPClient = super::client_session::client_mut(client_ptr); - // `build_request` returns a `Request<'_>` - // that mutably borrows `client`; capture every field we need first. let verbose = client.verbose; let href: &[u8] = client.url.href; let host: &[u8] = client.url.host; @@ -45,7 +43,12 @@ pub(crate) fn write_request( HTTPRequestBody::Bytes(_) ); - let request = client.build_request(body_len); + let mut header_overflow = Vec::new(); + let request = client.build_request(body_len, &mut header_overflow); + // lsxpack stores a field's length in 16 bits, so a longer method would be truncated. + if request.method.len() > usize::from(u16::MAX) { + return Err(crate::Error::HTTP3HeaderEncodingError); + } if verbose != HTTPVerboseLevel::None { let body = req_body.slice(); crate::print_request( diff --git a/src/http/lib.rs b/src/http/lib.rs index c59a51b1f5dc..d181700a400a 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -85,7 +85,7 @@ pub type HttpClient<'a> = HTTPClient<'a>; pub type AsyncHttp<'a> = AsyncHTTP<'a>; pub type ThreadlocalAsyncHttp<'a> = ThreadlocalAsyncHTTP<'a>; pub use bun_http_types::FetchRedirect::FetchRedirect; -pub use bun_http_types::Method::Method; +pub use bun_http_types::Method::{Method, MethodRef}; pub use bun_picohttp as picohttp; #[repr(u8)] @@ -149,6 +149,8 @@ pub struct HTTPResponseMetadata { pub url: bun_ptr::RawSlice, pub owned_buf: Box<[u8]>, pub response: bun_picohttp::Response<'static>, + /// The client decodes the body, so `Content-Encoding` no longer describes it. + pub did_decompress: bool, } impl HTTPResponseMetadata { @@ -236,6 +238,8 @@ pub struct Flags { pub(crate) protocol: Protocol, pub forced_protocol: Option, pub(crate) h3_retried: bool, + /// The request left its Alt-Svc alternative before it was written, so this hop stays on TCP. + pub(crate) h3_alt_svc_failed: bool, pub is_node_http_client: bool, pub redirect_credentials: RedirectCredentialsPolicy, /// While all connections are taken, this request lets the ones without the flag go first. @@ -262,6 +266,7 @@ impl Default for Flags { protocol: Protocol::Http1_1, forced_protocol: None, h3_retried: false, + h3_alt_svc_failed: false, is_node_http_client: false, redirect_credentials: RedirectCredentialsPolicy::SameOrigin, yields_to_other_requests: false, @@ -363,6 +368,30 @@ pub fn normalize_idle_timeout_seconds(raw: u64) -> c_uint { }) as c_uint } +/// Stop reusing a connection this long before the origin's `Keep-Alive: timeout=N` runs out (undici's `keepAliveTimeoutThreshold`). +const KEEPALIVE_TIMEOUT_MARGIN_SECONDS: u32 = 2; + +/// Returns `N` from a `Keep-Alive: timeout=N, max=M` header value (RFC 9112 appendix C.2.2). +fn parse_keepalive_timeout(value: &[u8]) -> Option { + for param in HeaderValueIterator::init(value) { + let Some((name, rest)) = strings::split_once_char(param, b'=') else { + continue; + }; + if !strings::eql_case_insensitive_ascii(name.trim_ascii_end(), b"timeout", true) { + continue; + } + let rest = rest.trim_ascii_start(); + let digits = &rest[..rest.iter().take_while(|c| c.is_ascii_digit()).count()]; + if digits.is_empty() { + return None; + } + return Some(digits.iter().fold(0u32, |n, c| { + n.saturating_mul(10).saturating_add(u32::from(c - b'0')) + })); + } + None +} + pub const END_OF_CHUNKED_HTTP1_1_ENCODING_RESPONSE_BODY: &[u8] = b"0\r\n\r\n"; /// HTTP-thread-only scratch buffer for building NUL-terminated hostnames. @@ -519,6 +548,15 @@ pub struct PoolOptions { pub max_idle_sockets: u16, } +impl PoolOptions { + pub(crate) fn idle_timeout_or_default(self) -> u32 { + match self.idle_timeout_seconds { + 0 => 5 * 60, + seconds => seconds, + } + } +} + impl<'a> HTTPClientResult<'a> { pub fn abort_reason(&self) -> Option { if self.is_timeout() { @@ -778,7 +816,7 @@ impl ProxySettings { // Intrusive raw-pointer backrefs (socket ext, h2/h3 streams) store the // lifetime-erased `HTTPClient<'static>` form via [`HTTPClient::as_erased_ptr`]. pub struct HTTPClient<'a> { - pub(crate) method: Method, + pub(crate) method: MethodRef, pub header_entries: headers::EntryList, pub(crate) header_buf: &'a [u8], pub(crate) url: URL<'a>, @@ -1085,18 +1123,22 @@ impl RedirectHop { } // ── shared per-thread buffers ─────────────────────────────────────────── -// All four are HTTP-thread-only scratch (single uws loop thread); `RacyCell` +// All of these are HTTP-thread-only scratch (single uws loop thread); `RacyCell` // is the alias-safe static cell per docs/PORTING.md §Global mutable state. // we always rewrite the entire HTTP request when write() returns EAGAIN // so we can reuse this buffer -const MAX_REQUEST_HEADERS: usize = 256; -static SHARED_REQUEST_HEADERS_BUF: bun_core::RacyCell<[picohttp::Header; MAX_REQUEST_HEADERS]> = - bun_core::RacyCell::new([picohttp::Header::ZERO; MAX_REQUEST_HEADERS]); +const SHARED_REQUEST_HEADERS_LEN: usize = 256; +static SHARED_REQUEST_HEADERS_BUF: bun_core::RacyCell< + [picohttp::Header; SHARED_REQUEST_HEADERS_LEN], +> = bun_core::RacyCell::new([picohttp::Header::ZERO; SHARED_REQUEST_HEADERS_LEN]); + +/// Most header fields an HTTP/1.1 response can have, same as node:http's client. +const MAX_RESPONSE_HEADERS: usize = 1000; // this doesn't need to be stack memory because it is immediately cloned after use -static SHARED_RESPONSE_HEADERS_BUF: bun_core::RacyCell<[picohttp::Header; 256]> = - bun_core::RacyCell::new([picohttp::Header::ZERO; 256]); +static SHARED_RESPONSE_HEADERS_BUF: bun_core::RacyCell<[picohttp::Header; MAX_RESPONSE_HEADERS]> = + bun_core::RacyCell::new([picohttp::Header::ZERO; MAX_RESPONSE_HEADERS]); // the first packet for Transfer-Encoding: chunked // is usually pretty small or sometimes even just a length @@ -1104,6 +1146,10 @@ static SHARED_RESPONSE_HEADERS_BUF: bun_core::RacyCell<[picohttp::Header; 256]> static SINGLE_PACKET_SMALL_BUFFER: bun_core::RacyCell<[u8; 16 * 1024]> = bun_core::RacyCell::new([0; 16 * 1024]); +// Zero-initialized so it lands in .bss. +static FILE_BODY_COPY_BUFFER: bun_core::RacyCell<[u8; 256 * 1024]> = + bun_core::RacyCell::new([0; 256 * 1024]); + /// Accessors for the HTTP-thread-only `RacyCell` scratch buffers. /// /// INVARIANT: every caller is on the dedicated HTTP thread (the only thread @@ -1114,12 +1160,12 @@ static SINGLE_PACKET_SMALL_BUFFER: bun_core::RacyCell<[u8; 16 * 1024]> = mod scratch { use super::*; #[inline] - pub(super) fn request_headers() -> &'static mut [picohttp::Header; MAX_REQUEST_HEADERS] { + pub(super) fn request_headers() -> &'static mut [picohttp::Header; SHARED_REQUEST_HEADERS_LEN] { // SAFETY: see module-level INVARIANT. unsafe { &mut *SHARED_REQUEST_HEADERS_BUF.get() } } #[inline] - pub(super) fn response_headers() -> &'static mut [picohttp::Header; 256] { + pub(super) fn response_headers() -> &'static mut [picohttp::Header; MAX_RESPONSE_HEADERS] { // SAFETY: see module-level INVARIANT. unsafe { &mut *SHARED_RESPONSE_HEADERS_BUF.get() } } @@ -1133,8 +1179,13 @@ mod scratch { // SAFETY: see module-level INVARIANT. unsafe { &mut *TEMP_HOSTNAME.get() } } + #[inline] + pub(crate) fn file_body_copy_buffer() -> &'static mut [u8; 256 * 1024] { + // SAFETY: see module-level INVARIANT. + unsafe { &mut *FILE_BODY_COPY_BUFFER.get() } + } } -pub(crate) use scratch::temp_hostname; +pub(crate) use scratch::{file_body_copy_buffer, temp_hostname}; // ── ALPN offer enum ───────────────────────────────────────────────────── // bun_boringssl doesn't expose an ALPN-offer enum, so @@ -1453,8 +1504,11 @@ pub(crate) fn print_request( Protocol::Http2 => "HTTP/2", Protocol::Http3 => "HTTP/3", }; + // `\\>` is a literal `>`. The pretty formatter drops a bare one as the end of a tag. + let prefix = picohttp::trace_line_prefix(); bun_core::pretty_errorln!( - "> {} {} {}", + "{}\\> {} {} {}", + prefix, ver, BStr::new(request.method), EscapeControlChars(bun_core::fmt::redacted_npm_url(url)), @@ -1467,12 +1521,13 @@ pub(crate) fn print_request( let value = header.value(); let scheme_len = strings::index_of_char_usize(value, b' ').map_or(0, |i| i + 1); bun_core::pretty_errorln!( - "> {}: {}[redacted]", + "{}\\> {}: {}[redacted]", + prefix, escape_control_chars(name), escape_control_chars(&value[..scheme_len]), ); } else { - bun_core::pretty_errorln!("> {}", header); + bun_core::pretty_errorln!("{}\\> {}", prefix, header); } } Output::flush(); @@ -1675,6 +1730,7 @@ impl<'a> HTTPClient<'a> { /// Common tail of `fail` / `fail_from_h2` / `complete_connecting_process`: /// build the result, reset request state, and dispatch the callback. fn dispatch_result_and_reset(&mut self, clear_proxy_tunneling: bool) { + self.report_body_ahead_of_failure(); let callback = self.result_callback; let result = self.to_result(); self.state.reset(); @@ -1696,6 +1752,18 @@ impl<'a> HTTPClient<'a> { } callback.run(self.parent_async_http(), result); } + fn report_body_ahead_of_failure(&mut self) { + if !core::mem::take(&mut self.state.flags.has_body_ahead_of_failure) { + return; + } + let fail = self.state.fail.take(); + let mut result = self.to_result(); + self.state.fail = fail; + result.has_more = true; + let decoded_body = core::mem::take(&mut self.state.decoded_body); + result.body = decoded_body.list.as_slice(); + self.result_callback.run(self.parent_async_http(), result); + } #[inline] fn progress_node_mut(&mut self) -> Option<&mut bun_core::Progress::Node> { // SAFETY: progress_node is owned by the caller (e.g. `bun install`'s @@ -2117,15 +2185,16 @@ impl<'a> HTTPClient<'a> { if self.has_tls_options_unsupported_by_h3() { return false; } - h3_alt_svc_enabled() + h3_alt_svc_enabled() && !self.flags.h3_alt_svc_failed } + /// QUIC only runs over TLS 1.3, where a `ciphers` list doesn't apply. fn has_tls_options_unsupported_by_h3(&self) -> bool { - self.signals.get(signals::Field::CertErrors) - || self - .tls_props - .as_ref() - .is_some_and(|tls| tls.get().requires_custom_request_ctx) + self.tls_props.as_ref().is_some_and(|tls| { + let max = tls.get().ssl_max_version; + !tls.get().ssl_ciphers.is_null() + || (max != 0 && max < i32::from(bun_boringssl_sys::TLS1_3_VERSION)) + }) } pub(crate) fn first_call(&mut self, socket: HttpSocket) { @@ -2189,9 +2258,46 @@ impl<'a> HTTPClient<'a> { pub(crate) fn retry_from_h2(&mut self) { debug_assert!(self.h2.is_none()); + self.h2_retries += 1; + self.restart(); + } + + /// Returns true if the request has no explicit `protocol` and its body can be sent twice. + pub(crate) fn can_retry_over_http1(&self) -> bool { + self.flags.forced_protocol.is_none() + && matches!(self.state.original_request_body, HTTPRequestBody::Bytes(_)) + } + + /// The server refused the stream with HTTP_1_1_REQUIRED. The request's redirects use HTTP/1.1 too. + pub(crate) fn retry_over_http1(&mut self) { + debug_assert!(self.h2.is_none()); + self.flags.forced_protocol = Some(Protocol::Http1_1); + self.restart(); + } + + /// The request lost its QUIC connection. If Alt-Svc routed it there and nothing was written yet, send it over TCP. + pub(crate) fn h3_upgrade_failed(&mut self, err: crate::Error) { + debug_assert!(self.h3.is_none()); + if self.flags.forced_protocol == Some(Protocol::Http3) + || self.state.request_stage != RequestStage::Pending + { + return self.fail_from_h2(err); + } + self.flags.h3_alt_svc_failed = true; + // Still inside this request's own `start_` (DNS answered inline), which goes on to TCP. + if self + .flags + .defer_terminal_dispatch_until_connecting_is_complete + { + return; + } + self.restart(); + } + + /// Starts the request over. Nothing of its body was consumed. + fn restart(&mut self) { self.unregister_abort_tracker(); self.flags.protocol = Protocol::Http1_1; - self.h2_retries += 1; let body = core::mem::replace( &mut self.state.original_request_body, HTTPRequestBody::Bytes(b""), @@ -2394,6 +2500,17 @@ impl<'a> HTTPClient<'a> { self.flags.pool_bypass == PoolBypass::ThisHop } + /// `self.pool` with its idle timeout lowered to what the response's `Keep-Alive: timeout=N` allows. + fn pool_for_release(&self) -> PoolOptions { + let Some(reuse_seconds) = self.state.keepalive_reuse_seconds else { + return self.pool; + }; + PoolOptions { + idle_timeout_seconds: self.pool.idle_timeout_or_default().min(reuse_seconds), + ..self.pool + } + } + /// Hash of the per-request tunnel discriminators beyond the (proxy, target /// url.hostname/port, ssl_config) tuple already covered by separate pool-key /// fields. Covers everything writeProxyConnect sends: all proxy_headers @@ -2510,12 +2627,30 @@ impl<'a> HTTPClient<'a> { &buf[ptr.offset as usize..end] } - pub(crate) fn build_request(&mut self, body_len: usize) -> picohttp::Request<'static> { + /// `overflow` backs the returned headers only when they don't fit the shared scratch buffer. + pub(crate) fn build_request<'h>( + &mut self, + body_len: usize, + overflow: &'h mut Vec, + ) -> picohttp::Request<'h> + where + 'a: 'h, + { let mut header_count: usize = 0; let header_entries = self.header_entries.slice(); let header_names = header_entries.items_name(); let header_values = header_entries.items_value(); - let request_headers_buf = scratch::request_headers(); + + // Connection, User-Agent, Accept, Host, Accept-Encoding, Content-Length/Transfer-Encoding. + const MAX_DEFAULT_HEADERS: usize = 6; + let max_headers = header_names.len() + MAX_DEFAULT_HEADERS; + let request_headers_buf: &'h mut [picohttp::Header] = + if max_headers <= SHARED_REQUEST_HEADERS_LEN { + scratch::request_headers().as_mut_slice() + } else { + overflow.resize(max_headers, picohttp::Header::ZERO); + overflow.as_mut_slice() + }; let mut override_accept_encoding = false; let mut override_accept_header = false; @@ -2524,39 +2659,25 @@ impl<'a> HTTPClient<'a> { let mut override_user_agent = false; let mut original_content_length: Option<&[u8]> = None; - // Reserve slots for default headers that may be appended after user headers - // (Connection, User-Agent, Accept, Host, Accept-Encoding, Content-Length/Transfer-Encoding). - const MAX_DEFAULT_HEADERS: usize = 6; - const MAX_USER_HEADERS: usize = MAX_REQUEST_HEADERS - MAX_DEFAULT_HEADERS; - for (i, head) in header_names.iter().enumerate() { let name = self.header_str(*head); // Hash it as lowercase let hash = hash_header_name(name); - // Whether this header will actually be written to the buffer. - // Override flags must only be set when the header is kept, otherwise - // the default header is suppressed but the user header is dropped, - // leaving the header entirely absent from the request. - let will_append = header_count < MAX_USER_HEADERS; - // Skip host and connection header // we manage those match hash { h if h == hash_header_const(b"Content-Length") => { - // Content-Length is always consumed (never written to the buffer). original_content_length = Some(self.header_str(header_values[i])); continue; } h if h == hash_header_const(b"Connection") => { - if will_append { - override_connection_header = true; - match connection_header_keep_alive(self.header_str(header_values[i])) { - Some(false) => { - self.flags.disable_keepalive = true; - } - _ => {} + override_connection_header = true; + match connection_header_keep_alive(self.header_str(header_values[i])) { + Some(false) => { + self.flags.disable_keepalive = true; } + _ => {} } } h if h == hash_header_const(b"if-modified-since") => { @@ -2569,30 +2690,20 @@ impl<'a> HTTPClient<'a> { } } h if h == hash_header_const(HOST_HEADER_NAME) => { - if will_append { - override_host_header = true; - } + override_host_header = true; } h if h == hash_header_const(b"Accept") => { - if will_append { - override_accept_header = true; - } + override_accept_header = true; } h if h == hash_header_const(b"User-Agent") => { - if will_append { - override_user_agent = true; - } + override_user_agent = true; } h if h == hash_header_const(b"Accept-Encoding") => { - if will_append { - override_accept_encoding = true; - } + override_accept_encoding = true; } h if h == hash_header_const(b"Upgrade") => { - if will_append { - if upgrade_header_is_not_h2(self.header_str(header_values[i])) { - self.flags.upgrade_state = HTTPUpgradeState::Pending; - } + if upgrade_header_is_not_h2(self.header_str(header_values[i])) { + self.flags.upgrade_state = HTTPUpgradeState::Pending; } } h if h == hash_header_const(CHUNKED_ENCODED_HEADER.name()) => { @@ -2602,11 +2713,6 @@ impl<'a> HTTPClient<'a> { _ => {} } - // Silently drop excess headers to stay within the fixed-size request header buffer. - if !will_append { - continue; - } - request_headers_buf[header_count] = picohttp::Header::new(name, self.header_str(header_values[i])); @@ -2639,7 +2745,8 @@ impl<'a> HTTPClient<'a> { header_count += 1; } - if body_len > 0 || self.method.has_request_body() { + // Only a verb from the table declares `Content-Length: 0` when it has no body. + if body_len > 0 || self.method.known().is_some_and(Method::has_request_body) { if self.flags.is_streaming_request_body { // `StreamFraming`, decided by the producer. An upgrade tunnels the bytes unframed. let framing = match &self.state.original_request_body { @@ -2684,19 +2791,15 @@ impl<'a> HTTPClient<'a> { header_count += 1; } - // SAFETY: every borrowed slice points into storage that outlives the - // returned `Request` — `Method::as_str()` is `'static`; `url.pathname` - // borrows `self.url` (lives for the client); `request_headers_buf` is - // the per-HTTP-thread `SHARED_REQUEST_HEADERS_BUF` static. Return as - // `'static` so callers don't pin `&mut self` for the rest of their fn. picohttp::Request { - method: self.method.as_str().as_bytes(), + method: match self.method { + MethodRef::Known(method) => method.as_str().as_bytes(), + MethodRef::Token(token) => self.header_str(token), + }, // SAFETY: `url.pathname` borrows `self.url`, which outlives the returned `Request`. path: unsafe { bun_ptr::detach_lifetime(self.url.pathname) }, minor_version: 1, - // SAFETY: `request_headers_buf` is the per-HTTP-thread - // `SHARED_REQUEST_HEADERS_BUF` static, outliving the returned `Request`. - headers: unsafe { bun_ptr::detach_lifetime(&request_headers_buf[0..header_count]) }, + headers: &request_headers_buf[..header_count], bytes_read: 0, } } @@ -2719,17 +2822,7 @@ impl<'a> HTTPClient<'a> { self.flags.is_streaming_request_body = false; } - // TODO: what we do with stream body? - let request_body: &[u8] = if self.state.flags.resend_request_body_on_redirect - && matches!(self.state.original_request_body, HTTPRequestBody::Bytes(_)) - { - match &self.state.original_request_body { - HTTPRequestBody::Bytes(b) => b, - _ => unreachable!(), - } - } else { - b"" - }; + let request_body = self.request_body_for_redirect(); self.state.response_message_buffer = MutableString::default(); @@ -2750,6 +2843,8 @@ impl<'a> HTTPClient<'a> { } else if self.is_keep_alive_possible() && self.is_request_fully_sent() && !socket.is_closed_or_has_error() + // The hop reuses this socket before a FIN behind the 3xx is read, and `on_close` only retries idempotent methods. + && self.method.is_idempotent() { bun_core::scoped_log!(fetch, "Keep-Alive release in redirect"); debug_assert!(!self.connected_url.hostname.is_empty()); @@ -2766,13 +2861,15 @@ impl<'a> HTTPClient<'a> { 0, None, self.unix_socket_path, - self.pool, + self.pool_for_release(), ); } else { GenHttpContext::::close_socket(socket); } - // Cleared after `release_socket` above, which keys the pool entry on it. - self.unix_socket_path = b""; + // With `unix` the URL's authority is a placeholder, so a same-origin hop stays on the socket. + if !is_same_origin_url(&self.connected_url, &self.url) { + self.unix_socket_path = b""; + } self.connected_url = URL::default(); // connected_url was the last borrower of the previous hop's URL buffer // (handleResponseMetadata already repointed this.url at the new one). @@ -2790,9 +2887,25 @@ impl<'a> HTTPClient<'a> { self.flags.proxy_tunneling = false; self.close_proxy_tunnel(false); self.flags.protocol = Protocol::Http1_1; + self.flags.h3_alt_svc_failed = false; self.reevaluate_proxy_for_redirect(); - self.start(HTTPRequestBody::Bytes(request_body)); + self.start(request_body); + } + + /// A `Stream` body only gets here on a 303, which is already a bodiless GET. + fn request_body_for_redirect(&self) -> HTTPRequestBody<'a> { + if !self.state.flags.resend_request_body_on_redirect { + return HTTPRequestBody::Bytes(b""); + } + match self.state.original_request_body { + HTTPRequestBody::Bytes(bytes) => HTTPRequestBody::Bytes(bytes), + HTTPRequestBody::Sendfile(mut sendfile) => { + sendfile.rewind(); + HTTPRequestBody::Sendfile(sendfile) + } + HTTPRequestBody::Stream(_) => HTTPRequestBody::Bytes(b""), + } } /// Re-resolve `http_proxy` against the post-redirect `self.url`. The @@ -2900,17 +3013,19 @@ impl<'a> HTTPClient<'a> { NonNull::new_unchecked(http_thread().uws_loop) }); if let Some(ctx) = h3_ctx { - if !h3::ClientContext::as_mut(ctx).connect( + if h3::ClientContext::as_mut(ctx).connect( self, strip_ipv6_brackets(self.url.hostname), alt_port, - ) { - self.fail(crate::Error::ConnectionRefused); + ) && !self.flags.h3_alt_svc_failed + { + self.complete_connecting_process(); + return; } - self.complete_connecting_process(); - return; + // The request is on no QUIC session, and nothing of it was written. + self.flags.protocol = Protocol::Http1_1; } - // engine init failed: fall through to TCP + // engine init or the connect failed: fall through to TCP } } } @@ -3112,7 +3227,8 @@ impl<'a> HTTPClient<'a> { let writer = &mut temporary_send_buffer; // Vec impls bun_io::Write - let request = self.build_request(self.body_len_for_send()); + let mut header_overflow = Vec::new(); + let request = self.build_request(self.body_len_for_send(), &mut header_overflow); if self.http_proxy.is_some() { if self.url.is_https() { @@ -3532,27 +3648,26 @@ impl<'a> HTTPClient<'a> { self.flush_stream::(socket); } HTTPRequestBody::Sendfile(sendfile) => { - if IS_SSL { - panic!( - "sendfile is only supported without SSL. This code should never have been reached!" - ); - } - - // sendfile.write() takes the raw fd, not the socket handle. - match sendfile.write(socket.fd()) { - #[cfg(not(windows))] + // sendfile(2) can't write through TLS, and XNU's can sleep uninterruptibly when mbufs run out. + let copy = IS_SSL || cfg!(target_os = "macos"); + let status = if copy { + sendfile.write_copy(|chunk| write_to_socket::(socket, chunk)) + } else { + // sendfile.write() takes the raw fd, not the socket handle. + sendfile.write(socket.fd()) + }; + match status { crate::send_file::Status::Done => { self.state.request_stage = RequestStage::Done; return; } - #[cfg(not(windows))] crate::send_file::Status::Err(err) => { self.close_and_fail::(err, socket); return; } + // A short `socket.write()` already polls for writable. + crate::send_file::Status::Again if copy => {} crate::send_file::Status::Again => { - // mark_needs_more_for_sendfile is `const SSL=false`-only; - // this arm is unreachable for SSL (panic above). uws::SocketTCP::from_any(socket.socket) .mark_needs_more_for_sendfile(); } @@ -3567,10 +3682,11 @@ impl<'a> HTTPClient<'a> { // the tunnel is a disjoint heap allocation (see // `proxy_tunnel::raw_as_mut` INVARIANT). let proxy = proxy_tunnel::raw_as_mut(proxy_ptr); - match &self.state.original_request_body { + if !self.state.original_request_body.is_stream() { + self.set_timeout(&socket); + } + match &mut self.state.original_request_body { HTTPRequestBody::Bytes(_) => { - self.set_timeout(&socket); - let to_send = self.request_body(); // just wait and retry when onWritable! if closed internally will call proxy.onClose let Ok(sent) = ProxyTunnel::write(proxy, to_send) else { @@ -3589,10 +3705,31 @@ impl<'a> HTTPClient<'a> { HTTPRequestBody::Stream(_) => { self.flush_stream::(socket); } - HTTPRequestBody::Sendfile(_) => { - panic!( - "sendfile is only supported without SSL. This code should never have been reached!" - ); + HTTPRequestBody::Sendfile(sendfile) => { + let mut tunnel_write_failed = false; + let status = sendfile.write_copy(|chunk| { + // The tunnel buffers the ciphertext the socket didn't take. Wait for that to drain. + if proxy.write_buffer.is_not_empty() { + return Ok(0); + } + ProxyTunnel::write(proxy, chunk) + .inspect_err(|_| tunnel_write_failed = true) + }); + // just wait and retry when onWritable! if closed internally will call proxy.onClose + if tunnel_write_failed { + return; + } + match status { + crate::send_file::Status::Done => { + self.state.request_stage = RequestStage::Done; + return; + } + crate::send_file::Status::Err(err) => { + self.close_and_fail::(err, socket); + return; + } + crate::send_file::Status::Again => {} + } } } } @@ -3614,7 +3751,9 @@ impl<'a> HTTPClient<'a> { let mut temporary_send_buffer: Vec = Vec::with_capacity(16 * 1024); let writer = &mut temporary_send_buffer; - let request = self.build_request(self.body_len_for_send()); + let mut header_overflow = Vec::new(); + let request = + self.build_request(self.body_len_for_send(), &mut header_overflow); if let Err(e) = write_request(writer, &request) { self.close_and_fail::(e, socket); return; @@ -3901,14 +4040,6 @@ impl<'a> HTTPClient<'a> { // Headers complete: start the body-idle window fresh (see [`IDLE_TIMEOUT_SECONDS`]). self.set_timeout(&socket); - if (self.state.content_encoding_i as usize) < response.headers.list.len() - && !self.state.flags.did_set_content_encoding - { - // if it compressed with this header, it is no longer because we will decompress it - self.state.flags.did_set_content_encoding = true; - self.state.content_encoding_i = u8::MAX; - } - if should_continue == ShouldContinue::Finished { if !to_read.is_empty() { self.state.flags.allow_keepalive = false; @@ -4183,6 +4314,7 @@ impl<'a> HTTPClient<'a> { owned_buf, response: cloned_response, url: href, + did_decompress: self.state.encoding.is_compressed(), }); } @@ -4421,7 +4553,7 @@ impl<'a> HTTPClient<'a> { }, None, self.unix_socket_path, - self.pool, + self.pool_for_release(), ); } else { if self.proxy_tunnel.is_some() { @@ -4507,16 +4639,7 @@ impl<'a> HTTPClient<'a> { self.flags.is_streaming_request_body = false; } self.unix_socket_path = b""; - let request_body: &[u8] = if self.state.flags.resend_request_body_on_redirect - && matches!(self.state.original_request_body, HTTPRequestBody::Bytes(_)) - { - match &self.state.original_request_body { - HTTPRequestBody::Bytes(b) => b, - _ => unreachable!(), - } - } else { - b"" - }; + let request_body = self.request_body_for_redirect(); self.state.response_message_buffer = MutableString::default(); self.remaining_redirect_count = self.remaining_redirect_count.saturating_sub(1); self.flags.redirected = true; @@ -4531,8 +4654,9 @@ impl<'a> HTTPClient<'a> { self.state.reset(); self.flags.proxy_tunneling = false; self.flags.protocol = Protocol::Http1_1; + self.flags.h3_alt_svc_failed = false; self.reevaluate_proxy_for_redirect(); - self.start(HTTPRequestBody::Bytes(request_body)); + self.start(request_body); } pub(crate) fn progress_update_h3(&mut self) { @@ -4842,6 +4966,27 @@ impl<'a> HTTPClient<'a> { } } + /// Fails the body at a malformed chunk. A `fetch()` reader still gets the chunks decoded ahead of it, like in Node. + fn on_malformed_chunk(&mut self) -> crate::Error { + self.state.flags.has_body_ahead_of_failure = self.signals.body_receive_mode.is_some() + && !self.state.encoding.is_compressed() + // If the head wasn't reported yet, the Response is built from the failure and has no readable body. + && self.state.cloned_metadata.is_none() + && !self.state.decoded_body.list.is_empty(); + crate::Error::InvalidHTTPResponse + } + + /// `undecoded` is what `phr_decode_chunked` returned: how many bytes of `incoming_data` follow the last chunk. + fn on_last_chunk(&mut self, incoming_data: &[u8], undecoded: isize) { + self.state.flags.received_last_chunk = true; + let trailing = + &incoming_data[incoming_data.len().saturating_sub(undecoded.unsigned_abs())..]; + // Nothing is pipelined, so the peer lost framing. A repeated terminator is harmless, as it is on an idle socket. + if !trailing.is_empty() && trailing != END_OF_CHUNKED_HTTP1_1_ENCODING_RESPONSE_BODY { + self.state.flags.allow_keepalive = false; + } + } + fn handle_response_body_chunked_encoding_from_multiple_packets( &mut self, incoming_data: &[u8], @@ -4888,7 +5033,7 @@ impl<'a> HTTPClient<'a> { match pret { // Invalid HTTP response body - -1 => return Err(crate::Error::InvalidHTTPResponse), + -1 => return Err(self.on_malformed_chunk()), // Needs more data -2 => { self.report_progress(buffer_len); @@ -4905,7 +5050,7 @@ impl<'a> HTTPClient<'a> { } // Done _ => { - self.state.flags.received_last_chunk = true; + self.on_last_chunk(incoming_data, pret); let processed = self.process_received_body(true)?; self.report_progress(buffer_len); @@ -4957,7 +5102,10 @@ impl<'a> HTTPClient<'a> { ); match pret { // Invalid HTTP response body - -1 => Err(crate::Error::InvalidHTTPResponse), + -1 => { + self.state.get_body_buffer().append_slice_exact(buffer)?; + Err(self.on_malformed_chunk()) + } // Needs more data -2 => { self.report_progress(buffer.len()); @@ -4977,7 +5125,7 @@ impl<'a> HTTPClient<'a> { } // Done _ => { - self.state.flags.received_last_chunk = true; + self.on_last_chunk(incoming_data, pret); self.handle_response_body_from_single_packet(buffer)?; debug_assert!(self.state.decoded_body.list.as_ptr() != buffer.as_ptr()); self.report_progress(buffer.len()); @@ -5007,7 +5155,7 @@ impl<'a> HTTPClient<'a> { let mut is_server_sent_events = false; let mut content_codings: u32 = 0; let mut has_keep_alive_token = false; - for (header_i, header) in response.headers.list.iter().enumerate() { + for header in response.headers.list.iter() { match hash_header_name(header.name()) { h if h == hash_header_const(b"Content-Length") => { // byte-level parse — header.value() is network bytes, not &str @@ -5048,13 +5196,11 @@ impl<'a> HTTPClient<'a> { Some(Encoding::Identity) => {} Some(coding) if coding.is_compressed() && content_codings == 0 => { self.state.encoding = coding; - self.state.content_encoding_i = header_i as u8; content_codings = 1; } // Stacked or unknown codings: we can only strip one layer, so pass through raw. _ => { self.state.encoding = Encoding::Identity; - self.state.content_encoding_i = u8::MAX; content_codings = u32::MAX; } } @@ -5075,6 +5221,17 @@ impl<'a> HTTPClient<'a> { None => {} } } + h if h == hash_header_const(b"Keep-Alive") => { + if let Some(timeout) = parse_keepalive_timeout(header.value()) { + match timeout.saturating_sub(KEEPALIVE_TIMEOUT_MARGIN_SECONDS) { + 0 => self.state.flags.allow_keepalive = false, + seconds => { + let reuse = &mut self.state.keepalive_reuse_seconds; + *reuse = Some(reuse.map_or(seconds, |prev| prev.min(seconds))); + } + } + } + } h if h == hash_header_const(b"Last-Modified") => { pretend_304 = self.flags.force_last_modified && response.status_code > 199 @@ -5108,6 +5265,8 @@ impl<'a> HTTPClient<'a> { if pretend_304 { response.status_code = 304; + // The 200's body is still on the wire. + self.state.flags.allow_keepalive = false; } // According to RFC 7230 section 3.3.3: @@ -5123,6 +5282,8 @@ impl<'a> HTTPClient<'a> { || response.status_code == 304 { self.state.content_length = Some(0); + // RFC 9112 §6.1 lets a 304 carry Transfer-Encoding to describe the 200 it stands in for. + self.state.transfer_encoding = Encoding::Identity; } // Don't do this for proxies because those connections will be open for awhile. @@ -5190,7 +5351,10 @@ impl<'a> HTTPClient<'a> { let hop: RedirectHop; { - if let Some(i) = strings::index_of(location, b"://") { + // RFC 3986 §3.1: a scheme can't contain '/', '?' or '#'. + let scheme_end = strings::index_of(location, b"://") + .filter(|&i| strings::index_of_any(&location[..i], b"/?#").is_none()); + if let Some(i) = scheme_end { let mut string_builder = StringBuilder::default(); let is_protocol_relative = i == 0; @@ -5240,7 +5404,7 @@ impl<'a> HTTPClient<'a> { debug_assert!(string_builder.cap == string_builder.len); let input = BunString::borrow_utf8(string_builder.allocated_slice()); - let normalized_url = bun_url::href_from_string(&input); + let normalized_url = bun_url::href_from_string_without_fragment(&input); if normalized_url.tag() == BunStringTag::Dead { // URL__getHref failed, dont pass dead tagged string to toOwnedSlice. return Err(crate::Error::RedirectURLInvalid); @@ -5292,7 +5456,7 @@ impl<'a> HTTPClient<'a> { debug_assert!(string_builder.cap == string_builder.len); let input = BunString::borrow_utf8(string_builder.allocated_slice()); - let normalized_url = bun_url::href_from_string(&input); + let normalized_url = bun_url::href_from_string_without_fragment(&input); if normalized_url.tag() == BunStringTag::Dead { return Err(crate::Error::RedirectURLInvalid); } @@ -5312,7 +5476,7 @@ impl<'a> HTTPClient<'a> { let base = BunString::borrow_utf8(original_url.href); let rel = BunString::borrow_utf8(location); - let new_url_ = bun_url::join(&base, &rel); + let new_url_ = bun_url::join_without_fragment(&base, &rel); if new_url_.is_empty() { return Err(crate::Error::InvalidRedirectURL); @@ -5342,7 +5506,7 @@ impl<'a> HTTPClient<'a> { && self.method != Method::HEAD) { // - Set request's method to `GET` and request's body to null. - self.method = Method::GET; + self.method = Method::GET.into(); // https://github.com/oven-sh/bun/issues/6053 if self.header_entries.len() > 0 { diff --git a/src/http/lshpack.rs b/src/http/lshpack.rs index d6af265cef42..2689f88f1c43 100644 --- a/src/http/lshpack.rs +++ b/src/http/lshpack.rs @@ -48,6 +48,93 @@ pub enum HpackError { UnableToEncode, } +/// The fields of one outbound header block, staged for [`HPACK::encode_block`]. +#[derive(Default)] +pub struct HeaderBlock { + /// Each name followed by its value, in field order. + bytes: Vec, + /// Two entries per field: the name length with `never_index` in bit 31, then the value length. + fields: Vec, +} + +impl HeaderBlock { + const NEVER_INDEX: u32 = 1 << 31; + + pub fn push( + &mut self, + name: &[u8], + value: &[u8], + never_index: bool, + ) -> Result<(), bun_alloc::AllocError> { + // Treat a length that overflows its entry like a failed allocation. + let (Ok(name_len), Ok(value_len)) = (u32::try_from(name.len()), u32::try_from(value.len())) + else { + return Err(bun_alloc::AllocError); + }; + if name_len >= Self::NEVER_INDEX { + return Err(bun_alloc::AllocError); + } + self.bytes + .try_reserve(name.len() + value.len()) + .map_err(|_| bun_alloc::AllocError)?; + self.fields + .try_reserve(2) + .map_err(|_| bun_alloc::AllocError)?; + self.bytes.extend_from_slice(name); + self.bytes.extend_from_slice(value); + self.fields.push(if never_index { + name_len | Self::NEVER_INDEX + } else { + name_len + }); + self.fields.push(value_len); + Ok(()) + } + + pub fn clear(&mut self) { + self.bytes.clear(); + self.fields.clear(); + } + + /// Value of the first field named `name`. + pub fn get(&self, name: &[u8]) -> Option<&[u8]> { + let mut rest = self.bytes.as_slice(); + for field in self.fields.chunks_exact(2) { + let (field_name, after_name) = rest.split_at((field[0] & !Self::NEVER_INDEX) as usize); + let (value, after_value) = after_name.split_at(field[1] as usize); + if field_name == name { + return Some(value); + } + rest = after_value; + } + None + } + + /// Number of fields. + pub fn len(&self) -> usize { + self.fields.len() / 2 + } + + pub fn is_empty(&self) -> bool { + self.fields.is_empty() + } + + /// Heap bytes retained after `clear()`. + pub fn capacity(&self) -> usize { + self.bytes.capacity() + self.fields.capacity() * core::mem::size_of::() + } + + /// https://github.com/nodejs/node/blob/v26.3.0/deps/nghttp2/lib/nghttp2_hd.c#L1578-L1603 + pub fn deflate_bound(&self) -> usize { + 12 + 12 * self.len() + self.bytes.len() + } + + /// Upper bound of the encoded size. + pub fn encode_bound(&self) -> usize { + self.bytes.len() + 32 * self.len() + } +} + impl HPACK { /// `name` and `value` point into a thread-local buffer that every `HPACK` on this thread decodes and encodes /// through. The borrow only stops this instance from overwriting them. @@ -85,6 +172,41 @@ impl HPACK { }) } + /// Appends the encoding of all of `block` to `dst`. `Err` appends nothing and leaves the table unchanged. + pub fn encode_block( + &mut self, + block: &HeaderBlock, + dst: &mut Vec, + ) -> Result<(), HpackError> { + if block.is_empty() { + return Ok(()); + } + let start = dst.len(); + dst.resize(start + block.encode_bound(), 0); + let out = &mut dst[start..]; + // SAFETY: `HeaderBlock::push` is the only writer of `bytes` and `fields`, so the lengths + // in `fields` add up to `bytes`. The C side writes at most `out.len()` bytes at `out`. + let written = unsafe { + lshpack_wrapper_encode_block( + self, + block.bytes.as_ptr(), + block.fields.as_ptr(), + block.len(), + out.as_mut_ptr(), + out.len(), + ) + }; + // lshpack could not allocate a table entry after it inserted earlier fields. + if written == usize::MAX { + bun_core::out_of_memory(); + } + dst.truncate(start + written); + if written == 0 { + return Err(HpackError::UnableToEncode); + } + Ok(()) + } + /// encode name, value with never_index option into dst_buffer /// if name + value length is greater than LSHPACK_MAX_HEADER_SIZE this will return UnableToEncode pub fn encode( @@ -126,6 +248,11 @@ impl HPACK { lshpack_wrapper_enc_set_max_capacity(self, max_capacity as c_uint); } + /// Sets both the table size and the limit on the peer's RFC 7541 §6.3 size updates. + pub fn set_decoder_max_capacity(&mut self, max_capacity: u32) { + lshpack_wrapper_dec_set_max_capacity(self, max_capacity as c_uint); + } + // Raw `*mut HPACK` teardown is subsumed by the // safe [`HpackHandle`] RAII wrapper below — every owner holds an // `HpackHandle`, so the raw destructor is private to `HpackHandle::drop`. @@ -208,6 +335,7 @@ unsafe extern "C" { // Only precondition is a valid non-null `*HPACK`; `&mut HPACK` (ABI-identical // thin pointer) discharges it at the type level, so this is `safe fn`. safe fn lshpack_wrapper_enc_set_max_capacity(self_: &mut HPACK, max_capacity: c_uint); + safe fn lshpack_wrapper_dec_set_max_capacity(self_: &mut HPACK, max_capacity: c_uint); // Frees `self_` (lshpack_{enc,dec}_cleanup + mi_free) — ownership transfer, // so this keeps its raw-pointer signature and caller-side safety obligation. fn lshpack_wrapper_deinit(self_: *mut HPACK); @@ -235,4 +363,13 @@ unsafe extern "C" { buffer_len: usize, buffer_offset: usize, ) -> usize; + // `fields` holds `field_count` pairs of u32 that describe `bytes` (see `HeaderBlock`). + fn lshpack_wrapper_encode_block( + self_: &mut HPACK, + bytes: *const u8, + fields: *const u32, + field_count: usize, + dst: *mut u8, + dst_len: usize, + ) -> usize; } diff --git a/src/http_jsc/method_jsc.rs b/src/http_jsc/method_jsc.rs index f4ca5ac88823..bb64388e0c15 100644 --- a/src/http_jsc/method_jsc.rs +++ b/src/http_jsc/method_jsc.rs @@ -1,7 +1,7 @@ //! JSC bridge for `bun_http_types::Method`. Keeps `bun_http_types` free of JSC types. use bun_core::String as BunString; -use bun_http_types::Method::Method; +use bun_http_types::Method::{Method, OwnedMethod}; use bun_jsc::{JSGlobalObject, JSValue, JsResult, StringJsc as _}; unsafe extern "C" { @@ -25,6 +25,21 @@ pub fn from_js(global_this: &JSGlobalObject, input: JSValue) -> JsResult JsResult { + let str = BunString::from_js(input, global_this)?; + let utf8 = str.to_utf8(); + Ok(match Method::which(utf8.slice()) { + Some(method) => method.into(), + // `method: isPost && "POST"` means GET when `isPost` is false. + None if !input.is_string() => Method::GET.into(), + None => OwnedMethod::from_unlisted(utf8.slice()), + }) +} + /// Extension trait providing `.to_js()` on `Method` (lives in the `*_jsc` crate so the /// base `bun_http_types` crate has no `bun_jsc` dependency). pub trait MethodJsc { diff --git a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs index 3d0321bc9481..31fff185f94c 100644 --- a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs +++ b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs @@ -887,9 +887,10 @@ where this.interim_len.set(0); return on_head(&response, &unparsed[response.bytes_read..]); } - Err(picohttp::ParseResponseError::MalformedHttpResponse) => { - ErrorCode::InvalidResponse - } + Err( + picohttp::ParseResponseError::MalformedHttpResponse + | picohttp::ParseResponseError::TooManyHeaders, + ) => ErrorCode::InvalidResponse, // picohttpparser validates nothing until it has 9 bytes, so check the prefix here. Err(picohttp::ParseResponseError::ShortRead) if !b"HTTP/1.".starts_with(&window[..window.len().min(7)]) => diff --git a/src/http_types/ETag.rs b/src/http_types/ETag.rs index 24c72bc58a04..0a434aa260e8 100644 --- a/src/http_types/ETag.rs +++ b/src/http_types/ETag.rs @@ -270,6 +270,19 @@ impl Headers { None } + /// Removes every entry named `name` (ASCII case-insensitive). + pub fn remove(&mut self, name: &[u8]) { + let mut i = 0; + while i < self.entries.len() { + let name_ptr = self.entries.items_name()[i]; + if strings::eql_case_insensitive_ascii(self.as_str(name_ptr), name, true) { + self.entries.ordered_remove(i); + } else { + i += 1; + } + } + } + // The only fallible calls are allocations — abort on OOM. pub fn append(&mut self, name: &[u8], value: &[u8]) { let mut offset: u32 = u32::try_from(self.buf.len()).unwrap(); diff --git a/src/http_types/FetchRedirect.rs b/src/http_types/FetchRedirect.rs index b05e7b418df3..35f88c37a4de 100644 --- a/src/http_types/FetchRedirect.rs +++ b/src/http_types/FetchRedirect.rs @@ -34,4 +34,5 @@ pub enum CommonAbortReason { Timeout = 1, UserAbort = 2, ConnectionClosed = 3, + RequestBodyTooLarge = 4, } diff --git a/src/http_types/Method.rs b/src/http_types/Method.rs index 46f43db7c880..e56f874a1b33 100644 --- a/src/http_types/Method.rs +++ b/src/http_types/Method.rs @@ -1,5 +1,8 @@ +use bun_core::StringPointer; use enumset::EnumSet; +use crate::ETag::Headers; + #[allow(non_camel_case_types)] #[repr(u8)] #[derive(enumset::EnumSetType, Debug)] @@ -173,8 +176,9 @@ impl Method { } } + /// Returns true if a response to this method can have a body (RFC 9112 §6.3 excludes only HEAD). pub fn has_body(self) -> bool { - !matches!(self, Method::HEAD | Method::TRACE) + !matches!(self, Method::HEAD) } /// RFC 9110: GET/HEAD have no defined body semantics; TRACE "MUST NOT @@ -224,6 +228,129 @@ impl Method { } } +/// RFC 9110 §5.6.2 `token`. +fn is_token(bytes: &[u8]) -> bool { + !bytes.is_empty() + && bytes + .iter() + .all(|c| c.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(c)) +} + +/// The method of a `Request`: a verb from the table, or any other token as written. +#[derive(Clone)] +pub enum OwnedMethod { + Known(Method), + Token(Box<[u8]>), +} + +impl From for OwnedMethod { + #[inline] + fn from(method: Method) -> Self { + OwnedMethod::Known(method) + } +} + +impl OwnedMethod { + /// Parses a string that isn't in [`Method::which`]'s table. Falls back to GET if it isn't a token. + #[cold] + pub fn from_unlisted(bytes: &[u8]) -> OwnedMethod { + // Fetch normalizes these six case-insensitively. + for method in [ + Method::DELETE, + Method::GET, + Method::HEAD, + Method::OPTIONS, + Method::POST, + Method::PUT, + ] { + if bytes.eq_ignore_ascii_case(method.as_str().as_bytes()) { + return method.into(); + } + } + if is_token(bytes) { + OwnedMethod::Token(Box::from(bytes)) + } else { + Method::GET.into() + } + } + + #[inline] + pub fn known(&self) -> Option { + match self { + OwnedMethod::Known(method) => Some(*method), + OwnedMethod::Token(_) => None, + } + } + + pub fn as_bytes(&self) -> &[u8] { + match self { + OwnedMethod::Known(method) => method.as_str().as_bytes(), + OwnedMethod::Token(token) => token, + } + } + + /// Appends a token to `headers.buf`, where the HTTP client reads it from. + pub fn to_ref(&self, headers: &mut Headers) -> MethodRef { + match self { + OwnedMethod::Known(method) => MethodRef::Known(*method), + OwnedMethod::Token(token) => { + let offset = u32::try_from(headers.buf.len()).unwrap(); + headers.buf.extend_from_slice(token); + MethodRef::Token(StringPointer { + offset, + length: u32::try_from(token.len()).unwrap(), + }) + } + } + } +} + +/// The HTTP client's copy of the method. A token is stored in the request's header buffer. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum MethodRef { + Known(Method), + Token(StringPointer), +} + +impl From for MethodRef { + #[inline] + fn from(method: Method) -> Self { + MethodRef::Known(method) + } +} + +impl PartialEq for MethodRef { + #[inline] + fn eq(&self, other: &Method) -> bool { + self.known() == Some(*other) + } +} + +impl MethodRef { + #[inline] + pub fn known(self) -> Option { + match self { + MethodRef::Known(method) => Some(method), + MethodRef::Token(_) => None, + } + } + + #[inline] + pub fn has_body(self) -> bool { + self.known().is_none_or(Method::has_body) + } + + #[inline] + pub fn has_request_body(self) -> bool { + self.known().is_none_or(Method::has_request_body) + } + + #[inline] + pub fn is_idempotent(self) -> bool { + self.known().is_some_and(Method::is_idempotent) + } +} + #[derive(Copy, Clone, PartialEq, Eq, Debug)] pub enum Optional { Any, diff --git a/src/http_types/h2.rs b/src/http_types/h2.rs index 00469eb18b24..a4fc8e796ba6 100644 --- a/src/http_types/h2.rs +++ b/src/http_types/h2.rs @@ -91,6 +91,7 @@ impl ErrorCode { pub const CANCEL: Self = Self(0x8); pub const COMPRESSION_ERROR: Self = Self(0x9); pub const ENHANCE_YOUR_CALM: Self = Self(0xb); + pub const HTTP_1_1_REQUIRED: Self = Self(0xd); } #[repr(transparent)] diff --git a/src/http_types/lib.rs b/src/http_types/lib.rs index d557b689a5eb..5445c3d66f4b 100644 --- a/src/http_types/lib.rs +++ b/src/http_types/lib.rs @@ -40,3 +40,12 @@ pub fn parse_content_length_strict(value: &[u8]) -> Option { } Some(n) } + +/// A response `:status`: three digits, 100 to 999, and never 101, which HTTP/2 and HTTP/3 don't have (RFC 9113 §8.6, RFC 9114 §4.5). +pub fn parse_status_pseudo_header(value: &[u8]) -> Option { + let &[a @ b'1'..=b'9', b @ b'0'..=b'9', c @ b'0'..=b'9'] = value else { + return None; + }; + let status = u16::from(a - b'0') * 100 + u16::from(b - b'0') * 10 + u16::from(c - b'0'); + (status != 101).then_some(status) +} diff --git a/src/io/PipeReader.rs b/src/io/PipeReader.rs index dcbb385a9310..89a256aa03e9 100644 --- a/src/io/PipeReader.rs +++ b/src/io/PipeReader.rs @@ -584,6 +584,11 @@ impl PosixBufferedReader { self.limit = ReadLimit(len); } + /// Returns true if `set_limit` asked for more bytes than the reader has read so far. + pub fn is_below_limit(&self) -> bool { + self.limit.0.is_some_and(|remaining| remaining > 0) + } + // Exists for consistently with Windows. pub fn has_pending_read(&self) -> bool { // `is_watching()` (registered && !needs-rearm) rather than @@ -1359,6 +1364,11 @@ impl WindowsBufferedReader { self.limit = ReadLimit(len); } + /// See `PosixBufferedReader::is_below_limit`. + pub fn is_below_limit(&self) -> bool { + self.limit.0.is_some_and(|remaining| remaining > 0) + } + pub fn set_raw_mode(&mut self, value: bool) -> sys::Result<()> { let Some(source) = self.source.as_mut() else { return sys::Result::Err(sys::Error { diff --git a/src/js/builtins.d.ts b/src/js/builtins.d.ts index 36b78df986ff..6a21f225efe0 100644 --- a/src/js/builtins.d.ts +++ b/src/js/builtins.d.ts @@ -478,6 +478,8 @@ declare function $ERR_HTTP2_STREAM_ERROR(code): Error; declare function $ERR_HTTP2_SESSION_ERROR(code): Error; declare function $ERR_HTTP2_PAYLOAD_FORBIDDEN(status): Error; declare function $ERR_HTTP2_INVALID_INFO_STATUS(code): RangeError; +declare function $ERR_HTTP2_UNSUPPORTED_PROTOCOL(protocol: string): Error; +declare function $ERR_HTTP2_TOO_MANY_ORIGINS(maxOriginSetSize: number): Error; declare function $ERR_INVALID_URL(input, base?): TypeError; declare function $ERR_INVALID_CHAR(name, field?): TypeError; declare function $ERR_HTTP_INVALID_HEADER_VALUE(value: string, name: string): TypeError; diff --git a/src/js/internal-for-testing.ts b/src/js/internal-for-testing.ts index e3410d551e61..caad2be5d08e 100644 --- a/src/js/internal-for-testing.ts +++ b/src/js/internal-for-testing.ts @@ -342,6 +342,11 @@ export const isolatedModuleCacheSourceType: (specifier: string) => string | null ); export const Dequeue = require("internal/fifo"); +/** Sends `[name, value, ...]` on a node:quic stream as one header block, without `sendHeaders()`'s checks. */ +export function quicSendRawHeaders(stream: object, pairs: string[], kind: number, flags: number): boolean { + return require("internal/quic/quic").sendRawHeadersForTesting(stream, pairs, kind, flags); +} + // node lib/internal/util.js normalizeEncoding: nullish and '' mean utf8, and // non-strings are undefined; Bun's Rust binding does not fold 'utf-16le', // so the node edge cases are handled here. @@ -701,6 +706,13 @@ export function setSocketOptions(socket: any, option: 1 | 2, size: number): void ); } +// Runs this thread's usockets timeout sweep (normally every 4 s) on the next loop iteration. Every socket on the thread moves one tick closer to its timeout. +export const runSocketTimeoutSweepSoon: () => void = $newRustFunction( + "runtime/socket/socket.rs", + "TestingAPIs.jsRunSocketTimeoutSweepSoon", + 0, +); + /** * The syscalls instrumented in bsd.c, plus non-syscall hooks whose failure * paths are otherwise unreachable without injection ("ssl_loop_buffer", diff --git a/src/js/internal/cluster/child.ts b/src/js/internal/cluster/child.ts index fa695c3a7fdc..856c53579764 100644 --- a/src/js/internal/cluster/child.ts +++ b/src/js/internal/cluster/child.ts @@ -20,6 +20,7 @@ interface ChildCluster extends InstanceType { Worker: typeof Worker; _setupWorker(): void; _getServer(obj, options, cb): void; + _trackServer(server): void; } interface RoundRobinFauxHandle { @@ -119,8 +120,10 @@ cluster._setupWorker = function () { cluster._getServer = function (obj, options, cb) { let address = options.address; - // Resolve unix socket paths to absolute paths - if (options.port < 0 && typeof address === "string" && process.platform !== "win32") address = path.resolve(address); + // Resolve unix socket paths to absolute paths. An abstract name (leading NUL) isn't a path. + if (options.port < 0 && typeof address === "string" && process.platform !== "win32" && address.charCodeAt(0) !== 0) { + address = path.resolve(address); + } const indexesKey = ArrayPrototypeJoin.$call([address, options.port, options.addressType, options.fd], ":"); @@ -175,6 +178,13 @@ cluster._getServer = function (obj, options, cb) { }); }; +// For a server that bound its own socket, so that worker.disconnect() closes it too. +cluster._trackServer = function (server) { + const handle = { [owner_symbol]: server }; + handles.set(handle, handle); + server.once("close", () => handles.delete(handle)); +}; + function removeIndexesKey(indexesKey, index) { const indexSet = indexes.get(indexesKey); if (!indexSet) { diff --git a/src/js/internal/cluster/primary.ts b/src/js/internal/cluster/primary.ts index e0a2514ecca5..2d78a507e4f4 100644 --- a/src/js/internal/cluster/primary.ts +++ b/src/js/internal/cluster/primary.ts @@ -270,8 +270,8 @@ function queryServer(worker, message) { if (cachedHandle && !cachedHandle.has(worker)) handle = cachedHandle; const kSharedOnlyHint = - "TLS and non-TLS cluster workers cannot share the same address:port under SCHED_RR " + - "(Bun's TLS accept is native and cannot adopt round-robin connection fds)"; + "http, https and TLS cluster workers cannot share the same address:port with a net worker under SCHED_RR " + + "(their accept is native and cannot adopt round-robin connection fds)"; if (handle !== undefined && message.sharedOnly === true && handle instanceof RoundRobinHandle) { send(worker, { errno: UV_EINVAL, key, ack: message.seq, data: handle.data, bunHint: kSharedOnlyHint }, null); return; @@ -292,8 +292,13 @@ function queryServer(worker, message) { if (handle === undefined) { let address = message.address; - // Find shortest path for unix sockets because of the ~100 byte limit - if (message.port < 0 && typeof address === "string" && process.platform !== "win32") { + // Find shortest path for unix sockets because of the ~100 byte limit. An abstract name (leading NUL) isn't a path. + if ( + message.port < 0 && + typeof address === "string" && + process.platform !== "win32" && + address.charCodeAt(0) !== 0 + ) { address = require("node:path").relative(process.cwd(), address); if (message.address.length < address.length) address = message.address; diff --git a/src/js/internal/http.ts b/src/js/internal/http.ts index 678010214feb..22218e75f323 100644 --- a/src/js/internal/http.ts +++ b/src/js/internal/http.ts @@ -1,5 +1,7 @@ const { isIPv4 } = require("internal/net/isIP"); +const queueMicrotask = globalThis.queueMicrotask; + const { setServerCustomOptions, setServerAppFlags, setServerMaxHeadersCount, drainMicrotasks } = $cpp( "NodeHTTP.cpp", "createNodeHTTPInternalBinding", @@ -67,6 +69,18 @@ const http1ServerPipeline: { resumeFallbackReadsOnDrain?: (socket: unknown) => void; finishDrainedResponse?: (res: unknown) => void; kMustCloseConnection?: symbol; + socketOnError?: (this: unknown, err: unknown) => void; + socketOnTimeout?: (this: unknown) => void; + onResponseFinishHandleSocket?: (server: unknown, socket: unknown, res: unknown) => void; + resetSocketTimeout?: (server: unknown, socket: unknown) => void; + kConnectionsCheckingInterval?: symbol; + // Registered by internal/http1_server_fallback when it loads. + checkHttp1Connections?: ( + server: unknown, + headersTimeout: number, + requestTimeout: number, + onRequestTimeout: (socket: unknown) => void, + ) => void; } = {}; export const enum NodeHTTPResponseAbortEvent { @@ -219,7 +233,13 @@ function onDataIncomingMessage(this: any, chunk, isLast, aborted: NodeHTTPRespon } if (isLast) { - emitEOFIncomingMessage(this); + if (chunk) { + // Node's parser runs the promise jobs of the last chunk's 'data' listeners before parserOnMessageComplete pushes the EOF. + this[eofInProgress] = true; + queueMicrotask(emitEOFIncomingMessageOuter.bind(undefined, this)); + } else { + emitEOFIncomingMessage(this); + } // Like Node's parserOnMessageComplete: any readStop above left the shared // socket's flowing=false, which would swallow the next request's 'pause'. if (!this.upgrade && socket && !socket._paused && socket.readable) socket.resume(); diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index 476bb70ce9d2..d0100edc731a 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -1,7 +1,7 @@ // JS HTTP/1 server path over an arbitrary Duplex with a JS stand-in for NodeHTTPResponse. // Used by http2's `allowHTTP1` ALPN fallback and http's `server.emit("connection", socket)`. // See https://github.com/nodejs/node/blob/main/lib/_http_server.js connectionListener. -const { STATUS_CODES, kPendingCallbacks } = require("internal/http"); +const { STATUS_CODES, kPendingCallbacks, kRequest, http1ServerPipeline } = require("internal/http"); const { SafeSet } = require("internal/primordials"); const AsyncContextFrame = require("internal/async_context_frame"); @@ -9,12 +9,19 @@ const kHttp1Connections = Symbol("http1Connections"); const kHttp1ActiveRequests = Symbol("http1ActiveRequests"); // Node's last_message_start_: from the first byte of a request to its end, closeIdleConnections() leaves the connection alone. const kHttp1MessageOpen = Symbol("http1MessageOpen"); +// When the connection was accepted or its current request began. 0 between requests and after a request timeout was reported. +const kHttp1MessageStart = Symbol("http1MessageStart"); +const monotonicNowMs = $bindgen("Timer.bind.ts", "monotonicNowMs"); const reportError = globalThis.reportError; function rethrowUncaught(err) { throw err; } +function connectionCorkNT(socket) { + socket.uncork(); +} + // Node fails the write callbacks that a destroyed socket still holds before it emits 'close' (Writable's errorBuffer). function failPendingWriteCallbacks(res, err) { const callbacks = res[kPendingCallbacks]; @@ -165,12 +172,13 @@ function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTim out += "Transfer-Encoding: chunked\r\n"; } out += "\r\n"; - writeToSocket(out); + if (!socket.writableEnded) socket.write(out, "latin1"); } function toBuffer(chunk, encoding) { if (chunk == null) return null; if (typeof chunk === "string") return Buffer.from(chunk, encoding || "utf8"); + if (!ArrayBuffer.isView(chunk)) throw $ERR_INVALID_ARG_TYPE("chunk", ["string", "Buffer", "Uint8Array"], chunk); return chunk; } @@ -180,7 +188,7 @@ function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTim } function writeBody(buf, callback?) { - const length = buf ? (buf.byteLength ?? buf.length) : 0; + const length = buf ? buf.byteLength : 0; if (length) { if (chunked) { writeToSocket(length.toString(16) + "\r\n"); @@ -293,6 +301,11 @@ function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTim }, write(chunk, encoding, callback, _strictContentLength) { const buf = toBuffer(chunk, encoding); + // Like Node's write_(): everything written in one tick reaches the socket as one write. + if (!socket.writableCorked) { + socket.cork(); + process.nextTick(connectionCorkNT, socket); + } writeHeadToSocket(null); const length = writeBody(buf); // Node's rule: the socket's own write() reports the backpressure. A discarded chunk (HEAD, 204, 304) is null. @@ -307,7 +320,8 @@ function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTim // A finished response emits no 'drain': native disarms its drain callback in end() too. this.onwritable = null; const buf = toBuffer(chunk, encoding); - const length = buf ? (buf.byteLength ?? buf.length) : 0; + const length = buf ? buf.byteLength : 0; + socket.cork(); writeHeadToSocket(length); // Like Node's `_hasBody && chunkedEncoding` gate: a bodiless (HEAD) // response never writes the terminating chunk, even when the user set @@ -315,6 +329,9 @@ function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTim const terminated = chunked && !noBody; writeBody(buf, terminated ? undefined : onEndWritten); if (terminated) writeToSocket("0\r\n\r\n", onEndWritten); + // Like Node's end(), fully uncork: the socket has the whole response, so a destroy() right after end() can't truncate it. + socket._writableState.corked = 1; + socket.uncork(); this.ended = true; // Like Node's OutgoingMessage#end(): while the socket holds bytes, the response has finished when its last write completes. if (socket.writableLength > 0 && !socket.destroyed) { @@ -356,8 +373,9 @@ function connectionListenerHTTP1(server, socket, options) { continueExpression, MAX_HEADER_PAIRS, } = require("node:_http_common"); + const { readStart, readStop } = require("node:_http_incoming"); const { ConnResetException } = require("internal/shared"); - const { kHandle: kHttp1ResponseHandle, kHeaderSource, http1ServerPipeline } = require("internal/http"); + const { kHandle: kHttp1ResponseHandle, kHeaderSource } = require("internal/http"); // Populated by node:_http_server, which the require("node:http") above loads. const { queuePipelinedResponse, @@ -368,6 +386,10 @@ function connectionListenerHTTP1(server, socket, options) { resumeFallbackReadsOnDrain, finishDrainedResponse, kMustCloseConnection, + socketOnError, + socketOnTimeout, + onResponseFinishHandleSocket, + resetSocketTimeout, } = http1ServerPipeline; const { allMethods } = process.binding("http_parser"); @@ -381,10 +403,19 @@ function connectionListenerHTTP1(server, socket, options) { // through req.socket.server (nodejs/node#13435). socket.server = server; + const { timeout } = server; + if (timeout && typeof socket.setTimeout === "function") socket.setTimeout(timeout); + socket.on("timeout", socketOnTimeout); + const connections = (server[kHttp1Connections] ??= new SafeSet()); connections.add(socket); socket[kHttp1ActiveRequests] = 0; socket[kHttp1MessageOpen] = false; + // Like Node, headersTimeout also covers a connection that never sends anything. + socket[kHttp1MessageStart] = monotonicNowMs(); + // Like Node's connectionListenerInternal. readStart() skips a socket that still has an earlier connection's read-gate mark. + socket._paused = false; + const connectionFrame = AsyncContextFrame.current(); const kOnHeaders = HTTPParser.kOnHeaders | 0; const kOnMessageBegin = HTTPParser.kOnMessageBegin | 0; @@ -405,6 +436,7 @@ function connectionListenerHTTP1(server, socket, options) { let req: Http1FallbackRequest | null = null; let pendingUpgrade: Http1FallbackRequest | null = null; + let stoppedBy: Http1FallbackRequest | null = null; // Node's per-connection state.requestsCount, behind server.maxRequestsPerSocket. let requestsCount = 0; @@ -413,6 +445,7 @@ function connectionListenerHTTP1(server, socket, options) { let flushedUrl = ""; parser[kOnMessageBegin] = function onHttp1MessageBegin() { socket[kHttp1MessageOpen] = true; + socket[kHttp1MessageStart] = monotonicNowMs(); }; parser[kOnHeaders] = function onHttp1Headers(headers, url) { @@ -440,6 +473,7 @@ function connectionListenerHTTP1(server, socket, options) { flushedUrl = ""; } + resetSocketTimeout(server, socket); socket[kHttp1ActiveRequests]++; req = new IncomingMessageClass(socket); @@ -478,6 +512,8 @@ function connectionListenerHTTP1(server, socket, options) { this._consuming = true; if (!socket._paused && socket.readable) socket.resume(); }; + // The socket's 'timeout' listener forwards the event to the request that's still arriving. + socket[kRequest] = req; const res = new ServerResponseClass(req); // The native dispatcher seeds these from the server; renderNativeHeaders @@ -518,12 +554,16 @@ function connectionListenerHTTP1(server, socket, options) { // `_last`: onHttp1SocketEnd saw the client's FIN while this response owned the socket. if (this[kMustCloseConnection] || this._last) { if (typeof socket.destroySoon === "function") { + // Bun's net.Socket emits 'end' for its own destroySoon(), and the parser would report that as a client's early EOF. + socket.removeListener("end", onHttp1SocketEnd); socket.destroySoon(); } else if (!socket.writableEnded) { socket.end(); } return; } + // Arms the keep-alive timeout. + onResponseFinishHandleSocket(server, socket, this); advanceResponsePipeline(server, socket); }); @@ -531,13 +571,16 @@ function connectionListenerHTTP1(server, socket, options) { // outgoing side is backed up, so pipelined requests cannot flood it. maybePauseFallbackReads(socket); + const isHttp11 = versionMajor === 1 && versionMinor === 1; + // Like Node's parserOnIncoming, before the request count and the Expect routing. + if (isHttp11 && server.requireHostHeader && req.headers.host === undefined) { + res.writeHead(400, ["Connection", "close"]); + res.end(); + return 0; + } + // Like Node's parserOnIncoming: upgrades (handed off above) are not counted, and the socket is never closed here. - if ( - versionMajor === 1 && - versionMinor === 1 && - typeof maxRequestsPerSocket === "number" && - maxRequestsPerSocket > 0 - ) { + if (isHttp11 && typeof maxRequestsPerSocket === "number" && maxRequestsPerSocket > 0) { requestsCount++; res.maxRequestsOnConnectionReached = maxRequestsPerSocket <= requestsCount; if (maxRequestsPerSocket < requestsCount) { @@ -551,8 +594,9 @@ function connectionListenerHTTP1(server, socket, options) { // Node's parserOnIncoming Expect routing (the native dispatcher applies the // same at _http_server.ts's DISPATCH_HAS_EXPECT branch). const expect = req.headers.expect; - if (expect !== undefined && versionMajor === 1 && versionMinor === 1) { + if (expect !== undefined && isHttp11) { if (continueExpression.test(String(expect))) { + res._expect_continue = true; if (server.listenerCount("checkContinue") > 0) { server.emit("checkContinue", req, res); } else { @@ -570,11 +614,22 @@ function connectionListenerHTTP1(server, socket, options) { server.emit("request", req, res); return 0; }; + // pipeline() and `for await` destroy a request but keep its connection, and a destroyed request never calls _read() again. + function onStoppedRequestClose(this: Http1FallbackRequest) { + if (!this.complete) readStart(socket); + } + // Node's parserOnBody: a full request buffer stops the socket, and req._read() restarts it. parser[kOnBody] = function onHttp1Body(chunk) { - if (req && !req._dumped) req.push(chunk); + if (!req || req._dumped || req.destroyed || req.push(chunk)) return; + readStop(socket); + if (stoppedBy !== req) { + stoppedBy = req; + req.once("close", onStoppedRequestClose); + } }; parser[kOnMessageComplete] = function onHttp1MessageComplete() { socket[kHttp1MessageOpen] = false; + socket[kHttp1MessageStart] = 0; // Fields flushed after the header section are the trailers. const rawTrailers = flushedHeaders; if (rawTrailers.length !== 0) flushedHeaders = []; @@ -583,33 +638,21 @@ function connectionListenerHTTP1(server, socket, options) { req.complete = true; req._addHeaderLines(rawTrailers, rawTrailers.length); req.push(null); + if (socket[kRequest] === req) socket[kRequest] = undefined; } + // Read the next request even if this one's body is still buffered, like Node's parserOnMessageComplete. + if (!socket.readableFlowing) readStart(socket); }; - function onHttp1SocketError(err, rawPacket) { - // Match Node's http _connectionListener: attach err.rawPacket and, when no - // 'clientError' listener is present, write the same raw error response - // Node's socketOnError does before destroying. - prepareError(err, parser, rawPacket); - if (!server.emit("clientError", err, socket)) { - if (socket.writable && !socket.destroyed) { - const code = err?.code; - socket.write( - code === "HPE_HEADER_OVERFLOW" - ? "HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n" - : code === "HPE_CHUNK_EXTENSIONS_OVERFLOW" - ? "HTTP/1.1 413 Payload Too Large\r\nConnection: close\r\n\r\n" - : "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n", - "latin1", - ); - } - socket.destroy(err); - } - } + // req._read() restarts a stopped socket, so 'data' can fire in that reader's async context. Node's parser runs in the connection's. function onHttp1SocketData(data) { + AsyncContextFrame.run(connectionFrame, parseHttp1SocketData, undefined, data); + } + function parseHttp1SocketData(data) { const ret = parser.execute(data); if (ret instanceof Error) { - onHttp1SocketError(ret, data); + prepareError(ret, parser, data); + socketOnError.$call(socket, ret); return; } if (pendingUpgrade) { @@ -619,9 +662,13 @@ function connectionListenerHTTP1(server, socket, options) { const upgradeReq = pendingUpgrade; pendingUpgrade = null; socket.removeListener("data", onHttp1SocketData); - socket.removeListener("error", onHttp1SocketErrorListener); + socket.removeListener("error", socketOnError); + socket.removeListener("timeout", socketOnTimeout); socket.removeListener("end", onHttp1SocketEnd); socket.removeListener("drain", onHttp1SocketDrain); + socket.removeListener("finish", onHttp1SocketFinish); + socket.removeListener("close", onHttp1SocketCloseFlush); + socket.removeListener("close", onHttp1SocketClose); connections.delete(socket); try { parser.close(); @@ -637,15 +684,14 @@ function connectionListenerHTTP1(server, socket, options) { } } } - function onHttp1SocketErrorListener(err) { - onHttp1SocketError(err, undefined); - } // Node's socketOnEnd: let llhttp detect a message cut short by EOF, then end // the connection the way Node does (httpAllowHalfOpen / _last / idle end). function onHttp1SocketEnd() { + // Bun's net.Socket also emits 'end' after its own destroy(), which isn't the client's EOF. + if (socket.destroyed) return; const ret = parser.finish(); if (ret instanceof Error) { - onHttp1SocketError(ret, undefined); + socketOnError.$call(socket, ret); return; } if (!server.httpAllowHalfOpen) { @@ -665,26 +711,22 @@ function connectionListenerHTTP1(server, socket, options) { function onHttp1SocketDrain() { resumeFallbackReadsOnDrain(socket); // Node's socketOnDrain then emits 'drain' on the response that waits for it. - socket._httpMessage?.[kHttp1ResponseHandle]?.socketDrained(); + socket._httpMessage?.[kHttp1ResponseHandle]?.socketDrained?.(); } - socket.on("data", onHttp1SocketData); - socket.on("error", onHttp1SocketErrorListener); - socket.once("end", onHttp1SocketEnd); - socket.on("drain", onHttp1SocketDrain); - socket.once("finish", () => { + // A tunnel socket can still carry a listen() server's pending response, and its native handle has none of these methods. + function onHttp1SocketFinish() { const inflight = socket._httpMessage; // An ended socket emits no 'drain', and all that the response wrote is out: the write callbacks that wait succeed. - inflight?.[kHttp1ResponseHandle]?.socketEnded(() => inflight._callPendingCallbacks()); - }); - // Prepended: like Node's 'finish' after a failed last write, a draining response finishes (and detaches) before the other 'close' listeners run. - socket.prependOnceListener("close", () => { - socket._httpMessage?.[kHttp1ResponseHandle]?.flushed(); - }); - socket.once("close", () => { + inflight?.[kHttp1ResponseHandle]?.socketEnded?.(() => inflight._callPendingCallbacks()); + } + function onHttp1SocketCloseFlush() { + socket._httpMessage?.[kHttp1ResponseHandle]?.flushed?.(); + } + function onHttp1SocketClose() { connections.delete(socket); const inflight = socket._httpMessage; // The write callbacks that still wait fail, before the response's 'close', as in Node. - inflight?.[kHttp1ResponseHandle]?.socketEnded(() => + inflight?.[kHttp1ResponseHandle]?.socketEnded?.(() => failPendingWriteCallbacks(inflight, socket.errored ?? inflight.errored ?? $ERR_STREAM_DESTROYED("write")), ); // Like the native socket's close path (Node's socketOnClose -> @@ -704,7 +746,15 @@ function connectionListenerHTTP1(server, socket, options) { try { parser.close(); } catch {} - }); + } + socket.on("data", onHttp1SocketData); + socket.on("error", socketOnError); + socket.once("end", onHttp1SocketEnd); + socket.on("drain", onHttp1SocketDrain); + socket.once("finish", onHttp1SocketFinish); + // Prepended: like Node's 'finish' after a failed last write, a draining response finishes (and detaches) before the other 'close' listeners run. + socket.prependOnceListener("close", onHttp1SocketCloseFlush); + socket.once("close", onHttp1SocketClose); } function closeIdleHttp1Connections(server) { @@ -725,6 +775,26 @@ function closeAllHttp1Connections(server) { } } +// The headersTimeout / requestTimeout sweep for these connections. The server's checkConnections() calls it. +function checkHttp1Connections(server, headersTimeout, requestTimeout, onRequestTimeout) { + const connections = server[kHttp1Connections]; + if (!connections || connections.size === 0) return; + const now = monotonicNowMs(); + for (const socket of connections) { + const start = socket[kHttp1MessageStart]; + if (!start || socket.destroyed) continue; + const elapsed = now - start; + if ( + (requestTimeout > 0 && elapsed > requestTimeout) || + (headersTimeout > 0 && elapsed > headersTimeout && !socket.parser?.headersCompleted()) + ) { + socket[kHttp1MessageStart] = 0; + onRequestTimeout(socket); + } + } +} +http1ServerPipeline.checkHttp1Connections = checkHttp1Connections; + export default { connectionListenerHTTP1, closeIdleHttp1Connections, diff --git a/src/js/internal/quic/quic.ts b/src/js/internal/quic/quic.ts index 850660e1ec09..f31c323f7416 100644 --- a/src/js/internal/quic/quic.ts +++ b/src/js/internal/quic/quic.ts @@ -144,8 +144,8 @@ function ERR_QUIC_VERSION_NEGOTIATION_ERROR() { return $ERR_QUIC_VERSION_NEGOTIATION_ERROR("The QUIC session requires version negotiation"); } -const kClientHttp = Symbol("kClientHttp"); -const kNoteClientHttp = Symbol("kNoteClientHttp"); +const kClientEngineKey = Symbol("kClientEngineKey"); +const kNoteClientEngineKey = Symbol("kNoteClientEngineKey"); const kSocketAddressHandle = Symbol("kSocketAddressHandle"); Object.defineProperty(SocketAddress.prototype, kSocketAddressHandle, { @@ -228,6 +228,7 @@ const kEmptyObject: {} = { __proto__: null }; const symbols = require("internal/quic/symbols"); const kAttachFileHandle: typeof symbols.kAttachFileHandle = symbols.kAttachFileHandle; +const kAvailable: typeof symbols.kAvailable = symbols.kAvailable; const kBlocked: typeof symbols.kBlocked = symbols.kBlocked; const kConnect: typeof symbols.kConnect = symbols.kConnect; const kDatagram: typeof symbols.kDatagram = symbols.kDatagram; @@ -256,6 +257,7 @@ const kPathValidation: typeof symbols.kPathValidation = symbols.kPathValidation; const kPrivateConstructor: typeof symbols.kPrivateConstructor = symbols.kPrivateConstructor; const kReset: typeof symbols.kReset = symbols.kReset; const kSessionTicket: typeof symbols.kSessionTicket = symbols.kSessionTicket; +const kStopSending: typeof symbols.kStopSending = symbols.kStopSending; const kTrailers: typeof symbols.kTrailers = symbols.kTrailers; const kVersionNegotiation: typeof symbols.kVersionNegotiation = symbols.kVersionNegotiation; const kInspect: typeof symbols.kInspect = symbols.kInspect; @@ -373,6 +375,17 @@ const kNilDatagramId = 0n; const endpointRegistry = new SafeSet(); +// Each [kInspect] below inspects a fresh wrapper object, so util.inspect's own cycle check never sees the back-references. +const beingInspected = new SafeSet(); +function inspectOnce(self, fields, options) { + beingInspected.add(self); + try { + return inspect(fields, options); + } finally { + beingInspected.delete(self); + } +} + let releaseEndpointSocket; process.on("exit", () => { for (const endpoint of endpointRegistry) { @@ -847,6 +860,11 @@ setCallbacks({ this[kOwner][kFinishClose](errorType, code, reason, errorName); }, + /** For `session.onapplication`, which isn't implemented. Node's setCallbacks() requires it. */ + onSessionApplication(settings) { + debug("session application callback", settings, this[kOwner]); + }, + /** * Called when the peer sends a GOAWAY frame (HTTP/3 only). * @param {bigint} lastStreamId The highest stream ID the peer may have @@ -1025,6 +1043,12 @@ setCallbacks({ session[kNewStream](stream, direction); }, + /** Called when a pending stream gets its ID. */ + onStreamAvailable() { + debug("stream available callback", this[kOwner]); + this[kOwner][kAvailable](); + }, + onStreamBlocked() { debug("stream blocked callback", this[kOwner]); this[kOwner][kBlocked](); @@ -1039,16 +1063,7 @@ setCallbacks({ if (error !== undefined) { error = convertQuicError(error); } else if (this[kOwner] && !this[kOwner].destroyed) { - const resetCode = getQuicStreamState(this[kOwner]).resetCode; - if (resetCode !== undefined && resetCode > 0n) { - error = makeQuicError( - "ERR_QUIC_APPLICATION_ERROR", - "QUIC application error", - "application", - resetCode, - `stream reset with code ${resetCode}`, - ); - } + error = streamResetError(this[kOwner]); } debug(`stream ${this[kOwner].id} closed callback with error: ${error}`); this[kOwner][kFinishClose](error); @@ -1062,6 +1077,15 @@ setCallbacks({ this[kOwner][kReset](error); }, + /** @param {any} error The application error carried by the peer's STOP_SENDING. */ + onStreamStopSending(error) { + if (error !== undefined) { + error = convertQuicError(error); + } + debug("stream stop sending callback", this[kOwner], error); + this[kOwner][kStopSending](error); + }, + onStreamHeaders(headers, kind) { debug(`stream ${this[kOwner].id} headers callback`, headers, kind); this[kOwner][kHeaders](headers, kind); @@ -1381,14 +1405,21 @@ function configureOutbound(handle, stream, body) { } if (isPromise(body)) { + const onError = err => { + if (!stream.destroyed) { + stream.destroy(err); + } + }; PromisePrototypeThen( body, - resolved => configureOutbound(handle, stream, resolved), - err => { - if (!stream.destroyed) { - stream.destroy(err); + resolved => { + try { + configureOutbound(handle, stream, resolved); + } catch (err) { + onError(err); } }, + onError, ); return; } @@ -1403,11 +1434,17 @@ function configureOutbound(handle, stream, body) { throw $ERR_INVALID_STATE("FileHandle is locked"); } body[kFileLocked] = true; + // Node attaches a FileHandle or a Blob natively, which drops it on a receive-only stream. + if (isReceiveOnlyQuicStream(stream)) { + markPromiseAsHandled(body.close()); + return; + } consumeAsyncSource(handle, stream, body.createReadStream()); return; } if (isBlob(body)) { + if (isReceiveOnlyQuicStream(stream)) return; consumeAsyncSource(handle, stream, body.stream()); return; } @@ -1483,9 +1520,9 @@ async function writeBatchWithDrain(handle, stream, batch) { } async function consumeAsyncSource(handle, stream, source) { - handle.initStreamingSource(); - initStreamingBackpressure(stream); try { + handle.initStreamingSource(); + initStreamingBackpressure(stream); const normalized = streamFrom(source); for await (const batch of normalized) { if (stream.destroyed) return; @@ -1502,11 +1539,11 @@ async function consumeAsyncSource(handle, stream, source) { } async function consumeSyncSource(handle, stream, source) { - handle.initStreamingSource(); - initStreamingBackpressure(stream); - const normalized = streamFromSync(source); - const iter = normalized[SymbolIterator](); try { + handle.initStreamingSource(); + initStreamingBackpressure(stream); + const normalized = streamFromSync(source); + const iter = normalized[SymbolIterator](); while (true) { if (stream.destroyed) return; const { value: batch, done } = iter.next(); @@ -1534,6 +1571,7 @@ function isSyncIterable(obj) { let getQuicStreamState; let getQuicSessionState; let isQuicSessionDestroying; +let isQuicSessionClosedByNative; let getQuicEndpointState; let assertIsQuicEndpoint; let assertIsQuicStream; @@ -1542,8 +1580,24 @@ let assertHeadersSupported; let assertEndpointNotClosedOrClosing; let assertEndpointIsNotBusy; let isQuicStream; +let isReceiveOnlyQuicStream; let isQuicSession; let isQuicEndpoint; +let sendRawHeadersForTesting; + +// A reset is an abnormal end of the stream even if the stream or its session then closes cleanly. +function streamResetError(stream) { + const resetCode = getQuicStreamState(stream).resetCode; + if (resetCode !== undefined && resetCode > 0n) { + return makeQuicError( + "ERR_QUIC_APPLICATION_ERROR", + "QUIC application error", + "application", + resetCode, + `stream reset with code ${resetCode}`, + ); + } +} function maybeGetCloseError(context, status, pendingError) { switch (context) { @@ -1627,6 +1681,7 @@ interface QuicStreamInner { state: QuicStreamState; stats: QuicStreamStats | undefined; pendingClose: PendingPromise | undefined; + pendingOpen: PendingPromise | undefined; reader: QuicStreamReader | undefined; destroying: boolean; iteratorLocked: boolean; @@ -1639,11 +1694,13 @@ interface QuicStreamInner { onerror: Function | undefined; onblocked: Function | undefined; onreset: Function | undefined; + onstopsending: Function | undefined; onheaders: Function | undefined; ontrailers: Function | undefined; oninfo: Function | undefined; onwanttrailers: Function | undefined; earlySnapshot?: boolean; + finReceivedSnapshot?: boolean; } class QuicStream { @@ -1656,6 +1713,7 @@ class QuicStream { state: undefined as unknown as QuicStreamState, stats: undefined, pendingClose: undefined, + pendingOpen: undefined, reader: undefined, destroying: false, iteratorLocked: false, @@ -1668,6 +1726,7 @@ class QuicStream { onerror: undefined, onblocked: undefined, onreset: undefined, + onstopsending: undefined, onheaders: undefined, ontrailers: undefined, oninfo: undefined, @@ -1679,6 +1738,11 @@ class QuicStream { return val != null && typeof val === "object" && #handle in val; }; + isReceiveOnlyQuicStream = function (stream) { + const inner = stream.#inner; + return inner.direction === kStreamDirectionUnidirectional && !inner.isLocal; + }; + assertIsQuicStream = function (val) { if (!isQuicStream(val)) { throw $ERR_INVALID_THIS("QuicStream"); @@ -1695,6 +1759,13 @@ class QuicStream { assertIsQuicStream(stream); return stream.#inner.state; }; + + // For bun:internal-for-testing. Sends `[name, value, ...]` as one block and skips buildNgHeaderString's checks. + sendRawHeadersForTesting = function (stream, pairs: string[], kind: number, flags: number) { + let block = ""; + for (let i = 0; i + 1 < pairs.length; i += 2) block += `${pairs[i]}\0${pairs[i + 1]}\0\0`; + return stream.#handle.sendHeaders(kind, [block, pairs.length >> 1], flags); + }; } /** @@ -1714,6 +1785,9 @@ class QuicStream { inner.direction = direction; inner.isLocal = isLocal; inner.state = new QuicStreamState(kPrivateConstructor, handle.state, handle.stateByteOffset); + if (inner.state.pending) { + inner.pendingOpen = PromiseWithResolvers(); + } if (hasObserver("quic")) { startPerf(this, kPerfEntry, { type: "quic", name: "QuicStream" }); @@ -1754,6 +1828,10 @@ class QuicStream { return $ERR_INVALID_STATE("The stream is not readable"); }, }); + + // The stream was destroyed before a FIN arrived, so the read is truncated. `closed` has the reason. + const writeOnly = inner.direction === kStreamDirectionUnidirectional && inner.isLocal; + if (inner.finReceivedSnapshot === false && !writeOnly) await this.closed; } /** @@ -1766,6 +1844,15 @@ class QuicStream { return this.#inner.state.pending; } + /** @type {Promise} Fulfilled once the stream has an ID. */ + get opened() { + assertIsQuicStream(this); + const inner = this.#inner; + // Only a stream that was pending at creation gets a deferred. + const pendingOpen = (inner.pendingOpen ??= { promise: PromiseResolve(), resolve: undefined, reject: undefined }); + return pendingOpen.promise; + } + /** * True if any data on this stream was received as 0-RTT (early data) * before the TLS handshake completed. Early data is less secure and @@ -1855,6 +1942,25 @@ class QuicStream { } } + /** @type {OnStreamErrorCallback} */ + get onstopsending() { + assertIsQuicStream(this); + return this.#inner.onstopsending; + } + + set onstopsending(fn) { + assertIsQuicStream(this); + const inner = this.#inner; + if (fn === undefined) { + inner.onstopsending = undefined; + inner.state.wantsStopSending = false; + } else { + validateFunction(fn, "onstopsending"); + inner.onstopsending = FunctionPrototypeBind(fn, this); + inner.state.wantsStopSending = true; + } + } + /** @type {OnHeadersCallback} */ get onheaders() { assertIsQuicStream(this); @@ -2085,8 +2191,13 @@ class QuicStream { this.#handle.abortForDestroy(wantStop ? abortCode : undefined, wantReset ? abortCode : undefined); } } + // Node doesn't call `onerror` for a stream that its connection closed. `session.closed` rejects with the same error. + const closedByConnection = cascadingFromSessionDestroy && isQuicSessionClosedByNative(inner.session); + if (closedByConnection) error ??= inner.localResetError ?? streamResetError(this); const { onerror } = inner; - if (error !== undefined && typeof onerror === "function") { + if (error !== undefined && closedByConnection) { + markPromiseAsHandled(this.closed); + } else if (error !== undefined && typeof onerror === "function") { invokeOnerror(onerror, error); } const handle = this.#handle; @@ -2196,6 +2307,22 @@ class QuicStream { } }; + const onStopSending = stream[kStopSending]; + stream[kStopSending] = reason => { + if (!closed && !errored) { + errored = true; + error = reason; + // `fail()` is a no-op for an errored writer, so mark `closed` here. + markPromiseAsHandled(stream.closed); + if (drainWakeup != null) { + markPromiseAsHandled(drainWakeup.promise); + drainWakeup.reject(error); + drainWakeup = null; + } + } + onStopSending.$call(stream, reason); + }; + function writeSync(chunk) { if (closed || errored || stream.#inner.state.writeEnded || drainWakeup != null) { return false; @@ -2348,12 +2475,7 @@ class QuicStream { }, }; - if ( - !handle || - this.destroyed || - inner.state.writeEnded || - (inner.direction === kStreamDirectionUnidirectional && !inner.isLocal) - ) { + if (!handle || this.destroyed || inner.state.writeEnded || isReceiveOnlyQuicStream(this)) { closed = true; return (inner.writer = writer); } @@ -2423,7 +2545,8 @@ class QuicStream { assertIsQuicStream(this); if (this.destroyed) return; code = BigInt(code); - this.#handle.resetStream(code); + // The native side returns false for a receive-only stream, which has nothing to reset. + if (this.#handle.resetStream(code) === false) return; this.#inner.localResetError ??= makeQuicError( "ERR_QUIC_APPLICATION_ERROR", "QUIC application error", @@ -2481,6 +2604,18 @@ class QuicStream { } else { inner.pendingClose.resolve!(); } + const { pendingOpen } = inner; + if (pendingOpen?.resolve !== undefined) { + // A stream with an ID did open, even if kAvailable hasn't run yet. + if (error !== undefined && inner.state.pending) { + markPromiseAsHandled(pendingOpen.promise); + pendingOpen.reject!(error); + } else { + pendingOpen.resolve(); + } + pendingOpen.resolve = undefined; + pendingOpen.reject = undefined; + } debug("stream closed"); if (onStreamClosedChannel.hasSubscribers) { onStreamClosedChannel.publish({ @@ -2501,6 +2636,7 @@ class QuicStream { } inner.stats?.[kFinishClose](); inner.earlySnapshot = inner.state?.early; + inner.finReceivedSnapshot = inner.state?.finReceived; inner.state?.[kFinishClose](); inner.session![kRemoveStream](this); inner.writer?.fail(error); @@ -2510,6 +2646,7 @@ class QuicStream { inner.pendingClose.resolve = undefined; inner.onblocked = undefined; inner.onreset = undefined; + inner.onstopsending = undefined; inner.onheaders = undefined; inner.onerror = undefined; inner.ontrailers = undefined; @@ -2525,6 +2662,14 @@ class QuicStream { } } + [kAvailable]() { + const { pendingOpen } = this.#inner; + if (pendingOpen?.resolve === undefined) return; + pendingOpen.resolve(); + pendingOpen.resolve = undefined; + pendingOpen.reject = undefined; + } + [kBlocked]() { const inner = this.#inner; assert(inner.onblocked, "Unexpected stream blocked event"); @@ -2554,6 +2699,12 @@ class QuicStream { safeCallbackInvoke(inner.onreset, this, error); } + [kStopSending](error) { + const inner = this.#inner; + assert(inner.onstopsending, "Unexpected stream stop sending event"); + safeCallbackInvoke(inner.onstopsending, this, error); + } + [kHeaders](headers, kind) { const block = parseHeaderPairs(headers); const kindName = kHeadersKindName[kind] ?? kind; @@ -2609,7 +2760,17 @@ class QuicStream { const inner = this.#inner; if (inner.pendingTrailers) { - this.sendTrailers(inner.pendingTrailers); + let sent; + try { + sent = this.sendTrailers(inner.pendingTrailers); + } catch (error) { + this.destroy(error); + return; + } + if (!sent) { + this.destroy(ERR_QUIC_STREAM_ABORTED("Failed to submit trailing headers")); + return; + } inner.pendingTrailers = undefined; } else { const { onwanttrailers } = inner; @@ -2621,6 +2782,9 @@ class QuicStream { if (depth < 0) { return "QuicStream { }"; } + if (beingInspected.has(this)) { + return "[Circular]"; + } const opts = { __proto__: null, @@ -2630,7 +2794,8 @@ class QuicStream { const { id, direction, pending, stats, session } = this; - return `QuicStream ${inspect( + return `QuicStream ${inspectOnce( + this, { __proto__: null, id, @@ -2709,6 +2874,7 @@ interface QuicSessionInner { isPendingClose: boolean; selfInitiatedClose: boolean; destroying: boolean; + closedByNative: boolean; handshakeCompleted: boolean; pendingClose: PendingPromise; pendingOpen: PendingPromise; @@ -2751,6 +2917,7 @@ class QuicSession { isPendingClose: false, selfInitiatedClose: false, destroying: false, + closedByNative: false, handshakeCompleted: false, pendingClose: PromiseWithResolvers(), pendingOpen: PromiseWithResolvers(), @@ -2803,6 +2970,11 @@ class QuicSession { assertIsQuicSession(session); return session.#inner.destroying; }; + + isQuicSessionClosedByNative = function (session) { + assertIsQuicSession(session); + return session.#inner.closedByNative; + }; } /** @@ -3637,6 +3809,7 @@ class QuicSession { * @param {string} [reason] */ [kFinishClose](errorType, code, reason, errorName) { + this.#inner.closedByNative = true; if (code === 0n) { debug("finishing closing the session with no error"); this.destroy(); @@ -4013,6 +4186,9 @@ class QuicSession { if (depth < 0) { return "QuicSession { }"; } + if (beingInspected.has(this)) { + return "[Circular]"; + } const opts = { __proto__: null, @@ -4022,7 +4198,8 @@ class QuicSession { const { isPendingClose: closing, endpoint, path, state, stats, streams } = this.#inner; - return `QuicSession ${inspect( + return `QuicSession ${inspectOnce( + this, { closed: this.closed, closing, @@ -4207,7 +4384,7 @@ interface QuicEndpointInner { busy: boolean; isPendingClose: boolean; listening: boolean; - clientHttp: boolean | undefined; + clientEngineKey: string | undefined; pendingClose: PendingPromise; pendingError: unknown; sessions: Set; @@ -4227,7 +4404,7 @@ class QuicEndpoint { busy: false, isPendingClose: false, listening: false, - clientHttp: undefined, + clientEngineKey: undefined, pendingClose: PromiseWithResolvers(), pendingError: undefined, sessions: new SafeSet(), @@ -4703,12 +4880,12 @@ class QuicEndpoint { return this.#inner.listening; } - get [kClientHttp]() { - return this.#inner.clientHttp; + get [kClientEngineKey]() { + return this.#inner.clientEngineKey; } - [kNoteClientHttp](wantHttp) { - this.#inner.clientHttp ??= wantHttp; + [kNoteClientEngineKey](key) { + this.#inner.clientEngineKey ??= key; } /** @type {boolean} */ @@ -4873,6 +5050,9 @@ class QuicEndpoint { if (depth < 0) { return "QuicEndpoint { }"; } + if (beingInspected.has(this)) { + return "[Circular]"; + } const opts = { __proto__: null, @@ -4882,7 +5062,8 @@ class QuicEndpoint { const { address, busy, isPendingClose: closing, listening, sessions, stats, state } = this.#inner; - return `QuicEndpoint ${inspect( + return `QuicEndpoint ${inspectOnce( + this, { address, busy, @@ -4915,11 +5096,11 @@ function alpnWantsHttp(alpn) { return typeof first !== "string" || first === "h3" || StringPrototypeStartsWith(first, "h3-"); } -function findSuitableEndpoint(wantHttp) { +function findSuitableEndpoint(engineKey) { for (const endpoint of endpointRegistry) { if (!endpoint.destroyed && !endpoint.closing && !endpoint.busy) { - const mode = endpoint[kClientHttp]; - if (mode !== undefined && mode !== wantHttp) { + const key = endpoint[kClientEngineKey]; + if (key !== undefined && key !== engineKey) { continue; } if (endpoint.listening) { @@ -4937,7 +5118,7 @@ function findSuitableEndpoint(wantHttp) { * @param {boolean} forServer * @returns {QuicEndpoint} */ -function processEndpointOption(endpoint, reuseEndpoint = true, forServer = false, wantHttp = true) { +function processEndpointOption(endpoint, reuseEndpoint, forServer, engineKey) { if (isQuicEndpoint(endpoint)) { return endpoint; } @@ -4945,7 +5126,7 @@ function processEndpointOption(endpoint, reuseEndpoint = true, forServer = false return new QuicEndpoint(endpoint); } if (reuseEndpoint && !forServer) { - const existing = findSuitableEndpoint(wantHttp); + const existing = findSuitableEndpoint(engineKey); if (existing !== undefined) return existing; } return new QuicEndpoint(); @@ -5361,9 +5542,6 @@ function processSessionOptions(options, config: ProcessSessionOptions = kEmptyOb } } - const wantHttp = forServer || alpnWantsHttp(options.alpn); - const actualEndpoint = processEndpointOption(endpoint, reuseEndpoint, forServer, wantHttp); - // Normalize into the shape Node stores and returns from `session.applicationOptions`. const { maxHeaderPairs = 128n, @@ -5375,6 +5553,19 @@ function processSessionOptions(options, config: ProcessSessionOptions = kEmptyOb enableConnectProtocol = false, enableDatagrams = true, } = application; + + // Every option `build_engine` in endpoint.rs turns into a setting of the endpoint's one lsquic client engine. + const tp = transportParams; + const engineKey = forServer + ? undefined + : `${alpnWantsHttp(options.alpn)}|${handshakeTimeout}|${cc}` + + `|${tp.initialMaxStreamDataBidiLocal}|${tp.initialMaxStreamDataBidiRemote}|${tp.initialMaxStreamDataUni}` + + `|${tp.initialMaxData}|${tp.initialMaxStreamsBidi}|${tp.initialMaxStreamsUni}|${tp.maxIdleTimeout}` + + `|${tp.maxUdpPayloadSize}|${!!tp.disableActiveMigration}|${tp.maxDatagramFrameSize}` + + `|${maxHeaderPairs}|${maxHeaderLength}|${!!enableConnectProtocol}|${!!enableDatagrams}`; + const actualEndpoint = processEndpointOption(endpoint, reuseEndpoint, forServer, engineKey); + actualEndpoint[kNoteClientEngineKey](engineKey); + if (sessionTicket !== undefined) { if (!isArrayBufferView(sessionTicket)) { throw $ERR_INVALID_ARG_TYPE("options.sessionTicket", ["ArrayBufferView"], sessionTicket); @@ -5511,8 +5702,6 @@ async function connect(address, options: SessionOptions = kEmptyObject) { const session = endpoint[kConnect](address[kSocketAddressHandle], rest); - endpoint[kNoteClientHttp](alpnWantsHttp(options.alpn)); - if (onEndpointClientSessionChannel.hasSubscribers) { onEndpointClientSessionChannel.publish({ __proto__: null, @@ -5569,4 +5758,5 @@ export default { getQuicStreamState, getQuicSessionState, getQuicEndpointState, + sendRawHeadersForTesting, }; diff --git a/src/js/internal/quic/state.ts b/src/js/internal/quic/state.ts index f97104248757..78a6d0e0a3ab 100644 --- a/src/js/internal/quic/state.ts +++ b/src/js/internal/quic/state.ts @@ -61,6 +61,7 @@ const { IDX_STATE_STREAM_WANTS_HEADERS, IDX_STATE_STREAM_WANTS_RESET, IDX_STATE_STREAM_WANTS_TRAILERS, + IDX_STATE_STREAM_WANTS_STOP_SENDING, IDX_STATE_STREAM_RECEIVED_EARLY_DATA, IDX_STATE_STREAM_WRITE_DESIRED_SIZE, IDX_STATE_STREAM_HIGH_WATER_MARK, @@ -727,6 +728,20 @@ class QuicStreamState { DataViewPrototypeSetUint8(handle, this.#offset + IDX_STATE_STREAM_WANTS_TRAILERS, val ? 1 : 0); } + /** @type {boolean} */ + get wantsStopSending() { + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_STREAM_WANTS_STOP_SENDING) !== 0; + } + + /** @type {boolean} */ + set wantsStopSending(val) { + const handle = this.#handle; + if (handle === undefined) return; + DataViewPrototypeSetUint8(handle, this.#offset + IDX_STATE_STREAM_WANTS_STOP_SENDING, val ? 1 : 0); + } + /** @type {boolean} */ get early() { const handle = this.#handle; @@ -787,6 +802,7 @@ class QuicStreamState { wantsReset, wantsHeaders, wantsTrailers, + wantsStopSending, early, resetCode, writeDesiredSize, @@ -807,6 +823,7 @@ class QuicStreamState { wantsReset, wantsHeaders, wantsTrailers, + wantsStopSending, early, resetCode: `${resetCode}`, writeDesiredSize, @@ -843,6 +860,7 @@ class QuicStreamState { wantsReset, wantsHeaders, wantsTrailers, + wantsStopSending, early, resetCode, writeDesiredSize, @@ -864,6 +882,7 @@ class QuicStreamState { wantsReset, wantsHeaders, wantsTrailers, + wantsStopSending, early, resetCode, writeDesiredSize, diff --git a/src/js/internal/quic/symbols.ts b/src/js/internal/quic/symbols.ts index c9a75a4874d1..8e713508c821 100644 --- a/src/js/internal/quic/symbols.ts +++ b/src/js/internal/quic/symbols.ts @@ -3,6 +3,7 @@ const kInspect = Symbol.for("nodejs.util.inspect.custom"); const kAttachFileHandle = Symbol("kAttachFileHandle"); +const kAvailable = Symbol("kAvailable"); const kBlocked = Symbol("kBlocked"); const kConnect = Symbol("kConnect"); const kDrain = Symbol("kDrain"); @@ -31,11 +32,13 @@ const kRemoveSession = Symbol("kRemoveSession"); const kRemoveStream = Symbol("kRemoveStream"); const kReset = Symbol("kReset"); const kSessionTicket = Symbol("kSessionTicket"); +const kStopSending = Symbol("kStopSending"); const kTrailers = Symbol("kTrailers"); const kVersionNegotiation = Symbol("kVersionNegotiation"); export default { kAttachFileHandle, + kAvailable, kBlocked, kConnect, kDatagram, @@ -65,6 +68,7 @@ export default { kRemoveStream, kReset, kSessionTicket, + kStopSending, kTrailers, kVersionNegotiation, } as const; diff --git a/src/js/internal/test/binding.ts b/src/js/internal/test/binding.ts index 0238b1e8335c..95b3ebe177a5 100644 --- a/src/js/internal/test/binding.ts +++ b/src/js/internal/test/binding.ts @@ -54,6 +54,15 @@ function safeGetenv(name: string) { let cachedUvBinding: Record | undefined; +function nghttp2ErrorString(code: number) { + const { NghttpError } = require("node:http2")[Symbol.for("::bunhttp2internals::")].util; + return new NghttpError(code).message; +} + +// Bun has no native handle classes. Tests only stub methods on these prototypes, which has no effect here. +class Http2Session {} +class Http2Stream {} + function internalBinding(name: string) { switch (name) { case "trace_events": @@ -92,6 +101,13 @@ function internalBinding(name: string) { const { kMaxLength, kStringMaxLength } = require("node:buffer"); return { kMaxLength, kStringMaxLength }; } + case "http2": + return { + Http2Session, + Http2Stream, + constants: require("node:http2").constants, + nghttp2ErrorString, + }; case "udp_wrap": return { UDP: require("internal/dgram").UDP }; case "tcp_wrap": diff --git a/src/js/internal/url.ts b/src/js/internal/url.ts index 4740c16317c2..f8daf7d01bf4 100644 --- a/src/js/internal/url.ts +++ b/src/js/internal/url.ts @@ -1,5 +1,10 @@ const [domainToASCII, domainToUnicode, idnaToASCII] = $cpp("NodeURL.cpp", "Bun::createNodeURLBinding"); +// Node's isURL (lib/internal/url.js). Legacy url.parse() objects set `auth` and `path`, so they don't match. +function isURL(self) { + return Boolean(self?.href && self.protocol && self.auth === undefined && self.path === undefined); +} + function urlToHttpOptions(url) { const options = { __proto__: null, @@ -29,5 +34,6 @@ export default { domainToASCII, domainToUnicode, idnaToASCII, + isURL, urlToHttpOptions, }; diff --git a/src/js/internal/validators.ts b/src/js/internal/validators.ts index 92b3f2eb5efb..dcd4b83191b6 100644 --- a/src/js/internal/validators.ts +++ b/src/js/internal/validators.ts @@ -171,7 +171,6 @@ export default { validateBuffer: $newCppFunction("NodeValidator.cpp", "jsFunction_validateBuffer", 0), /** `(value, name, oneOf)` */ validateOneOf: $newCppFunction("NodeValidator.cpp", "jsFunction_validateOneOf", 0), - isUint8Array: value => value instanceof Uint8Array, /** `(path)` — accepts a string or file URL, returns it resolved to an absolute path string */ getValidatedPath, getValidatedFsPath, diff --git a/src/js/node/_http_client.ts b/src/js/node/_http_client.ts index 07cd0db6817f..b999d1279691 100644 --- a/src/js/node/_http_client.ts +++ b/src/js/node/_http_client.ts @@ -16,7 +16,7 @@ const { } = require("node:_http_common"); const { kUniqueHeaders, parseUniqueHeadersOption, OutgoingMessage } = require("node:_http_outgoing"); const Agent = require("node:_http_agent"); -const { urlToHttpOptions } = require("internal/url"); +const { isURL, urlToHttpOptions } = require("internal/url"); const { kOutHeaders, kNeedDrain, @@ -102,10 +102,6 @@ function closeRequest(req) { req.emit("close"); } -function isURLInstance(input) { - return input != null && typeof input === "object" && input instanceof URL; -} - // When proxying a HTTP request, the following needs to be done: // https://datatracker.ietf.org/doc/html/rfc7230#section-5.3.2 // 1. Rewrite the request path to absolute-form. @@ -176,7 +172,7 @@ function ClientRequest(input?, options?, cb?): void { if (typeof input === "string") { const urlStr = input; input = urlToHttpOptions(new URL(urlStr)); - } else if (isURLInstance(input)) { + } else if (isURL(input)) { // url.URL instance input = urlToHttpOptions(input); } else { diff --git a/src/js/node/_http_outgoing.ts b/src/js/node/_http_outgoing.ts index 6e5276f4afd1..9d0a362cbbd6 100644 --- a/src/js/node/_http_outgoing.ts +++ b/src/js/node/_http_outgoing.ts @@ -2,7 +2,8 @@ // This is a port of Node.js's lib/_http_outgoing.js // https://github.com/nodejs/node/blob/v26.3.0/lib/_http_outgoing.js const { Stream } = require("internal/stream"); -const { isUint8Array, validateString } = require("internal/validators"); +const { validateString } = require("internal/validators"); +const { isUint8Array } = require("node:util/types"); const { deprecate } = require("internal/util/deprecate"); const { getDefaultHighWaterMark } = require("internal/streams/state"); const { kOutHeaders, kNeedDrain, utcDate } = require("internal/http"); @@ -333,33 +334,51 @@ OutgoingMessage.prototype.cork = function cork() { OutgoingMessage.prototype.uncork = function uncork() { this[kCorked]--; - if (this[kSocket]) { - this[kSocket].uncork(); - } - - if (this[kCorked] || this[kChunkedBuffer].length === 0) { - return; - } + const socket = this[kSocket]; + try { + if (this[kCorked] || this[kChunkedBuffer].length === 0) { + return; + } - const len = this[kChunkedLength]; - const buf = this[kChunkedBuffer]; + const len = this[kChunkedLength]; + const buf = this[kChunkedBuffer]; + if (this.destroyed || socket?.destroyed) { + const error = this[kErrored] || socket?._writableState?.errored || $ERR_STREAM_DESTROYED("write"); + for (let n = 2; n < buf.length; n += 3) { + if (buf[n] !== nop) { + process.nextTick(buf[n], error); + } + } + buf.length = 0; + this[kChunkedLength] = 0; + return; + } - $assert(this.chunkedEncoding); + $assert(this.chunkedEncoding); - let callbacks; - this._send(len.toString(16), "latin1", null); - this._send(crlf_buf, null, null); - for (let n = 0; n < buf.length; n += 3) { - this._send(buf[n + 0], buf[n + 1], null); - if (buf[n + 2]) { - callbacks ??= []; - callbacks.push(buf[n + 2]); + let callbacks; + this._send(len.toString(16), "latin1", null); + this._send(crlf_buf, null, null); + for (let n = 0; n < buf.length; n += 3) { + this._send(buf[n + 0], buf[n + 1], null); + if (buf[n + 2]) { + callbacks ??= []; + callbacks.push(buf[n + 2]); + } } + this._send(crlf_buf, null, callbacks?.length ? runChunkCallbacks.bind(undefined, callbacks) : null); + + this[kChunkedBuffer].length = 0; + this[kChunkedLength] = 0; + } finally { + socket?.uncork(); } - this._send(crlf_buf, null, callbacks?.length ? runChunkCallbacks.bind(undefined, callbacks) : null); - this[kChunkedBuffer].length = 0; - this[kChunkedLength] = 0; + // If we had a pending drain and flushed all data, emit the drain event. + if (this[kNeedDrain] && this.writableLength === 0) { + this[kNeedDrain] = false; + this.emit("drain"); + } }; function runChunkCallbacks(callbacks, err) { @@ -986,6 +1005,12 @@ function write_(msg, chunk, encoding, callback, fromEnd) { } } + // _writeRaw() drops a write to a destroyed socket along with its callback. + if (callback !== nop && msg[kSocket]?.destroyed) { + process.nextTick(callback, msg[kSocket]._writableState?.errored || $ERR_STREAM_DESTROYED("write")); + callback = nop; + } + let msgSocket; if (!fromEnd && (msgSocket = msg.socket) && !msgSocket.writableCorked) { msgSocket.cork(); @@ -1109,6 +1134,13 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) { throw $ERR_HTTP_CONTENT_LENGTH_MISMATCH(this[kBytesWritten], contentLength); } + // Flush buffered chunks before terminating the message. + if (this[kChunkedBuffer].length !== 0) { + this[kSocket]?.cork(); + this[kCorked] = 1; + this.uncork(); + } + const finish = onFinish.bind(undefined, this); if (this._hasBody && this.chunkedEncoding) { diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 958ba989a17d..2c10fa891cac 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -15,15 +15,18 @@ const { validateLinkHeaderValue, validateBoolean, validateInteger, + validateNumber, validateFunction, validateOneOf, } = require("internal/validators"); const { ConnResetException, ErrnoException, + ExceptionWithHostPort, hasObserver, startPerf, stopPerf, + kClusterOwner, kInternalSendOptions, isStoppedModuleGraphRunning, } = require("internal/shared"); @@ -87,6 +90,14 @@ const kTrackedConnections = Symbol("http.server.trackedConnections"); const kPendingDrainClose = Symbol("http.server.pendingDrainClose"); const kPendingCloseGenerations = Symbol("http.server.pendingCloseGenerations"); const kListenerGeneration = Symbol("http.server.listenerGeneration"); +const kClusterHandle = Symbol("http.server.clusterHandle"); +// listen() and close() bump it, so a reply from the cluster primary to an earlier listen() is dropped. +const kClusterListeningId = Symbol("http.server.clusterListeningId"); +// Under `bun --hot`, maps a reused native server to the http.Server it dispatches to, or to null once another http.Server stopped it. +let nativeServerOwners: WeakMap | undefined; +const SafeWeakMap = WeakMap; +const WeakMapPrototypeGet = WeakMap.prototype.get; +const WeakMapPrototypeSet = WeakMap.prototype.set; // Set on the 'connect'/'upgrade' handoff; closeAll/closeIdleConnections() skip these, as in Node. const kHandedOff = Symbol("http.server.socketHandedOff"); function closeHandle(handle) { @@ -109,7 +120,6 @@ function traceServerRequestEnd() { traceEvents.emitEvent("e", kHttpTraceCat, "http.server.request"); } -const getBunServerAllClosedPromise = $newRustFunction("node_http_binding.rs", "getBunServerAllClosedPromise", 1); const getBunServerOpenCount = $bindgen("node_http_binding.bind.ts", "getBunServerOpenCount"); const kServerResponse = Symbol("ServerResponse"); @@ -120,7 +130,7 @@ const kEmptyBuffer = Buffer.alloc(0); const ObjectKeys = Object.keys; const MathMin = Math.min; const MathFloor = Math.floor; -const DateNow = Date.now; +const monotonicNowMs = $bindgen("Timer.bind.ts", "monotonicNowMs"); let cluster; @@ -141,6 +151,10 @@ function emitCloseServer(self: Server) { if (!relistened) self[kListenerGeneration] = undefined; self.emit("close"); } +// One tick later than the last socket's 'error', so 'clientError' still comes before the server's 'close'. +function emitCloseServerAfterSocketError(self: Server) { + process.nextTick(emitCloseServer, self); +} function markListenerGenerationClosed(self: Server, generation) { generation.nativeClosed = true; emitCloseServer(self); @@ -148,6 +162,21 @@ function markListenerGenerationClosed(self: Server, generation) { function emitCloseNTServer(this: Server, generation) { process.nextTick(markListenerGenerationClosed, this, generation); } +// 'close' runs in listen()'s async context. Node uses close()'s: https://github.com/oven-sh/bun/pull/33719 +function queueCloseNTServer(self: Server, stopped, generation) { + const frame = generation.frame; + generation.frame = undefined; + const current = $getInternalField($asyncContext, 0); + if (frame !== current) $putInternalField($asyncContext, 0, frame); + try { + // A server whose Bun.ModuleGraph was disposed emits nothing. + if (frame === undefined || !isStoppedModuleGraphRunning()) { + stopped.$then(emitCloseNTServer.bind(self, generation)); + } + } finally { + if (frame !== current) $putInternalField($asyncContext, 0, current); + } +} function setCloseCallback(self, callback) { if (callback === self[kCloseCallback]) { @@ -272,6 +301,32 @@ function emitListenErrorNextTick(self, err) { self.emit("error", err); } +function emitListenError(server, err, port, host, socketPath, fd) { + if (fd !== undefined) { + // Not a socket Node can listen on: https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L2063-L2070 + if (err.code === "EBADF" || err.code === "ENOTSOCK" || err.code === "EOPNOTSUPP") err.code = "EINVAL"; + err = formatListenError(err, null, undefined); + } else { + err = socketPath ? formatListenError(err, socketPath, -1) : formatListenError(err, host ?? "::", port); + } + process.nextTick(emitListenErrorNextTick, server, err); +} + +// https://github.com/nodejs/node/blob/v26.5.0/lib/internal/errors.js#L689-L716 +function formatListenError(err, address, port) { + // Node emits a failed lookup's error as dns.lookup() made it. + if (err?.syscall === "getaddrinfo") return err; + const errno = process.binding("uv")[`UV_${err?.code}`]; + if (errno === undefined) return err; + const description = require("node:util").getSystemErrorMessage(errno); + err.message = `listen ${err.code}: ${description}${port > 0 ? ` ${address}:${port}` : address ? ` ${address}` : ""}`; + err.errno = errno; + err.syscall = "listen"; + err.address = address; + if (port) err.port = port; + return err; +} + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1367-L1368 function normalizeServerTls(tls) { tls.requestCert = tls.requestCert === true; @@ -301,6 +356,8 @@ interface Server extends NodeHTTPServer { requireHostHeader: boolean; httpAllowHalfOpen: boolean; blockList?: import("node:net").BlockList; + keepAlive: boolean; + keepAliveInitialDelay: number; } function Server(options, callback): void { if (!(this instanceof Server)) return new Server(options, callback); @@ -312,6 +369,7 @@ function Server(options, callback): void { if (socket != null && typeof socket === "object") socket.server = this; }); + this._handle = null; this._unref = false; this.timeout = 0; this.maxRequestsPerSocket = 0; @@ -322,7 +380,8 @@ function Server(options, callback): void { this[kPendingDrainClose] = false; this[kPendingCloseGenerations] = new Set(); this[kListenerGeneration] = undefined; - this[tlsSymbol] = null; + // http.Server.call() on an existing https.Server keeps its TLS configuration, like Node. + this[tlsSymbol] ??= null; this.noDelay = true; if (typeof options === "function") { callback = options; @@ -333,7 +392,7 @@ function Server(options, callback): void { validateObject(options, "options"); options = { ...options }; const tlsHelpers = - this[isTlsSymbol] || options.pfx || options.cert || options.key || options.ca + (this[isTlsSymbol] && !this[tlsSymbol]) || options.pfx || options.cert || options.key || options.ca ? require("internal/tls") : undefined; @@ -358,7 +417,7 @@ function Server(options, callback): void { // PKCS#12-embedded CAs extend the trust set; the server path hands raw // {key, cert, ca} to the native config and has no addCACert hook, so fold // them into `ca` (mirrors tls.Server.setSecureContext). - const pfxExtraCAs = tlsOptions._pfxExtraCACerts; + const pfxExtraCAs = options.pfx ? tlsOptions._pfxExtraCACerts : undefined; if (pfxExtraCAs?.length) { ca = ca == null ? pfxExtraCAs : $isArray(ca) ? [...ca, ...pfxExtraCAs] : [ca, ...pfxExtraCAs]; } @@ -460,6 +519,10 @@ Server.prototype[kServerResponse] = undefined; Server.prototype[kConnectionsCheckingInterval] = undefined; +Server.prototype[kClusterHandle] = undefined; + +Server.prototype[kClusterListeningId] = 0; + function rethrowUncaught(err) { throw err; } @@ -474,6 +537,8 @@ function setupConnectionsTracking(this: any) { const delay = this.connectionsCheckingInterval ?? 30_000; this[kConnectionsCheckingInterval] = setInterval(checkConnections.bind(this), delay); this[kConnectionsCheckingInterval].unref(); + // Under `bun --hot`, a newer http.Server can take the native server before this 'listening' fires. + if (lostListener(this)) releaseListener(this); } // Node.js's checkConnections sweep: every connectionsCheckingInterval, expire @@ -483,12 +548,17 @@ function setupConnectionsTracking(this: any) { function checkConnections(this: Server) { const headersTimeout = this.headersTimeout; const requestTimeout = this.requestTimeout; - if (headersTimeout === 0 && requestTimeout === 0) { - return; - } + // Connections parsed in JS (emit('connection'), allowHTTP1) aren't in kTrackedConnections. + http1ServerPipeline.checkHttp1Connections?.(this, headersTimeout, requestTimeout, onRequestTimeout); const connections = this[kTrackedConnections]; if (!connections || connections.size === 0) { + // Under `bun --hot`, a replaced server with no connections left has nothing to check. + if (lostListener(this)) clearInterval(this[kConnectionsCheckingInterval]); + return; + } + + if (headersTimeout === 0 && requestTimeout === 0) { return; } @@ -560,6 +630,15 @@ Server.prototype.close = function (optionalCallback?) { // even when the server was never listening. clearInterval(this[kConnectionsCheckingInterval]); http1Fallback?.closeIdleHttp1Connections(this); + if (!isPrimary) { + this[kClusterListeningId]++; + const clusterHandle = this[kClusterHandle]; + if (clusterHandle) { + this[kClusterHandle] = undefined; + clusterHandle[kClusterOwner] = null; + clusterHandle.close(); + } + } if (!server) { if (typeof optionalCallback === "function") process.nextTick(optionalCallback, $ERR_SERVER_NOT_RUNNING()); // Like Node.js's net.Server#close, close() returns the server. @@ -569,13 +648,19 @@ Server.prototype.close = function (optionalCallback?) { const generation = this[kListenerGeneration]; if (generation) this[kPendingCloseGenerations].add(generation); this[serverSymbol] = undefined; + this._handle = null; this[kPendingDrainClose] = true; // Like Node, a connection that this sweep closes counts as gone, also a TLS one whose close ends later with the peer's close_notify. const open = getBunServerOpenCount(server); const swept = server.closeIdleConnections(); if (generation) generation.drainedAtClose = open === swept; + if (nativeServerOwners !== undefined) { + // Under `bun --hot`, stopping a shared native server also takes the listener away from its current http.Server. + const owner = WeakMapPrototypeGet.$call(nativeServerOwners, server); + if (owner != null && owner !== this) WeakMapPrototypeSet.$call(nativeServerOwners, server, null); + } // stop() queues the task that emits 'close', which holds the loop one more turn, as node's uv_close() does. - server.stop(); + queueCloseNTServer(this, server.stop(), generation); return this; }; @@ -622,6 +707,9 @@ Server.prototype.listen = function () { const server = this; let port, host; let socketPath; + let fd: number | undefined; + let reusePort = false; + let exclusive = false; let tls = this[tlsSymbol]; // This logic must align with: @@ -632,9 +720,22 @@ Server.prototype.listen = function () { if (($isObject(arg0) || $isCallable(arg0)) && arg0 !== null) { // (options[...][, cb]) addServerAbortSignalOption(this, arg0); - port = arg0.port; - host = arg0.host; - socketPath = arg0.path; + // `fd` wins over the rest: https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L2314-L2317 + const { fd: fdOption } = arg0; + if (typeof fdOption === "number" && fdOption >= 0) { + fd = fdOption; + } else { + if ("fd" in arg0 && !("port" in arg0 || "path" in arg0)) { + throw $ERR_INVALID_ARG_VALUE("options", arg0, 'must have the property "port" or "path"'); + } + port = arg0.port; + host = arg0.host; + socketPath = arg0.path; + reusePort = arg0.reusePort === true; + exclusive = reusePort || !!arg0.exclusive; + // Like net.Server.listen(), an explicit `port: undefined` or `port: null` binds any free port. + if ((port === undefined && "port" in arg0) || port === null) port = 0; + } const otherTLS = arg0.tls; if (otherTLS && $isObject(otherTLS)) { @@ -654,7 +755,7 @@ Server.prototype.listen = function () { // Bun defaults to port 3000. // Node defaults to port 0. - if (port === undefined && !socketPath) { + if (port == null && !socketPath) { port = 0; } @@ -665,6 +766,10 @@ Server.prototype.listen = function () { } } + // Like net.Server.listen(), a port wins over `path`, and listening on a `path` ignores `host`. + if (typeof port === "number" || typeof port === "string") socketPath = undefined; + else if (socketPath) host = undefined; + const lastArg = arguments[argc - 1]; if ($isCallable(lastArg)) { // Before the bind, as in node, so a listen() retried from the 'error' handler still calls it. @@ -672,15 +777,31 @@ Server.prototype.listen = function () { } try { + // Windows has no descriptor to listen on. Node reports EINVAL there too. + if (fd !== undefined && (fd !== (fd | 0) || process.platform === "win32")) { + throw new ErrnoException(process.binding("uv").UV_EINVAL, "listen"); + } + // listenInCluster if (isPrimary) { - server[kRealListen](tls, port, host, socketPath, false); + server[kRealListen](tls, port, host, socketPath, reusePort, fd); return this; } if (cluster === undefined) cluster = require("node:cluster"); + // SO_REUSEPORT can't share a unix socket or port 0, and a worker's `fd` names a descriptor of the primary. + if ( + process.connected && + process.platform !== "win32" && + (fd !== undefined || (!exclusive && (socketPath || port === 0))) + ) { + listenInCluster(server, tls, port, host, socketPath, fd); + return this; + } + + server[kClusterListeningId]++; server.once("listening", () => { // No channel (NODE_UNIQUE_ID inherited by a plain child, or already disconnected): nothing to notify. if (!process.connected) return; @@ -699,15 +820,62 @@ Server.prototype.listen = function () { process.send!(message, undefined, kInternalSendOptions); }); - server[kRealListen](tls, port, host, socketPath, true); + server[kRealListen](tls, port, host, socketPath, true, fd); + if (!exclusive) cluster._trackServer(server); } catch (err) { - process.nextTick(emitListenErrorNextTick, server, err); + emitListenError(server, err, port, host, socketPath, fd); } return this; }; -Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort) { +// net.Server's listenInCluster, always `sharedOnly` like its TLS servers: Bun.serve can't take connections the primary accepted. +function listenInCluster(server, tls, port, host, socketPath, fd) { + const listeningId = ++server[kClusterListeningId]; + function query(address, queryPort, addressType) { + const serverQuery = { address, port: queryPort, addressType, fd, flags: 0, sharedOnly: true }; + cluster._getServer(server, serverQuery, (errno, handle, reply) => { + if (listeningId !== server[kClusterListeningId] || cluster.worker.exitedAfterDisconnect) { + handle?.close(); + return; + } + if (errno) { + const ex = new ExceptionWithHostPort(errno, "bind", address, queryPort); + if (typeof reply?.bunHint === "string") ex.message += `\n note: ${reply.bunHint}`; + server.emit("error", ex); + return; + } + server[kClusterHandle] = handle; + handle[kClusterOwner] = server; + try { + server[kRealListen](tls, port, host, socketPath, false, handle.sharedFd); + handle.adopted = true; + } catch (err) { + // Once Bun.serve() returned, the listener owns the descriptor and handle.close() must not close it again. + handle.adopted = !!server[serverSymbol]; + server.close(); + emitListenError(server, err, port, host, socketPath, fd); + } + }); + } + + // The (address, port, addressType, fd) tuple keys the primary's handles, so it matches net.Server's. + if (fd !== undefined) return query(null, null, null); + if (socketPath) return query(socketPath, -1, -1); + if (!host) return query(null, port, 4); + // Bun.serve() takes "[::1]". The primary's bind() wants it bare. + const address = host[0] === "[" ? host.slice(1, -1) : host; + const addressType = isIP(address); + if (addressType !== 0) return query(address, port, addressType); + // https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L2434-L2452 + require("node:dns").lookup(address, (err, ip, family) => { + if (listeningId !== server[kClusterListeningId]) return; + if (err) server.emit("error", err); + else query(ip, port, family === 6 ? 6 : 4); + }); +} + +Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort, fd?: number) { { // What the first connection and the first request need, so that neither waits for it. getNodeHTTPServerSocket(); @@ -728,6 +896,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort port, hostname: host, unix: socketPath, + fd, reusePort, maxRequestBodySize: Number.MAX_SAFE_INTEGER, @@ -822,7 +991,6 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort detachSocketListenersForHandoff(socket); // Pass the pipelined data (head buffer) if any was received with the CONNECT request const head = connectHead ? connectHead : kEmptyBuffer; - socket.bytesRead += head.length; // Node.js's parserOnIncoming: req.upgrade is true for CONNECT // regardless of shouldUpgradeCallback. http_req.upgrade = true; @@ -842,7 +1010,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort // handler ran: Node.js hands the socket over with no listeners and // tests assert socket.listenerCount("close") === 0 there. const promise = $newPromise(); - socket.once("close", resolveHandoffPromise.bind(undefined, promise)); + socket.once("close", createHandoffPromiseResolver(promise)); return promise; } else { // Node.js will close the socket and will NOT respond with 400 Bad Request @@ -1062,7 +1230,6 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort http_req.complete = true; } const upgradeHead = !hasBody && connectHead ? connectHead : kEmptyBuffer; - socket.bytesRead += upgradeHead.length; let upgradeHandled; try { upgradeHandled = server.emit("upgrade", http_req, socket, upgradeHead); @@ -1083,7 +1250,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort // machinery; hold the native callback open until the raw socket // closes. const upgradePromise = $newPromise(); - socket.once("close", resolveHandoffPromise.bind(undefined, upgradePromise)); + socket.once("close", createHandoffPromiseResolver(upgradePromise)); return upgradePromise; } else if (rejectMissingHost) { http_res.writeHead(400, { Connection: "close" }); @@ -1094,6 +1261,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort // parserOnIncoming (RFC 7231 5.1.1: expectation values compare // case-insensitively); computed natively into the bitfield. if ((dispatchBits & DISPATCH_EXPECT_CONTINUE) !== 0) { + http_res._expect_continue = true; if (server.listenerCount("checkContinue") > 0) { server.emit("checkContinue", http_req, http_res); } else { @@ -1141,14 +1309,24 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort }); const handle = this[serverSymbol]; + // supertest only closes its server when `_handle` is set. + this._handle = handle; listenerGeneration = { - isUnix: !!socketPath, + isUnix: !!socketPath || (fd !== undefined && handle.address === null), nativeClosed: false, drainedAtClose: false, + // The async context of listen(), until close(). + frame: $getInternalField($asyncContext, 0), }; this[kListenerGeneration] = listenerGeneration; - getBunServerAllClosedPromise(handle).$then(emitCloseNTServer.bind(this, listenerGeneration)); applyServerCustomOptions(this); + // Only `bun --hot` sets an id. The next listen() on this address reuses this native server. + if (handle.id !== "") { + const owners = (nativeServerOwners ??= new SafeWeakMap()); + const previous = WeakMapPrototypeGet.$call(owners, handle); + WeakMapPrototypeSet.$call(owners, handle, this); + if (previous != null) releaseListener(previous); + } if (this?._unref) { this[serverSymbol]?.unref?.(); @@ -1177,6 +1355,18 @@ function applyServerCustomOptions(server: Server) { setServerMaxHeadersCount(handle, nativeMaxHeadersCount(server.maxHeadersCount)); } +// Returns true if, under `bun --hot`, the native server now dispatches to another http.Server or was stopped by one. +function lostListener(server: Server) { + if (nativeServerOwners === undefined) return false; + const owner = WeakMapPrototypeGet.$call(nativeServerOwners, server[serverSymbol]); + return owner !== undefined && owner !== server; +} + +// A replaced server keeps its timeout sweep only while it still has connections. +function releaseListener(server: Server) { + if (server[kTrackedConnections].size === 0) clearInterval(server[kConnectionsCheckingInterval]); +} + // Resolution: httpValidation > explicit insecureHTTPParser > --insecure-http-parser. Native // implements two llhttp lenient bits: bit 0 = LENIENT_HEADERS ("relaxed" gets this only), // bit 1 = LENIENT_TRANSFER_ENCODING (kLenientAll / "insecure" gets both). @@ -1345,6 +1535,9 @@ enum HttpParserError { HTTP_PARSER_ERROR_CHUNK_TERMINATOR_EXPECTED = 16, HTTP_PARSER_ERROR_TRAILER_CONTENT_LENGTH = 17, HTTP_PARSER_ERROR_INVALID_URL = 18, + HTTP_PARSER_ERROR_EMPTY_CONTENT_LENGTH = 19, + HTTP_PARSER_ERROR_CONTENT_LENGTH_OVERFLOW = 20, + HTTP_PARSER_ERROR_DUPLICATE_CONTENT_LENGTH = 21, } // Native callback fired when the HTTP parser rejects incoming bytes. Builds // the same error object Node's parser produces and routes it through @@ -1382,7 +1575,16 @@ function onServerClientError( err = $HPE_INVALID_CHUNK_SIZE("Parse Error: Invalid character in chunk size"); break; case HttpParserError.HTTP_PARSER_ERROR_INVALID_CONTENT_LENGTH: - err = $HPE_UNEXPECTED_CONTENT_LENGTH("Parse Error"); + err = $HPE_INVALID_CONTENT_LENGTH("Parse Error: Invalid character in Content-Length"); + break; + case HttpParserError.HTTP_PARSER_ERROR_EMPTY_CONTENT_LENGTH: + err = $HPE_INVALID_CONTENT_LENGTH("Parse Error: Empty Content-Length"); + break; + case HttpParserError.HTTP_PARSER_ERROR_CONTENT_LENGTH_OVERFLOW: + err = $HPE_INVALID_CONTENT_LENGTH("Parse Error: Content-Length overflow"); + break; + case HttpParserError.HTTP_PARSER_ERROR_DUPLICATE_CONTENT_LENGTH: + err = $HPE_UNEXPECTED_CONTENT_LENGTH("Parse Error: Duplicate Content-Length"); break; case HttpParserError.HTTP_PARSER_ERROR_INVALID_TRANSFER_ENCODING: err = $HPE_INVALID_TRANSFER_ENCODING("Parse Error: Request has invalid `Transfer-Encoding`"); @@ -1434,6 +1636,8 @@ function onServerClientError( err = $HPE_INTERNAL("Parse Error"); break; } + // Node builds the message as `Parse Error: ${reason}`. + err.reason = err.message.slice("Parse Error: ".length); err.rawPacket = Buffer.from(rawPacket); socketOnError.$call(nodeSocket, err); } @@ -1451,6 +1655,7 @@ function replyMissingHostHeader(socket) { // Snapshot of bytesWritten, taken when the socket releases its native handle. const kBytesWritten = Symbol("kBytesWritten"); +const kBytesRead = Symbol("kBytesRead"); // Raw socket.write() bytes plus the bytes the current response wrote (its body, or a WebSocket's 101). function handleBytesWritten(handle) { return handle.bytesWritten + (handle.response?.getBytesWritten() ?? 0); @@ -1494,11 +1699,15 @@ function detachSocketListenersForHandoff(socket) { // Like Node's onParserExecuteCommon: a flowing socket would drop tunnel bytes that arrive before the listener attaches a reader. socket.readableFlowing = null; } -function resolveHandoffPromise(promise) { - $resolvePromise(promise, undefined); +// A closure, not bind(). A patched Function.prototype.bind would receive a function that resolves any argument without a type check. +function createHandoffPromiseResolver(promise) { + return () => $resolvePromise(promise, undefined); } const kSocketTimeoutTimer = Symbol("socketTimeoutTimer"); const kStreamingEnabled = Symbol("kStreamingEnabled"); +// The socket has a 'data' or 'readable' listener, so native hands it every read before the HTTP parser gets it. +const kRawReads = Symbol("kRawReads"); +const kUpdateRawReads = Symbol("kUpdateRawReads"); // destroySoon() was called: native lets the read that is being parsed reach the request, then closes right behind the FIN. const kDestroySoon = Symbol("kDestroySoon"); // Scratch options object for the builtin ServerResponse (see the dispatcher). @@ -1510,11 +1719,8 @@ const scratchResponseOptions = { // Per-socket cached bound abort handler (the socket outlives its requests). const kBoundOnAbort = Symbol("kBoundOnAbort"); const kKeepAliveTimeoutSet = Symbol("keepAliveTimeoutSet"); -// When the keep-alive idle period on a connection started (the last response -// finish). onResponseFinishHandleSocket records this instead of rescheduling -// the socket timer on every response; onSocketTimeoutTimerExpired reads it to -// grant the remaining idle budget when the timer actually fires. -const kKeepAliveIdleStart = Symbol("keepAliveIdleStart"); +// monotonicNowMs() at the last response write or finish. Rescheduling the socket timer each time is slow, so onSocketTimeoutTimerExpired moves the deadline instead. +const kLastResponseWrite = Symbol("lastResponseWrite"); // HTTP/1.1 pipelining (responses queued behind an in-flight response): // - on the socket: array of queued ServerResponses, in arrival order // - on a queued response: { ops, bytes, needDrain, ended, isAncient } while it @@ -1550,7 +1756,8 @@ const requestChunkExtensionsTooLargeResponse = Buffer.from( function socketOnError(this: any, err) { // Ignore further errors this.removeListener("error", socketOnError); - this.on("error", noopOnError); + // A 'clientError' listener can keep the connection open, and a later parse error calls this again. + if (this.listenerCount("error", noopOnError) === 0) this.on("error", noopOnError); const server = this.server; if (!server || !server.emit("clientError", err, this)) { @@ -1602,23 +1809,16 @@ function onSocketTimeoutTimerExpired(socket) { socket[kSocketTimeoutTimer]?.refresh(); return; } - // onResponseFinishHandleSocket records when the last response finished - // instead of rescheduling the timer on every response, so the timer's - // deadline may predate the start of the real idle period. Grant the - // remaining budget once, measured from that last finish. Gated on - // kKeepAliveTimeoutSet so a fire of the server's regular per-socket - // timeout (socket.timeout !== 0 with no response outstanding) never - // consumes a stale keep-alive idle mark. - const idleStart = socket[kKeepAliveIdleStart]; - if (idleStart !== undefined && socket[kKeepAliveTimeoutSet]) { - socket[kKeepAliveIdleStart] = undefined; - const remaining = socket.timeout - (DateNow() - idleStart); - if (remaining > 0) { - const existingTimer = socket[kSocketTimeoutTimer]; - if (existingTimer !== undefined) clearTimeout(existingTimer); - const timer = setTimeout(onSocketTimeoutTimerExpired, remaining, socket); - timer.unref(); - socket[kSocketTimeoutTimer] = timer; + // Like Node, the deadline is one full timeout after the last write, which can be later than this timer was armed for. + const lastWrite = socket[kLastResponseWrite]; + if (lastWrite !== undefined) { + socket[kLastResponseWrite] = undefined; + const sinceWrite = monotonicNowMs() - lastWrite; + const timer = socket[kSocketTimeoutTimer]; + if (sinceWrite < socket.timeout && timer !== undefined) { + // Reuse this timer. _unrefTimer() would refresh a new, shorter one with its shorter interval. + timer.refresh(); + timer._idleStart -= sinceWrite; return; } } @@ -1641,6 +1841,7 @@ let NodeHTTPServerSocket; type NodeHTTPServerSocket = InstanceType>; type NetSocketPrototypeAccessors = | "bufferSize" + | "bytesRead" | "bytesWritten" | "localAddress" | "localFamily" @@ -1660,14 +1861,15 @@ function getNodeHTTPServerSocket() { const { Socket: NetSocket }: { Socket: NetSocketConstructor } = require("node:net"); const { getTimerDuration } = require("internal/timers"); NodeHTTPServerSocket = class Socket extends NetSocket { - bytesRead = 0; connecting = false; timeout = 0; parser = null; [kStreamingEnabled] = false; + [kRawReads] = false; [kBoundOnAbort] = null; - [kKeepAliveIdleStart] = undefined; + [kLastResponseWrite]: number | undefined = undefined; [kBytesWritten] = 0; + [kBytesRead] = 0; [kClosedWithUnsentData] = undefined; [kHandle]; [kUpgradeIncoming]: import("node:http").IncomingMessage | undefined = undefined; @@ -1716,6 +1918,7 @@ function getNodeHTTPServerSocket() { this._secureEstablished = !!handle?.secureEstablished; handle.onclose = this.#onClose.bind(this); handle.duplex = this; + if (server.keepAlive) handle.setKeepAlive(true, server.keepAliveInitialDelay); this.encrypted = encrypted; this.on("timeout", onNodeHTTPServerSocketTimeout); @@ -1744,6 +1947,14 @@ function getNodeHTTPServerSocket() { this[kBytesWritten] = value; } + get bytesRead() { + const handle = this[kHandle]; + return handle ? handle.bytesRead : this[kBytesRead]; + } + set bytesRead(value) { + this[kBytesRead] = value; + } + // Called when an 'upgrade' or 'connect' listener, or `ws` from a 'request' listener, takes the connection over. Its // 'close' listeners run after the handle is gone and still ask who the peer was, and the handle can't answer once the // connection has closed. @@ -1778,18 +1989,50 @@ function getNodeHTTPServerSocket() { } } } + // Node's connectionListener wraps the same three methods to unconsume the parser: https://github.com/nodejs/node/blob/v26.5.0/lib/_http_server.js#L1432-L1448 + on(event, listener) { + super.on(event, listener); + if (event === "data" || event === "readable") this[kUpdateRawReads](); + return this; + } + addListener(event, listener) { + return this.on(event, listener); + } + prependListener(event, listener) { + super.prependListener(event, listener); + if (event === "data" || event === "readable") { + this[kUpdateRawReads](); + // In Node the parser's 'data' listener already made the socket flow. + if (event === "data" && !this[kHandedOff] && this.readableFlowing === null) this.resume(); + } + return this; + } + [kUpdateRawReads]() { + const handle = this[kHandle]; + if (!handle || this[kHandedOff]) return; + const enable = this.listenerCount("data") > 0 || this.listenerCount("readable") > 0; + if (this[kRawReads] === enable) return; + this[kRawReads] = enable; + handle.ondata = enable ? this.#onRawData.bind(this) : undefined; + handle.setRawReads(enable); + } + #onRawData(chunk) { + this[kUpdateRawReads](); + if (!this[kRawReads]) return; + // A full buffer stops the reads, like #onData. + if (this.push(chunk) === false) this[kHandle]?.readStop(); + } #onDrain() { const callback = this.#pendingCallback; if (callback) { this.#pendingCallback = null; + // Writable emits 'drain' from the write callback. (callback as Function)(); } - this.emit("drain"); } #onData(chunk, last) { this._unrefTimer(); if (chunk) { - this.bytesRead += chunk.length; // A full buffer stops the reads: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/stream_base_commons.js#L191-L198 if (this.push(chunk) === false && !last) { this[kHandle]?.readStop(); @@ -1819,6 +2062,7 @@ function getNodeHTTPServerSocket() { } #closeHandle(handle, callback, err?: Error) { this[kBytesWritten] = handleBytesWritten(handle); + this[kBytesRead] = handle.bytesRead; this[kHandle] = undefined; // Capture the in-flight response before detachSocket() can clear it: a // synchronous res.destroy() inside the request handler runs detachSocket() @@ -1846,6 +2090,7 @@ function getNodeHTTPServerSocket() { this[kHandle] = null; if (closedHandle) { this[kBytesWritten] = handleBytesWritten(closedHandle); + this[kBytesRead] = closedHandle.bytesRead; this[kClosedWithUnsentData] = closedHandle.closedWithUnsentData; // Peer FIN: 'end' before 'close', like Node's net.Socket. read(0) emits it when nothing reads the socket. if (closedHandle.peerEnded) { @@ -1865,8 +2110,8 @@ function getNodeHTTPServerSocket() { if (tracked) { tracked.delete(this); if (tracked.size === 0 && server[kPendingDrainClose]) { - // The server 'close' follows this socket's own 'close' event. - this.once("close", () => process.nextTick(emitCloseServer, server)); + // Don't wait for this socket's 'close' event. User code can remove the listener, or throw from an earlier one. + process.nextTick(errored || this.#closeError ? emitCloseServerAfterSocketError : emitCloseServer, server); } } const timer = this[kSocketTimeoutTimer]; @@ -2062,7 +2307,7 @@ function getNodeHTTPServerSocket() { #resumeSocket(readStart: boolean) { const handle = this[kHandle]; // A tunnel reads again: response.resume() below does nothing for it. - if (readStart && this[kStreamingEnabled]) handle?.readStart(); + if (readStart && (this[kStreamingEnabled] || this[kRawReads])) handle?.readStart(); const response = handle?.response; const upgradeIncoming = this[kUpgradeIncoming]; if (upgradeIncoming) { @@ -2162,7 +2407,8 @@ function getNodeHTTPServerSocket() { return this; } - setKeepAlive(_enable = false, _initialDelay = 0) { + setKeepAlive(enable = false, initialDelay = 0) { + this[kHandle]?.setKeepAlive(Boolean(enable), ~~(initialDelay / 1000)); return this; } @@ -2294,7 +2540,6 @@ function willBeChunked(response) { // fields, so it can carry neither a body nor trailers. function hasInvalidTrailer(response) { if (willBeChunked(response)) return false; - if (response._trailer) return true; const outHeaders = response[kOutHeaders]; return outHeaders !== null && outHeaders["trailer"] !== undefined; } @@ -2308,6 +2553,15 @@ function validateStatusCode(statusCode) { return statusCode; } +// Node renders the head inside writeHead(). Copy the caller's array so later changes to it, and appendHeader()'s push, never reach the wire. +function snapshotHeaderValue(value) { + if (!$isArray(value)) return value; + const length = value.length; + const copy = $newArrayWithSize(length); + for (let i = 0; i < length; i++) $putByValDirect(copy, i, value[i]); + return copy; +} + function _writeHead(statusCode, reason, obj, response) { statusCode = validateStatusCode(statusCode); @@ -2336,7 +2590,7 @@ function _writeHead(statusCode, reason, obj, response) { if (length && $isArray(obj[0])) { for (let i = 0; i < length; i++) { const k = obj[i]; - if (k) response.appendHeader(k[0], k[1]); + if (k) response.appendHeader(k[0], snapshotHeaderValue(k[1])); } } else { if (length % 2 !== 0) { @@ -2360,7 +2614,7 @@ function _writeHead(statusCode, reason, obj, response) { for (let n = 0; n < length; n += 2) { k = obj[n]; - if (k) response.appendHeader(k, obj[n + 1]); + if (k) response.appendHeader(k, snapshotHeaderValue(obj[n + 1])); } } } else if (obj) { @@ -2370,7 +2624,7 @@ function _writeHead(statusCode, reason, obj, response) { // Refs: https://github.com/nodejs/node/pull/30958 for (let i = 0; i < length; i++) { k = keys[i]; - if (k) response.setHeader(k, obj[k]); + if (k) response.setHeader(k, snapshotHeaderValue(obj[k])); } } if (hasInvalidTrailer(response)) { @@ -2469,6 +2723,8 @@ const AUTO_HEADER_KEEP_ALIVE_TIMEOUT = 1 << 3; // line, so it is rendered natively with the other auto headers rather than being // pushed into the flat array (which goes out first). const AUTO_HEADER_TRANSFER_ENCODING_CHUNKED = 1 << 4; +// No header line. The handler set Transfer-Encoding: chunked, which overrides HTTP/1.0 and Content-Length framing. +const AUTO_HEADER_BODY_CHUNKED = 1 << 5; // The largest Keep-Alive timeout, in seconds, handed to the native writeHead (it takes a uint32). const kMaxNativeKeepAliveSecs = 0x7fffffff; // Out-parameters of renderNativeHeaders, read by its callers in the same @@ -2589,8 +2845,20 @@ function renderNativeHeaders(res) { res[kMustCloseConnection] = true; } } + } else if (storedTransferEncoding !== undefined && res._hasBody !== false) { + // Node's matchHeader frames the body by the Transfer-Encoding value. + if (chunkExpression.test(String(storedTransferEncoding[1]))) { + autoHeaders |= AUTO_HEADER_BODY_CHUNKED; + } else if (storedContentLength === undefined) { + // Node sends this body raw on a keep-alive connection, so the client can't tell where it ends. Close instead (RFC 9112 6.3). + closeDelimited = true; + res[kMustCloseConnection] = true; + } } + // Node's writeHead(): the client may never send the body, so a final response with no 100 Continue closes the connection. + if (res._expect_continue && !res._sent100 && !hasConnection) res.shouldKeepAlive = false; + if (res._removedConnection) { // Node's _storeHeader: `this._last = !this.shouldKeepAlive` - no // Connection header is written (the user removed it), but the socket @@ -2754,17 +3022,24 @@ function onResponseFinishHandleSocket(server, socket, res) { // idle period started; onSocketTimeoutTimerExpired grants the remaining // budget if the timer fires early, so the socket still closes after // exactly `total` ms of idle. + socket[kLastResponseWrite] = monotonicNowMs(); const timer = socket[kSocketTimeoutTimer]; if (timer !== undefined && timer._idleTimeout === total) { socket.timeout = total; } else { socket.setTimeout(total); } - socket[kKeepAliveIdleStart] = DateNow(); socket[kKeepAliveTimeoutSet] = true; } } +// Node's resetSocketTimeout, for connections parsed in JS: a new request ends the keep-alive timeout. +function resetFallbackSocketTimeout(server, socket) { + if (!socket[kKeepAliveTimeoutSet]) return; + socket[kKeepAliveTimeoutSet] = false; + socket.setTimeout(server.timeout || 0); +} + // Node.js's socketOnClose frees the parser, which aborts parsing of any // pipelined requests still sitting in the current buffer - even when 'close' // was emitted manually by user code. @@ -3109,14 +3384,23 @@ function accountQueuedHeaderBytes(res, queued) { addPipelineOutgoingData(queued, bytes); } +// Accepts what the native handle accepts. The handle only sees the chunk after the head is written, or when a queued response replays outside the handler. +function validateChunk(chunk) { + if (chunk == null || typeof chunk === "string" || $isTypedArrayView(chunk)) return; + const { isAnyArrayBuffer, isDataView, isStringObject } = require("node:util/types"); + if (isAnyArrayBuffer(chunk) || isDataView(chunk) || isStringObject(chunk)) return; + throw $ERR_INVALID_ARG_TYPE("chunk", ["string", "Buffer", "Uint8Array"], chunk); +} + function bufferPipelinedWrite(res, queued, chunk, encoding, callback) { + validateChunk(chunk); callWriteHeadIfObservable(res, res[headerStateSymbol]); if (res[headerStateSymbol] === NodeHTTPHeaderState.none) { updateHasBody(res, (res.statusCode = validateStatusCode(res.statusCode))); } accountQueuedHeaderBytes(res, queued); if (!res._hasBody) { - if (chunk && res[kRejectNonStandardBodyWrites]) { + if ((chunk || chunk === "") && res[kRejectNonStandardBodyWrites]) { throw $ERR_HTTP_BODY_NOT_ALLOWED(); } // Node's write_(): a response that cannot have a body discards the write, calls back and returns true. @@ -3141,6 +3425,7 @@ function bufferPipelinedWrite(res, queued, chunk, encoding, callback) { } function bufferPipelinedEnd(res, queued, chunk, encoding, callback) { + validateChunk(chunk); callWriteHeadIfObservable(res, res[headerStateSymbol], true); if (res[headerStateSymbol] === NodeHTTPHeaderState.none) { updateHasBody(res, (res.statusCode = validateStatusCode(res.statusCode))); @@ -3256,11 +3541,11 @@ Object.defineProperty(ServerResponse.prototype, "headers", { if ($isArray(value)) { // Array of [name, value] pairs, like the WHATWG Headers sequence init. for (const { 0: key, 1: val } of value) { - this.appendHeader(key, val); + this.appendHeader(key, snapshotHeaderValue(val)); } } else if (typeof value.entries === "function") { for (const { 0: key, 1: val } of value.entries()) { - this.appendHeader(key, val); + this.appendHeader(key, snapshotHeaderValue(val)); } } else { const keys = ObjectKeys(value); @@ -3492,11 +3777,9 @@ ServerResponse.prototype.writeContinue = function (cb) { this._sent100 = true; return; } - const native = this.socket?.[kHandle]?.response; - if (native) native.writeContinue(); - else this[kHandle]?.writeContinue?.(); + this[kHandle].writeContinue(); this._sent100 = true; - cb?.(); + if (typeof cb === "function") process.nextTick(cb); }; // This end method is actually on the OutgoingMessage prototype in Node.js @@ -3560,18 +3843,13 @@ ServerResponse.prototype.end = function (chunk, encoding, callback) { // Content-Length and the response can carry a body - Node.js drops them in // every other case (explicit Content-Length, HTTP/1.0, body-less statuses). const trailer = this._trailer; - if ( - trailer && - this._hasBody && - !this.hasHeader("content-length") && - this.req?.httpVersionMajor === 1 && - this.req?.httpVersionMinor >= 1 - ) { + if (trailer && this._hasBody && willBeChunked(this)) { this.socket?.[kHandle]?.setResponseTrailers(trailer); } + if (!droppedChunk) callWriteHeadIfObservable(this, this[headerStateSymbol], true); + // Read after the call: a replaced writeHead() can send the head itself with write(). const headerState = this[headerStateSymbol]; - if (!droppedChunk) callWriteHeadIfObservable(this, headerState, true); const flags = handle.flags; if (!!(flags & NodeHTTPResponseFlags.closed_or_completed)) { @@ -3643,6 +3921,14 @@ ServerResponse.prototype.end = function (chunk, encoding, callback) { } } this._header = " "; + noteResponseWrite(this); + // Like Node's end(), fully uncork the socket. A leftover cork would buffer a later 'connect' or 'upgrade' listener's writes forever. + const socket = this[fakeSocketSymbol]; + const socketWritableState = socket?._writableState; + if (socketWritableState?.corked) { + socketWritableState.corked = 1; + socket.uncork(); + } // The socket is NOT detached here: like Node.js, res.socket stays assigned // until the response 'finish' machinery runs (the dispatcher detaches it // right after a synchronously-finished handler returns, or via its 'finish' @@ -3738,6 +4024,7 @@ ServerResponse.prototype.write = function (chunk, encoding, callback) { let result = 0; const headerState = this[headerStateSymbol]; + if (handle && headerState !== NodeHTTPHeaderState.sent) validateChunk(chunk); callWriteHeadIfObservable(this, headerState); if (!handle) { @@ -3760,7 +4047,7 @@ ServerResponse.prototype.write = function (chunk, encoding, callback) { // updateHasBody only ever clears _hasBody, so this is idempotent. updateHasBody(this, (this.statusCode = validateStatusCode(this.statusCode))); } - if (chunk && !this._hasBody) { + if ((chunk || chunk === "") && !this._hasBody) { if (this[kRejectNonStandardBodyWrites]) { throw $ERR_HTTP_BODY_NOT_ALLOWED(); } else { @@ -3815,6 +4102,7 @@ ServerResponse.prototype.write = function (chunk, encoding, callback) { return false; } + if (hasBody) noteResponseWrite(this); this._callPendingCallbacks(); if (callback) { // A write callback reports that its bytes reached the socket: uncork them before the callback can destroy the response. @@ -3865,9 +4153,14 @@ function flushWriteAccountingNT(res) { res[kAccountingFlushScheduled] = false; const needsDrain = (res[kBytesBuffered] ?? 0) >= res.writableHighWaterMark; res[kBytesBuffered] = 0; - if (needsDrain && !res.destroyed && !res.finished) { - res.emit("drain"); + if (!needsDrain || res.destroyed || res.finished) return; + const handle = res[kHandle]; + if (handle?.bufferedAmount > 0) { + // The handle may still reference the caller's buffer for an unsent tail, so 'drain' waits for its drain callback. + handle.onwritable ??= allowWritesToContinue.bind(res); + return; } + res.emit("drain"); } function scheduleWriteAccountingFlush(res) { if (res[kAccountingFlushScheduled]) return; @@ -3919,17 +4212,26 @@ ServerResponse.prototype._implicitHeader = function () { this.writeHead(this.statusCode); }; +// A response without a native handle (new ServerResponse(req) and assignSocket()) writes through OutgoingMessage, which keeps its own accounting. +const { + writableNeedDrain: { get: outgoingWritableNeedDrain }, + writableFinished: { get: outgoingWritableFinished }, + writableLength: { get: outgoingWritableLength }, +} = Object.getOwnPropertyDescriptors(OutgoingMessagePrototype); + Object.defineProperty(ServerResponse.prototype, "writableNeedDrain", { get() { // True between a write() that returned false and the next 'drain'. An armed native drain callback means a later write flushed the bytes. const handle = this[kHandle]; + if (!handle) return outgoingWritableNeedDrain!.$call(this); + if (this.destroyed || this.finished) return false; + // A queued pipelined response's handle reports the bytes buffered for the response ahead of it, and those never emit 'drain' here. + const queued = this[kPipelinedQueuedState]; + if (queued !== undefined) return queued.needDrain; return ( - !this.destroyed && - !this.finished && - ((handle?.bufferedAmount ?? 0) !== 0 || - handle?.onwritable !== undefined || - (this[kBytesBuffered] ?? 0) >= this.writableHighWaterMark || - (this[kPipelinedQueuedState]?.needDrain ?? false)) + handle.bufferedAmount !== 0 || + handle.onwritable !== undefined || + (this[kBytesBuffered] ?? 0) >= this.writableHighWaterMark ); }, }); @@ -3937,24 +4239,22 @@ Object.defineProperty(ServerResponse.prototype, "writableNeedDrain", { Object.defineProperty(ServerResponse.prototype, "writableFinished", { get() { const handle = this[kHandle]; + if (!handle) return outgoingWritableFinished!.$call(this); // Like Node, nothing is left to write once the socket is gone. - return !!( - this.finished && - (!handle || handle.finished || handle.flags & NodeHTTPResponseFlags.closed_or_completed) - ); + return !!(this.finished && (handle.finished || handle.flags & NodeHTTPResponseFlags.closed_or_completed)); }, }); Object.defineProperty(ServerResponse.prototype, "writableLength", { get() { + const handle = this[kHandle]; + if (!handle) return outgoingWritableLength!.$call(this); if (this.writableFinished) return 0; // Bytes handed off this event-loop turn (including chunked framing, like // Node.js's outputData accounting) plus whatever the native handle still // has buffered, plus anything buffered while queued behind a pipelined // response. - return ( - (this[kBytesBuffered] ?? 0) + (this[kHandle]?.bufferedAmount ?? 0) + (this[kPipelinedQueuedState]?.bytes ?? 0) - ); + return (this[kBytesBuffered] ?? 0) + handle.bufferedAmount + (this[kPipelinedQueuedState]?.bytes ?? 0); }, }); @@ -4121,6 +4421,8 @@ ServerResponse.prototype.flushHeaders = function () { // socket (advanceResponsePipeline) - nothing can be flushed before then. return; } + // Called from a replaced writeHead() that end() is driving. end() sends the head with the body, like Node's corked end(). + if (this[kImplicitHeaderFromEnd]) return; const handle = this[kHandle]; if (handle) { @@ -4143,6 +4445,7 @@ ServerResponse.prototype.flushHeaders = function () { } } handle.flushHeaders(); + noteResponseWrite(this); } else { // Standalone path: _storeHeader rendered this._header; _send('') pushes // it to the assigned socket like OutgoingMessage.flushHeaders does. @@ -4188,7 +4491,17 @@ function callWriteHeadIfObservable(self, headerState, fromEnd?) { } } +// A write that only reached the backpressure buffer doesn't count, so a peer that stopped reading still times out. +function noteResponseWrite(res) { + const socket = res[fakeSocketSymbol]; + if (!socket?.timeout) return; + // The timer may have fired and a 'timeout' listener kept the socket. refresh() arms it again, like in Node. + if (socket[kLastResponseWrite] === undefined) socket._unrefTimer(); + socket[kLastResponseWrite] = monotonicNowMs(); +} + function allowWritesToContinue() { + noteResponseWrite(this); this._callPendingCallbacks(); this.emit("drain"); } @@ -4204,6 +4517,7 @@ function failPendingWriteCallbacks(res, error) { } } +ServerResponse.prototype._expect_continue = false; OriginalWriteHeadFn = ServerResponse.prototype.writeHead; OriginalImplicitHeadFn = ServerResponse.prototype._implicitHeader; @@ -4321,6 +4635,15 @@ function storeHTTPOptions(options) { validateBoolean(optimizeEmptyRequests, "options.optimizeEmptyRequests"); } this[kOptimizeEmptyRequests] = optimizeEmptyRequests || false; + + // Like Node's net.Server, which stores the delay in seconds: https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L1850-L1882 + let keepAliveInitialDelay = options.keepAliveInitialDelay; + if (keepAliveInitialDelay !== undefined) { + validateNumber(keepAliveInitialDelay, "options.keepAliveInitialDelay"); + if (keepAliveInitialDelay < 0) keepAliveInitialDelay = 0; + } + this.keepAlive = Boolean(options.keepAlive); + this.keepAliveInitialDelay = ~~(keepAliveInitialDelay / 1000); } // Share the pipelining machinery with internal/http1_server_fallback through @@ -4333,9 +4656,16 @@ http1ServerPipeline.maybePauseFallbackReads = maybePauseFallbackReads; http1ServerPipeline.resumeFallbackReadsOnDrain = resumeFallbackReadsOnDrain; http1ServerPipeline.finishDrainedResponse = finishDrainedResponse; http1ServerPipeline.kMustCloseConnection = kMustCloseConnection; +http1ServerPipeline.socketOnError = socketOnError; +http1ServerPipeline.socketOnTimeout = onNodeHTTPServerSocketTimeout; +http1ServerPipeline.onResponseFinishHandleSocket = onResponseFinishHandleSocket; +http1ServerPipeline.resetSocketTimeout = resetFallbackSocketTimeout; +http1ServerPipeline.kConnectionsCheckingInterval = kConnectionsCheckingInterval; export default { Server, ServerResponse, + setupConnectionsTracking, + _connectionListener: connectionListener, kConnectionsCheckingInterval, }; diff --git a/src/js/node/dgram.ts b/src/js/node/dgram.ts index b26bdb0d25b7..44cc85cb2247 100644 --- a/src/js/node/dgram.ts +++ b/src/js/node/dgram.ts @@ -1186,17 +1186,20 @@ Socket.prototype[SymbolAsyncDispose] = async function () { return; } const promise = $newPromise(); - this.close(FunctionPrototypeBind.$call(onAsyncDisposeClosed, undefined, promise)); + this.close(createAsyncDisposeClosedCallback(promise)); return promise; }; -function onAsyncDisposeClosed(promise, err) { - if (err) { - $rejectPromiseWithFirstResolvingFunctionCallCheck(promise, err); - } else { - $resolvePromiseWithFirstResolvingFunctionCallCheck(promise, undefined); - } +// A closure, not bind(). A patched Function.prototype.bind would receive a function that settles any argument without a type check. +function createAsyncDisposeClosedCallback(promise) { + return err => { + if (err) { + $rejectPromiseWithFirstResolvingFunctionCallCheck(promise, err); + } else { + $resolvePromiseWithFirstResolvingFunctionCallCheck(promise, undefined); + } + }; } function socketCloseNT(self) { diff --git a/src/js/node/http.ts b/src/js/node/http.ts index 26aedf076fa7..677f09e89750 100644 --- a/src/js/node/http.ts +++ b/src/js/node/http.ts @@ -5,7 +5,7 @@ const { ClientRequest } = require("node:_http_client"); const { validateHeaderName, validateHeaderValue, parsers } = require("node:_http_common"); const { IncomingMessage } = require("node:_http_incoming"); const { OutgoingMessage } = require("node:_http_outgoing"); -const { Server, ServerResponse } = require("node:_http_server"); +const { Server, ServerResponse, _connectionListener } = require("node:_http_server"); const { METHODS, STATUS_CODES, setMaxHTTPHeaderSize, getMaxHTTPHeaderSize } = require("internal/http"); @@ -42,6 +42,7 @@ function get(url, options, cb) { } const http_exports = { + _connectionListener, Agent, Server, METHODS, diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 62e209bd231f..9f1ea7735182 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -28,7 +28,7 @@ */ const { isTypedArray } = require("node:util/types"); const { hideFromStack, hasObserver, enqueueNodeEntry, PerformanceNodeEntry } = require("internal/shared"); -const { STATUS_CODES, utcDate } = require("internal/http"); +const { STATUS_CODES, utcDate, http1ServerPipeline } = require("internal/http"); const { kTimeout, getTimerDuration } = require("internal/timers"); const tls = require("node:tls"); const net = require("node:net"); @@ -72,6 +72,7 @@ type Http2ConnectOptions = { ALPNProtocols?: string[]; maxConcurrentStreams?: number; maxOutstandingSettings?: number; + maxOriginSetSize?: number; maxReservedRemoteStreams?: number; remoteCustomSettings?: number[]; strictFieldWhitespaceValidation?: boolean; @@ -89,8 +90,10 @@ const ObjectKeys = Object.keys; const FunctionPrototypeBind = Function.prototype.bind; const StringPrototypeTrim = String.prototype.trim; const ArrayPrototypePush = Array.prototype.push; +const ArrayPrototypeSlice = Array.prototype.slice; const StringPrototypeToLowerCase = String.prototype.toLowerCase; const StringPrototypeIncludes = String.prototype.includes; +const StringPrototypeSlice = String.prototype.slice; const StringPrototypeStartsWith = String.prototype.startsWith; const ObjectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty; @@ -142,6 +145,7 @@ function throwSettingTypeError(name: string, value: any) { } function validateSettings(settings: any) { + if (settings === undefined) return; if (typeof settings !== "object" || settings === null || $isArray(settings)) { throw $ERR_INVALID_ARG_TYPE("settings", "object", settings); } @@ -227,10 +231,6 @@ function validateSettings(settings: any) { } } -function assertSettings(settings: any) { - validateSettings(settings); -} - function getPackedSettings(settings?: any): Buffer { if (settings === undefined) return Buffer.alloc(0); validateSettings(settings); @@ -359,6 +359,7 @@ const bunHTTP2SessionTeardownFrame = Symbol("::bunhttp2sessionteardownframe::"); // undefined (the root context), so "no teardown in progress" needs its own value. const kNoSessionTeardown = Symbol("::bunhttp2noteardown::"); const runInFrame = require("internal/async_context_frame").run; +const moduleGraphOfFrame = require("internal/async_context_frame").graphOf; const ReflectGetPrototypeOf = Reflect.getPrototypeOf; @@ -378,6 +379,8 @@ const kSetStreamId = Symbol("setStreamId"); const kRequest = Symbol("request"); const kHeadRequest = Symbol("headRequest"); const kSessionDestroyError = Symbol("sessionDestroyError"); +// Streams the parser already freed that wait for 'end' before they destroy themselves. +const kUnreadClosedStreams = Symbol("unreadClosedStreams"); // Set on a ServerHttp2Stream by respondWithFile(): the stream opened the descriptor itself and is // responsible for closing it exactly once. respondWithFD() descriptors belong to the caller. const kOwnsFd = Symbol("ownsFd"); @@ -400,6 +403,10 @@ let priorityWeightDeprecationWarned = false; // Marks a client stream created from a received PUSH_PROMISE: its response HEADERS fire 'push'. const kPush = Symbol("pushStream"); const kNeverAnnounced = Symbol("neverAnnounced"); +// Set on a client request while it waits in its session's queue: the arguments it's submitted with. +const kQueuedRequest = Symbol("queuedRequest"); +const kRemoveQueuedRequest = Symbol("removeQueuedRequest"); +const kCancelQueuedRequest = Symbol("cancelQueuedRequest"); const kReceivedGoaway = Symbol("receivedGoaway"); // The error code carried by a received GOAWAY; like Node's state.goawayCode it // takes precedence over the destroy code when streams are torn down. @@ -448,18 +455,6 @@ function emitSessionCloseNT(self: Http2Session, frame) { runInFrame(frame, self.emit, self, "close"); } } -function emitErrorNT(self: any, error: any, destroy: boolean) { - if (destroy) { - if (self.listenerCount("error") > 0) { - self.destroy(error); - } else { - self.destroy(); - } - } else if (self.listenerCount("error") > 0) { - self.emit("error", error); - } -} - function emitOutofStreamErrorNT(self: any) { self.destroy($ERR_HTTP2_OUT_OF_STREAMS()); } @@ -1227,20 +1222,19 @@ class Http2ServerResponse extends Stream { const linkHeaderValue = validateLinkHeaderValue(hints.link); for (const key of ObjectKeys(hints)) { if (key !== "link") { - headers[key] = hints[key]; + const name = key.trim().toLowerCase(); + assertValidHeader(name, hints[key]); + if (!checkIsHttpToken(name)) { + throw $ERR_INVALID_HTTP_TOKEN("Header name", name); + } + headers[name] = hints[key]; } } if (linkHeaderValue.length === 0) { return false; } - const stream = this[kStream]; - if (stream.headersSent || this[kState].closed) return false; - stream.additionalHeaders({ - ...headers, - [HTTP2_HEADER_STATUS]: HTTP_STATUS_EARLY_HINTS, - "Link": linkHeaderValue, - }); - return true; + headers.Link = linkHeaderValue; + return this.writeInformation(HTTP_STATUS_EARLY_HINTS, headers); } } @@ -1854,12 +1848,14 @@ const kNghttp2ErrorMessages = { [-502]: "Out of buffer space", [-505]: "Protocol error", [-510]: "Stream was already closed or invalid", + [-517]: "GOAWAY has already been sent", [-522]: "The length of the frame is invalid", [-523]: "Header compression/decompression error", [-524]: "Flow control error", [-901]: "Out of memory", [-903]: "Received bad client magic byte string", [-904]: "Flooding was detected in this HTTP/2 session, and it must be closed", + [-905]: "Too many CONTINUATION frames following a HEADER frame", }; class NghttpError extends Error { @@ -1915,6 +1911,7 @@ abstract class Http2Session extends EventEmitter { // run inside it so 'close' doesn't inherit the last stream's frame. [bunHTTP2AsyncContextFrame] = $getInternalField($asyncContext, 0); [kDeferWriteCallback]: typeof process.nextTick | typeof setImmediate = setImmediate; + [kUnreadClosedStreams]: Set | null = null; [kTransportClosed] = false; // The GOAWAY this side received (not one it sent), like node's Http2Session getters. get goawayCode() { @@ -1943,6 +1940,40 @@ function streamErrorFromCode(code: number) { return $ERR_HTTP2_STREAM_ERROR(nameForErrorCode[code] || code); } hideFromStack(streamErrorFromCode); +// One per SETTINGS frame a settings() call sent: [callback, start time, async context frame]. +type PendingSettings = [Function | undefined, number, unknown]; +function submitSettings(self, parser, pending: Array, settings, callback) { + // A getter on `settings` can destroy the session during validation. + if (!parser) return; + const socket = self[bunHTTP2Socket]; + // While the socket connects, node submits from a 'connect' listener, so the ACK runs in that event's context. + const connecting = socket?.connecting || socket?.secureConnecting; + const frame = connecting ? self[bunHTTP2AsyncContextFrame] : $getInternalField($asyncContext, 0); + const entry: PendingSettings = [callback || undefined, Date.now(), frame]; + // Queue the entry first. A Duplex transport can deliver the ACK inside flush(). + pending.push(entry); + try { + parser.settings(settings); + } catch (err) { + const index = pending.lastIndexOf(entry); + if (index !== -1) pending.splice(index, 1); + throw err; + } + // Node schedules a write for every settings() call. A late frame could miss a session that closes at the next response. + parser.flush(); +} +// Node's settingsCallback runs the callback first, then emits 'localSettings', both in the settings() caller's context. +function deliverSettingsAck(self, settings, callback, start) { + if (callback !== undefined) callback(null, settings, Date.now() - start); + self.emit("localSettings", settings); +} +function onSettingsAck(self, settings, queued: PendingSettings | null | undefined) { + if (queued == null) return void self.emit("localSettings", settings); + const current = $getInternalField($asyncContext, 0); + // A caller inside another Bun.ModuleGraph doesn't get its context installed around the session's listeners. + const frame = moduleGraphOfFrame(queued[2]) === moduleGraphOfFrame(current) ? queued[2] : current; + runInFrame(frame, deliverSettingsAck, undefined, self, settings, queued[0], queued[1]); +} // Pending ping callbacks are invoked with the cancel error when their session goes away (node // semantics) instead of being silently dropped. Like node's pingCallback wrapper, a cancelled // ping reports only the error (no duration/payload arguments). @@ -2026,7 +2057,8 @@ function validateWindowSize(windowSize) { hideFromStack(validateWindowSize); function pushToStream(stream, data) { - if (data && stream[bunHTTP2StreamStatus] & StreamState.Closed) return; + // close() ends the readable before it sets Closed, and an in-process transport can deliver DATA in between. + if (data && (stream[bunHTTP2StreamStatus] & StreamState.Closed || stream._readableState.ended)) return; // Node's onStreamRead (lib/internal/stream_base_commons.js): push() returning false // is the readable side's backpressure signal, and the reader must readStop() @@ -2041,6 +2073,9 @@ function pushToStream(stream, data) { if (perf.firstByte === 0) perf.firstByte = performance.now() - perf.start; perf.bytesRead += data.length; } + if (stream[kTimeout]) stream[kTimeout].refresh(); + const sessionTimer = stream[bunHTTP2Session]?.[kTimeout]; + if (sessionTimer) sessionTimer.refresh(); if (!stream.push(data) && data !== null) { streamOnPause.$call(stream); } @@ -2052,6 +2087,25 @@ function endInboundHalf(stream: Http2Stream) { pushToStream(stream, null); } +// Like node, only END_STREAM on a DATA frame reads the EOF. streamHeaders already pushed a HEADERS frame's, and it waits for a reader. +function endReadableAtHalfClose(stream: Http2Stream) { + if (!stream.readable || stream.closed || stream._readableState.ended) return; + endInboundHalf(stream); + stream.read(0); +} +function endServerReadableAtClose(stream: Http2Stream) { + endInboundHalf(stream); + if (stream.readableFlowing === null) { + // Like node's onStreamClose, dump a request nobody tried to read so the stream can finish. + stream.resume(); + } else if (stream.writableFinished) { + // Only read(0) ends a paused, empty readable. It waits for 'finish', which the destroy() after 'end' would swallow. + stream.read(0); + } else { + stream.once("finish", readNothing); + } +} + enum StreamState { EndedCalled = 1 << 0, // 00001 = 1 WantTrailer = 1 << 1, // 00010 = 2 @@ -2069,6 +2123,14 @@ enum StreamState { // callback). Until then no 'error' listener can exist, so stream errors must not be emitted: // node never constructs the JS stream object before a complete header block arrives. Delivered = 1 << 8, // 100000000 = 256 + // close(code) scheduled an RST_STREAM, so neither _final nor the last DATA frame may send END_STREAM first. + RstPending = 1 << 9, // 1000000000 = 512 + // An RST_STREAM was sent or received, so the stream has nothing left to send. + Reset = 1 << 10, // 10000000000 = 1024 + // The client request counts against the peer's SETTINGS_MAX_CONCURRENT_STREAMS. + HoldsRequestSlot = 1 << 11, // 100000000000 = 2048 + // The request HEADERS that opened this server stream carried END_STREAM. + EndAfterHeaders = 1 << 12, // 1000000000000 = 4096 } // native.writeStream() return-value flag (mirrors WRITE_FLUSHED_WITHOUT_CALLBACK in // h2_frame_parser.rs): the chunk was handed to the socket without queueing and the engine did @@ -2095,7 +2157,8 @@ function isFinalWrite(stream: Http2Stream, pendingLength: number) { return ( (stream._writableState.ending || stream[kEndingWithChunk] === true) && stream.writableLength === pendingLength && - !stream[bunHTTP2WaitForTrailers] + !stream[bunHTTP2WaitForTrailers] && + (stream[bunHTTP2StreamStatus] & StreamState.RstPending) === 0 ); } @@ -2105,9 +2168,10 @@ function markWritableDone(stream: Http2Stream) { stream[bunHTTP2StreamFinal] = null; _final(); stream[bunHTTP2StreamStatus] |= StreamState.WritableClosed | StreamState.FinalCalled; - return; + } else { + stream[bunHTTP2StreamStatus] |= StreamState.WritableClosed; } - stream[bunHTTP2StreamStatus] |= StreamState.WritableClosed; + if (stream instanceof ServerHttp2Stream) maybeCloseUnreadServerStream(stream); } const kCloseChannelPublished = Symbol("closeChannelPublished"); function publishStreamCloseChannel(stream: Http2Stream) { @@ -2220,13 +2284,33 @@ function markStreamClosed(stream: Http2Stream) { if ((status & StreamState.Closed) === 0) { stream[bunHTTP2StreamStatus] = status | StreamState.Closed; + clearTimeout(stream[kTimeout]); + stream[kTimeout] = null; + stream.removeAllListeners("timeout"); publishStreamCloseChannel(stream); markWritableDone(stream); } } -function rstNextTick(id: number, rstCode: number) { - const session = this as Http2Session; +// Like node's closeStream(), which runs once per stream. Returns true if the caller still has to end() the writable side. +function abortIfWritableOpen(stream: Http2Stream) { + if (stream._writableState.ending) return false; + if ((stream[bunHTTP2StreamStatus] & StreamState.Closed) === 0 && !stream[kAborted]) { + stream[kAborted] = true; + stream.emit("aborted"); + } + return true; +} +// Like node's onStreamClose, the peer's CANCEL ends an open writable side before the stream is destroyed, so 'finish' still fires. +function abortOnPeerCancel(stream: Http2Stream) { + stream[bunHTTP2StreamStatus] |= StreamState.Reset | StreamState.NativeClosed | StreamState.WritableClosed; + stream.rstCode = NGHTTP2_CANCEL; + if (abortIfWritableOpen(stream)) stream.end(); +} +function rstNextTick(this: Http2Stream, session: Http2Session, id: number, rstCode: number) { + // Native can't dedupe this. It forgets a closed stream at the next read. + if ((this[bunHTTP2StreamStatus] & (StreamState.NativeClosed | StreamState.Reset)) !== 0) return; + this[bunHTTP2StreamStatus] |= StreamState.Reset; session[bunHTTP2Native]?.rstStream(id, rstCode); } // node streamOnPause/streamOnResume (lib/internal/http2/core.js): the readable's flow state @@ -2249,11 +2333,14 @@ function sendRstOnReady(this: Http2Stream, session: Http2Session, code: number) 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)); + setImmediate(rstNextTick.bind(this, session, id, code)); } function uncorkNT(stream: Http2Stream) { stream.uncork(); } +function readNothing(this: Http2Stream) { + this.read(0); +} // Per-request AbortSignal handler: node destroys the stream with an AbortError when the signal // fires (lib/internal/http2/core.js request()). function abortRequestStream(this: Http2Stream, signal: AbortSignal) { @@ -2300,8 +2387,13 @@ function destroyStreamForSessionDestroy(error: Error | undefined, rstCode: numbe // NGHTTP2_CANCEL while unread UNIMPLEMENTED streams are still around). stream.destroy(error !== undefined && stream.listenerCount("error") > 0 ? error : undefined); } +function cancelStreamForSessionDestroy(session: ClientHttp2Session, rstCode: number, stream: Http2Stream) { + if (stream.destroyed || stream.closed) return; + emitStreamErrorNT(session, stream, rstCode, true, false); +} interface Http2StreamReadableState { destroyed: boolean; + ended: boolean; } interface Http2StreamWritableState { ending: boolean; @@ -2312,7 +2404,9 @@ interface DuplexStateAccessors { readonly closed: boolean; destroyed: boolean; } -type Http2StreamBase = new (options?: import("node:stream").DuplexOptions) => DuplexStream & DuplexStateAccessors; +type Http2StreamBase = new ( + options?: import("node:stream").DuplexOptions & { writable?: boolean }, +) => DuplexStream & DuplexStateAccessors; class Http2Stream extends (Duplex as Http2StreamBase) { declare _readableState: Http2StreamReadableState; @@ -2329,17 +2423,21 @@ class Http2Stream extends (Duplex as Http2StreamBase) { // async-context tracking when no AsyncLocalStorage is in use. [bunHTTP2AsyncContextFrame] = $getInternalField($asyncContext, 0); - rstCode: number | undefined = undefined; + rstCode: number = 0; + timeout: number | undefined = undefined; + [kTimeout]: ReturnType | null = null; [bunHTTP2Headers]: any; + [kRequestHeaders]: any; [kInfoHeaders]: any; #sentTrailers: any; [kSendingTrailers]: boolean = false; [kAborted]: boolean = false; [kHeadRequest]: boolean = false; - constructor(streamId, session, headers) { + constructor(streamId, session, headers, writable = true) { super({ decodeStrings: false, autoDestroy: false, + writable, }); this.#id = streamId; this[bunHTTP2Session] = session; @@ -2353,7 +2451,7 @@ class Http2Stream extends (Duplex as Http2StreamBase) { } get scheme() { - const headers = this[bunHTTP2Headers]; + const headers = this[kRequestHeaders] || this[bunHTTP2Headers]; if (headers) return headers[":scheme"] || "https"; return "https"; } @@ -2427,6 +2525,7 @@ class Http2Stream extends (Duplex as Http2StreamBase) { if ((this[bunHTTP2StreamStatus] & StreamState.WantTrailer) === 0) { throw $ERR_HTTP2_TRAILERS_NOT_READY(); } + if (this[kTimeout]) this[kTimeout].refresh(); if (headers == undefined) { headers = {}; @@ -2443,26 +2542,17 @@ class Http2Stream extends (Duplex as Http2StreamBase) { // symbol keys, and deleting it here would flip the object into dictionary mode, // pessimizing every later property access on it. const sensitiveNames = buildSensitiveNames(headers, sensitives); + assertNoConnectionHeaders(headers); // node keeps the never-index list visible on sentTrailers (symbol keys are not iterated by // the wire-encoding path, so re-attaching is safe). if (sensitives !== undefined) headers[sensitiveHeaders] = sensitives; - // RFC 9113 §8.1 doesn't explicitly forbid an empty trailer HEADERS frame, - // but strict peer implementations (nghttp2, used by curl and Node) reject - // a zero-length HPACK block as a callback failure. When the user passes an - // empty trailer object (which the compat Http2ServerResponse does - // unconditionally from onStreamTrailersReady), emit an empty DATA frame - // with END_STREAM instead — this matches Node's wire output. // Mark before the native call so a re-entrant sendTrailers() from a header-value // coercion hits ERR_HTTP2_TRAILERS_ALREADY_SENT, but clear it if validation throws // (no frame is written then) so a corrected retry succeeds like node. this.#sentTrailers = headers; this[kSendingTrailers] = true; try { - if (ObjectKeys(headers).length === 0) { - session[bunHTTP2Native]?.noTrailers(this.#id); - } else { - session[bunHTTP2Native]?.sendTrailers(this.#id, headers, sensitiveNames); - } + session[bunHTTP2Native]?.sendTrailers(this.#id, headers, sensitiveNames); } catch (error) { this.#sentTrailers = undefined; throw error; @@ -2471,10 +2561,8 @@ class Http2Stream extends (Duplex as Http2StreamBase) { } } - setTimeout(timeout, callback) { - const session = this[bunHTTP2Session]; - if (!session) return; - session.setTimeout(timeout, callback); + setTimeout(msecs, callback) { + return setIdleTimeout.$call(this, msecs, callback, this[bunHTTP2Session], streamTimerExpired); } get closed() { @@ -2503,7 +2591,19 @@ class Http2Stream extends (Duplex as Http2StreamBase) { get state() { const session = this[bunHTTP2Session]; if (session && !session.destroyed && typeof this.#id === "number") { - return session[bunHTTP2Native]?.getStreamState(this.#id) ?? {}; + const native = session[bunHTTP2Native]; + if (!native) return {}; + // Node's Http2Stream::RefreshState reports an idle stream once nghttp2 has dropped the id. + return ( + native.getStreamState(this.#id) ?? { + state: constants.NGHTTP2_STREAM_STATE_IDLE, + weight: 0, + sumDependencyWeight: 0, + localClose: 0, + remoteClose: 0, + localWindowSize: 0, + } + ); } // node reports an empty object while the stream is still pending (no id yet) and once the // stream's session has been destroyed. @@ -2528,11 +2628,7 @@ class Http2Stream extends (Duplex as Http2StreamBase) { } get endAfterHeaders() { - const session = this[bunHTTP2Session]; - if (session) { - return session[bunHTTP2Native]?.getEndAfterHeaders(this.#id) || false; - } - return false; + return (this[bunHTTP2StreamStatus] & StreamState.EndAfterHeaders) !== 0; } get aborted() { @@ -2552,7 +2648,12 @@ class Http2Stream extends (Duplex as Http2StreamBase) { // push, regardless of the server-settings default. const session = this[bunHTTP2Session]; return ( - session != null && session.type === 0 && !!session.remoteSettings?.enablePush && !this.destroyed && !this.closed + session != null && + session.type === 0 && + !session.closed && + !!session.remoteSettings?.enablePush && + !this.destroyed && + !this.closed ); } close(code?, callback?) { @@ -2572,30 +2673,29 @@ class Http2Stream extends (Duplex as Http2StreamBase) { validateFunction(callback, "callback"); this.once("close", callback); } + // An 'aborted' listener such as pipeline()'s can destroy the stream, and _destroy() resets it with this code. + this.rstCode = code; if (this.pending || code === NGHTTP2_NO_ERROR || code === NGHTTP2_CANCEL) { // For other rstCodes _destroy ends the readable so 'end' is suppressed. this.push(null); } - const { ending } = this._writableState; - if (!ending) { - // If the writable side of the Http2Stream is still open, emit the - // 'aborted' event and set the aborted flag. - if (!this.aborted) { - this[kAborted] = true; - this.emit("aborted"); - } - this.end(); - } + const endWritable = abortIfWritableOpen(this); + if (endWritable) this.end(); this.rstCode = code; markStreamClosed(this); if (this.pending) { + // Like node, only close() after end(body) still sends the request. Its reset waits for 'finish'. + const resetsNow = endWritable || code !== NGHTTP2_NO_ERROR || this.writableLength === 0; + if (resetsNow && (session as ClientHttp2Session)[kCancelQueuedRequest](this as unknown as ClientHttp2Stream)) + return; // No id yet (the HEADERS frame is still queued behind connect/concurrency limits): the // RST_STREAM has to be sent after the HEADERS frame, once the id is assigned. this.once("ready", sendRstOnReady.bind(this, session, code)); } else if (this.writableFinished || code) { - setImmediate(rstNextTick.bind(session, this.#id, code)); + this[bunHTTP2StreamStatus] |= StreamState.RstPending; + setImmediate(rstNextTick.bind(this, session, this.#id, code)); } else { - this.once("finish", rstNextTick.bind(session, this.#id, code)); + this.once("finish", rstNextTick.bind(this, session, this.#id, code)); } // node destroys the stream once both halves have finished; without this a stream closed // while idle never emits 'close'. @@ -2611,30 +2711,19 @@ class Http2Stream extends (Duplex as Http2StreamBase) { // push(null)) and a throwing listener would otherwise skip the clear and // leave a retained stream pinning the store. this[bunHTTP2AsyncContextFrame] = undefined; - const { ending } = this._writableState; + this[bunHTTP2Session]?.[kUnreadClosedStreams]?.delete(this); + if (this[kQueuedRequest] !== undefined) this[bunHTTP2Session]?.[kRemoveQueuedRequest](this); this.push(null); - // A pushed stream's request was synthesized by the server, so its local (writable) half is - // closed by definition — closing it is not an abort and nothing must be sent on the wire. - if (!ending && !this[kPush]) { - // If the writable side of the Http2Stream is still open, emit the - // 'aborted' event and set the aborted flag. - if (!this.aborted) { - this[kAborted] = true; - this.emit("aborted"); - } - // at this state destroyed will be true but we need to close the writable side - this._writableState.destroyed = false; - this.end(); - // we now restore the destroyed flag - this._writableState.destroyed = true; - } + // `destroyed` is already set, so end() skips _final and the peer gets RST_STREAM instead of END_STREAM. + if (abortIfWritableOpen(this)) this.end(); const session = this[bunHTTP2Session]; assertSession(session); let rstCode = this.rstCode; if (!rstCode) { - if (err != null) { + // Like Node, a stream that already closed cleanly keeps its code. + if (err != null && (this[bunHTTP2StreamStatus] & StreamState.NativeClosed) === 0) { if (err.code === "ABORT_ERR") { // Enables using AbortController to cancel requests with RST code 8. rstCode = NGHTTP2_CANCEL; @@ -2682,11 +2771,9 @@ class Http2Stream extends (Duplex as Http2StreamBase) { session && typeof this.#id === "number" && !this[kNeverAnnounced] && - // A cleanly closed stream the native side already freed has nothing to send: - // the deferred rstStream would be a guaranteed no-op host call per request. - (rstCode !== 0 || (this[bunHTTP2StreamStatus] & StreamState.NativeClosed) === 0) + (this[bunHTTP2StreamStatus] & (StreamState.NativeClosed | StreamState.Reset)) === 0 ) { - setImmediate(rstNextTick.bind(session, this.#id, rstCode)); + setImmediate(rstNextTick.bind(this, session, this.#id, rstCode)); } // Diagnostics channels: published after the stream is closed and destroyed, with the same error @@ -2723,15 +2810,17 @@ class Http2Stream extends (Duplex as Http2StreamBase) { } return; } + if ((status & StreamState.RstPending) !== 0) { + this[bunHTTP2StreamStatus] = status | StreamState.FinalCalled | StreamState.WritableClosed; + callback(); + return; + } const session = this[bunHTTP2Session]; - if (session) { + if (session && (status & StreamState.NativeClosed) === 0) { const native = session[bunHTTP2Native]; if (native) { - if (this instanceof ServerHttp2Stream && !this.headersSent && (this.id & 1) === 0) { - // A locally-pushed (even-id) stream ended before respond() (HEAD/endStream pushes): an - // empty DATA frame would precede the response HEADERS on the wire. respond() forces - // endStream for these streams, so END_STREAM rides on the HEADERS frame and the - // streamEnd(7) dispatch completes this callback through markWritableDone. + if (this instanceof ServerHttp2Stream && !this.headersSent) { + // An empty DATA frame ahead of the response HEADERS is a connection error for the peer. this[bunHTTP2StreamFinal] = callback; return; } @@ -2834,9 +2923,8 @@ class Http2Stream extends (Duplex as Http2StreamBase) { } if ((status & StreamState.EndedCalled) !== 0) { - typeof callback == "function" && callback(); - // Writable#end always returns the stream (request(...).end() chains rely on it). - return this; + // The writable is already ending, so this only queues the callback and never reaches _final. + return super.end(undefined, undefined as unknown as BufferEncoding, callback); } this[bunHTTP2StreamStatus] = status | StreamState.EndedCalled; // Don't create an empty buffer for end() without data - let the Duplex stream @@ -2868,7 +2956,7 @@ class Http2Stream extends (Duplex as Http2StreamBase) { } } const session = this[bunHTTP2Session]; - if (session) { + if (session && (this[bunHTTP2StreamStatus] & StreamState.NativeClosed) === 0) { const native = session[bunHTTP2Native]; if (native) { if (session[kTransportClosed]) { @@ -2903,6 +2991,7 @@ class Http2Stream extends (Duplex as Http2StreamBase) { } const chunk = Buffer.concat(chunks || []); if (session[kTimeout]) session[kTimeout].refresh(); + if (this[kTimeout]) this[kTimeout].refresh(); const endStream = isFinalWrite(this, batchLength); const status = native.writeStream(this.#id, chunk, undefined, endStream, callback, true); if (status & kWriteFlushedWithoutCallback) session[kDeferWriteCallback](callback); @@ -2933,7 +3022,7 @@ class Http2Stream extends (Duplex as Http2StreamBase) { writePerf.bytesWritten += typeof chunk === "string" ? Buffer.byteLength(chunk, encoding) : chunk.length; } const session = this[bunHTTP2Session]; - if (session) { + if (session && (this[bunHTTP2StreamStatus] & StreamState.NativeClosed) === 0) { const native = session[bunHTTP2Native]; if (native) { if (session[kTransportClosed]) { @@ -2950,6 +3039,7 @@ class Http2Stream extends (Duplex as Http2StreamBase) { wireEncoding = undefined; } if (session[kTimeout]) session[kTimeout].refresh(); + if (this[kTimeout]) this[kTimeout].refresh(); const endStream = isFinalWrite(this, chunk.length); const status = native.writeStream(this.#id, wireChunk, wireEncoding, endStream, callback, true); if (status & kWriteFlushedWithoutCallback) session[kDeferWriteCallback](callback); @@ -2983,6 +3073,15 @@ class Http2Stream extends (Duplex as Http2StreamBase) { } class ClientHttp2Stream extends Http2Stream { declare authority: string | undefined; + headersSent = true; + [kQueuedRequest]: + | { + headers: HeadersObject; + wireHeaders: HeadersObject | any[]; + sensitiveNames: Record; + options: any; + } + | undefined = undefined; } // Wrap a native→JS #Handlers callback so its body runs inside the target @@ -3013,6 +3112,12 @@ function tryClose(fd) { function doSendFileFD(options, fd, headers, err, stat) { const onError = options.onError; const ownsFd = this[kOwnsFd] === true; + // Built before statCheck can write to `options`. Like node, a file response ignores endStream. + const respondOptions = { + waitForTrailers: options.waitForTrailers, + sendDate: options.sendDate, + paddingStrategy: options.paddingStrategy, + }; if (err) { if (ownsFd && err.code !== "EBADF") { tryClose(fd); @@ -3020,7 +3125,7 @@ function doSendFileFD(options, fd, headers, err, stat) { if (onError) onError(err); else { - if (!this.destroyed && !this.closed) this.respond(headers, options); + if (!this.destroyed && !this.closed) this.respond(headers, respondOptions); this.destroy(streamErrorFromCode(NGHTTP2_INTERNAL_ERROR)); } return; @@ -3039,7 +3144,8 @@ function doSendFileFD(options, fd, headers, err, stat) { if (ownsFd) tryClose(fd); if (onError) onError(err); else { - if (!this.destroyed && !this.closed) this.respond(headers, options); + // Like node, respondWithFile() fails before any headers go out. + if (!ownsFd && !this.destroyed && !this.closed) this.respond(headers, respondOptions); this.destroy(err); } return; @@ -3108,7 +3214,7 @@ function doSendFileFD(options, fd, headers, err, stat) { headers[HTTP2_HEADER_CONTENT_LENGTH] = statOptions.length; } try { - this.respond(headers, options); + this.respond(headers, respondOptions); } catch (err) { // respond() rejected the headers (e.g. a request pseudo-header in the response): the fd opened // for the file never reaches a read stream, so close it here before the stream is destroyed. @@ -3138,7 +3244,8 @@ function doSendFileFD(options, fd, headers, err, stat) { // An fd opened by respondWithFile is closed by its read stream once the transfer ends or // fails; an fd handed to respondWithFD stays the caller's to close (node semantics). autoClose: this[kOwnsFd] === true, - start: statOptions.offset ? statOptions.offset : undefined, + // Read a regular file by position. The caller can reuse a respondWithFD() descriptor, whose own position is at EOF by then. + start: stat.isFile() ? statOptions.offset : undefined, end: typeof statOptions.length === "number" ? statOptions.length + (statOptions.offset || 0) - 1 : undefined, emitClose: false, }); @@ -3149,6 +3256,7 @@ function doSendFileFD(options, fd, headers, err, stat) { cb(); return; } + if (stream[kTimeout]) stream[kTimeout].refresh(); const status = native.writeStream(stream.id, chunk, undefined, false, cb, true); if (status & kWriteFlushedWithoutCallback) process.nextTick(cb); }, @@ -3207,41 +3315,26 @@ function afterOpen(options, headers, err, fd) { fs.fstat(fd, doSendFileFD.bind(this, options, fd, headers)); } -// Node's Http2Stream[kMaybeDestroy] server branch (lib/internal/http2/core.js): once the -// response has finished, a server stream with no pending trailers whose request body was -// never consumed (no read()/'data'/pipe - readableDidRead + readableFlowing) is closed -// with RST_STREAM NO_ERROR. Without it a rejected upload deadlocks: the request body -// stalls once the receive window fills (nothing reads, so backpressure never reopens it) -// and the stream never reaches the fully-closed state, so the peer is never released. -// setImmediate matches node (it lets push streams reach the peer before the close). -// -// Unlike node, the native layer can dispatch the request's END_STREAM and body to JS -// after the response's 'finish' (node learns endOfStream with the headers), so at -// 'finish' an untouched readable does not prove the peer is stuck and an empty one does -// not prove it is not. Two timing-independent triggers cover both orderings: -// - 'finish' with unread request bytes already buffered (the peer was mid-upload), and -// - unread request bytes reaching JS after the response already finished -// (ServerHttp2Session's streamData). A request with no body can produce neither. +// Like node's kMaybeDestroy, RST_STREAM(NO_ERROR) closes an answered stream whose request was never read, or it stays open for as long as the peer likes. function maybeCloseUnreadServerStream(stream: ServerHttp2Stream) { - if (stream.destroyed || stream.closed) return; + // An ended request closes through streamEnd(7). + if (stream._readableState.ended || stream.destroyed || stream.closed) return; if (!stream.writableFinished || !stream.headersSent) return; - // A file response (respondWithFile/FD) ends the user-facing writable while the file is - // still being written natively, so writableFinished does not mean the response is done; - // node never installs its auto-close hook for that path (it nulls _final), match it. + // A reset drops queued frames, so the response's END_STREAM or trailers have to leave first. + if ((stream[bunHTTP2StreamStatus] & StreamState.WritableClosed) === 0) return; + // A file response ends the writable while native still writes the file. Node doesn't auto-close it either. if (stream[kFileResponseFinal] !== undefined) return; - // Node holds the auto-close while trailers are pending: its HAS_TRAILERS flag is set by - // respond({waitForTrailers}) and only cleared once sendTrailers() has submitted them. - if (stream[kSendingTrailers]) return; - if ((stream[bunHTTP2StreamStatus] & StreamState.WantTrailer) !== 0 && stream.sentTrailers === undefined) return; if (!stream.readableDidRead && stream.readableFlowing === null) { - setImmediate(callStreamClose, stream); + setImmediate(closeUnreadServerStream, stream); } } function serverStreamOnFinish(this: ServerHttp2Stream) { - if (this.readableLength > 0) maybeCloseUnreadServerStream(this); + maybeCloseUnreadServerStream(this); } -function callStreamClose(stream: ServerHttp2Stream) { - if (!stream.destroyed && !stream.closed) stream.close(); +function closeUnreadServerStream(stream: ServerHttp2Stream) { + if (stream.destroyed || stream.closed) return; + // Checked again: 'finish' can come before a handler attaches its reader behind an await. + if (!stream.readableDidRead && stream.readableFlowing === null) stream.close(); } class ServerHttp2Stream extends Http2Stream { headersSent = false; @@ -3253,7 +3346,7 @@ class ServerHttp2Stream extends Http2Stream { // user-facing writable while the file is still being written natively, so it must // never install the hook - the early 'finish' would RST and truncate the transfer. _final(callback) { - this.once("finish", serverStreamOnFinish); + if (!this._readableState.ended) this.once("finish", serverStreamOnFinish); super._final(callback); } // Node sends the implicit response headers (:status 200) when the stream is written to before @@ -3279,7 +3372,7 @@ class ServerHttp2Stream extends Http2Stream { } { const session = this[bunHTTP2Session]; - if (session == null || session.destroyed) { + if (session == null || session.destroyed || session.closed) { throw $ERR_HTTP2_PUSH_DISABLED(); } } @@ -3300,6 +3393,7 @@ class ServerHttp2Stream extends Http2Stream { if (!parser) { throw $ERR_HTTP2_INVALID_STREAM(); } + if (this[kTimeout]) this[kTimeout].refresh(); headers = { ...headers }; assertNoConnectionHeaders(headers); // Thrown synchronously like node, before a promised stream id is reserved. @@ -3313,14 +3407,14 @@ class ServerHttp2Stream extends Http2Stream { } const sensitiveNames = buildSensitiveNames(headers, sensitives); // A PUSH_PROMISE carries a request; default the method/path/scheme/authority like node does. - const parentRequestHeaders = this[kRequestHeaders] || this[bunHTTP2Headers]; + const parentRequestHeaders = this[kRequestHeaders]; if (headers[HTTP2_HEADER_METHOD] === undefined) headers[HTTP2_HEADER_METHOD] = "GET"; if (headers[HTTP2_HEADER_PATH] === undefined) headers[HTTP2_HEADER_PATH] = "/"; if (headers[HTTP2_HEADER_SCHEME] === undefined) { headers[HTTP2_HEADER_SCHEME] = parentRequestHeaders?.[HTTP2_HEADER_SCHEME] || this.scheme; } - if (headers[HTTP2_HEADER_AUTHORITY] === undefined && parentRequestHeaders) { - headers[HTTP2_HEADER_AUTHORITY] = parentRequestHeaders[HTTP2_HEADER_AUTHORITY]; + if (getAuthority(headers) === undefined && parentRequestHeaders) { + headers[HTTP2_HEADER_AUTHORITY] = getAuthority(parentRequestHeaders); } const pushId = parser.getNextStream(); if (pushId === -1) { @@ -3331,6 +3425,7 @@ class ServerHttp2Stream extends Http2Stream { const pushedStream = parser.getStreamContext(pushId); if (pushedStream && pushedStream[bunHTTP2Headers] == null) { pushedStream[bunHTTP2Headers] = headers; + pushedStream[kRequestHeaders] = headers; } if (pushedStream) endInboundHalf(pushedStream); if (onServerStreamCreatedChannel.hasSubscribers) { @@ -3387,7 +3482,7 @@ class ServerHttp2Stream extends Http2Stream { } respondWithFile(path, headers?: HeadersObject | null, options?) { - if (this.destroyed) { + if (this.destroyed || this.closed) { throw $ERR_HTTP2_INVALID_STREAM(); } if (this.headersSent) throw $ERR_HTTP2_HEADERS_SENT(); @@ -3408,7 +3503,10 @@ class ServerHttp2Stream extends Http2Stream { if (headers[HTTP2_HEADER_STATUS] === undefined) { headers[HTTP2_HEADER_STATUS] = 200; } - const statusCode = headers[HTTP2_HEADER_STATUS]; + const statusCode = (headers[HTTP2_HEADER_STATUS] |= 0); + if (statusCode < 200 || statusCode > 599) { + throw $ERR_HTTP2_STATUS_INVALID(statusCode); + } options = { ...options }; // Payload/DATA frames are not permitted in these cases @@ -3447,7 +3545,7 @@ class ServerHttp2Stream extends Http2Stream { throw err; } } - if (this.destroyed) { + if (this.destroyed || this.closed) { throw $ERR_HTTP2_INVALID_STREAM(); } if (this.headersSent) throw $ERR_HTTP2_HEADERS_SENT(); @@ -3468,7 +3566,10 @@ class ServerHttp2Stream extends Http2Stream { if (headers[HTTP2_HEADER_STATUS] === undefined) { headers[HTTP2_HEADER_STATUS] = 200; } - const statusCode = headers[HTTP2_HEADER_STATUS]; + const statusCode = (headers[HTTP2_HEADER_STATUS] |= 0); + if (statusCode < 200 || statusCode > 599) { + throw $ERR_HTTP2_STATUS_INVALID(statusCode); + } // Payload/DATA frames are not permitted in these cases if ( @@ -3515,6 +3616,7 @@ class ServerHttp2Stream extends Http2Stream { if (this.headersSent) { throw $ERR_HTTP2_HEADERS_AFTER_RESPOND(); } + if (this[kTimeout]) this[kTimeout].refresh(); if (headers == undefined) { headers = {}; @@ -3541,13 +3643,9 @@ class ServerHttp2Stream extends Http2Stream { // Pre-validate single-value headers in JS so a throwing additionalHeaders() leaves no partial // state in the shared HPACK table (same rule request() applies). if (this[bunHTTP2Session]?.[kStrictSingleValueFields] !== false) assertSingleValueHeaders(headers); - let hasStatus = true; - if (headers[HTTP2_HEADER_STATUS] === undefined) { - headers[HTTP2_HEADER_STATUS] = 200; - hasStatus = false; - } - const statusCode = headers[HTTP2_HEADER_STATUS]; - if (hasStatus) { + assertNoConnectionHeaders(headers); + if (headers[HTTP2_HEADER_STATUS] != null) { + const statusCode = (headers[HTTP2_HEADER_STATUS] |= 0); if (statusCode === HTTP_STATUS_SWITCHING_PROTOCOLS) throw $ERR_HTTP2_STATUS_101(); if (statusCode < 100 || statusCode >= 200) { throw $ERR_HTTP2_INVALID_INFO_STATUS(statusCode); @@ -3561,10 +3659,11 @@ class ServerHttp2Stream extends Http2Stream { ArrayPrototypePush.$call(this[kInfoHeaders], headers); } - session[bunHTTP2Native]?.request(this.id, undefined, headers, sensitiveNames); + // The last argument keeps the stream open for respond() if this block is over maxSendHeaderBlockLength. + session[bunHTTP2Native]?.request(this.id, undefined, headers, sensitiveNames, undefined, true); } respond(headers?: HeadersObject | any[] | null, options?: any) { - if (this.destroyed || this.session === undefined) { + if (this.destroyed || this.closed || this.session === undefined) { throw $ERR_HTTP2_INVALID_STREAM(); } @@ -3574,6 +3673,8 @@ class ServerHttp2Stream extends Http2Stream { if (this.sentTrailers) { throw $ERR_HTTP2_TRAILERS_ALREADY_SENT(); } + if (options !== null) assertIsObject(options, "options"); + if (this[kTimeout]) this[kTimeout].refresh(); // Raw (flat [name, value, ...] array) headers form: the pairs are encoded // on the wire in their given order; a default :status is prepended and a @@ -3612,15 +3713,7 @@ class ServerHttp2Stream extends Http2Stream { headers.push(HTTP2_HEADER_DATE, utcDate()); } rawHeadersList = headers as any[]; - const headersObject = { __proto__: null }; - for (let i = 0; i < rawHeadersList.length; i += 2) { - const key = rawHeadersList[i]; - const value = rawHeadersList[i + 1]; - const existing = headersObject[key]; - if (existing === undefined) headersObject[key] = value; - else if ($isArray(existing)) existing.push(value); - else headersObject[key] = [existing, value]; - } + const headersObject = headerListToObject(rawHeadersList); if (rawHeadersList[sensitiveHeaders] !== undefined) { headersObject[sensitiveHeaders] = rawHeadersList[sensitiveHeaders]; } @@ -3642,6 +3735,7 @@ class ServerHttp2Stream extends Http2Stream { // Pre-validate single-value headers in JS so a throwing respond() leaves no partial state in // the shared HPACK table (same rule request() applies). if (session[kStrictSingleValueFields] !== false) assertSingleValueHeaders(headers); + assertNoConnectionHeaders(headers); // node keeps the never-index list visible on sentHeaders (symbol keys are not iterated by the // wire-encoding path, so re-attaching is safe). if (sensitives !== undefined) headers[sensitiveHeaders] = sensitives; @@ -3656,12 +3750,8 @@ class ServerHttp2Stream extends Http2Stream { } statusCode = headers[HTTP2_HEADER_STATUS] |= 0; } - // RFC 9113 8.1.1 removes 101 (Switching Protocols) from HTTP/2; node uses a dedicated code. - if (statusCode === 101) { - throw $ERR_HTTP2_STATUS_101(); - } - // RFC 9110: only 1xx-5xx status codes exist; node rejects anything outside 100-599. - if (statusCode < 100 || statusCode > 599) { + // A 1xx block goes through additionalHeaders(). + if (statusCode < 200 || statusCode > 599) { throw $ERR_HTTP2_STATUS_INVALID(statusCode); } let endStream = !!options?.endStream; @@ -3670,20 +3760,13 @@ class ServerHttp2Stream extends Http2Stream { statusCode === HTTP_STATUS_NO_CONTENT || statusCode === HTTP_STATUS_RESET_CONTENT || statusCode === HTTP_STATUS_NOT_MODIFIED || - this.headRequest === true + this.headRequest === true || + // end() ran first and _final is waiting for these HEADERS to carry END_STREAM. + typeof this[bunHTTP2StreamFinal] === "function" ) { - // When endStream is true the HEADERS frame itself carries END_STREAM - // and the stream moves to HALF_CLOSED_LOCAL inside native request(). - // If waitForTrailers is ALSO true the native layer dispatches - // onWantTrailers immediately after, whose JS handler calls - // noTrailers → sendData("", true) and emits a spurious DATA frame on - // the already-half-closed stream (RFC 9113 §5.1 violation). Strip - // waitForTrailers here so the native never fires that path; the JS - // guard further down (`options?.waitForTrailers && !endStream`) only - // covers the `_final` side and runs AFTER the native call. - options = { ...options, endStream: true, waitForTrailers: false }; endStream = true; } + const waitForTrailers = !endStream && !!options?.waitForTrailers; const sendDate = options?.sendDate; if (rawHeadersList === null && (sendDate == null || sendDate)) { const current_date = headers["date"]; @@ -3693,21 +3776,17 @@ class ServerHttp2Stream extends Http2Stream { } const wireHeaders = rawHeadersList !== null ? rawHeadersList : headers; - if (typeof options === "undefined") { + const paddingStrategy = options?.paddingStrategy; + if (!endStream && !waitForTrailers && paddingStrategy === undefined) { session[bunHTTP2Native]?.request(this.id, undefined, wireHeaders, sensitiveNames); } else { - session[bunHTTP2Native]?.request(this.id, undefined, wireHeaders, sensitiveNames, options); - // Only track waitForTrailers when the HEADERS frame above did NOT end - // the stream. Status codes 204/205/304 and HEAD requests force - // endStream=true earlier in this method, which means the native - // request() already wrote END_STREAM on the HEADERS frame — driving - // the wantTrailers path from `_final` on such a stream would call - // `noTrailers`/`emit("wantTrailers")` on an already-half-closed - // stream and corrupt state. Use optional chaining: `options` may be - // `null` here (typeof null === "object" enters this else branch). - if (options?.waitForTrailers && !endStream) { - this[bunHTTP2WaitForTrailers] = true; - } + // request() shares the native writer, which also reads parent, weight, exclusive and silent. + session[bunHTTP2Native]?.request(this.id, undefined, wireHeaders, sensitiveNames, { + endStream, + waitForTrailers, + paddingStrategy, + }); + if (waitForTrailers) this[bunHTTP2WaitForTrailers] = true; } this.headersSent = true; if (onServerStreamFinishChannel.hasSubscribers) { @@ -3724,10 +3803,14 @@ class ServerHttp2Stream extends Http2Stream { function connectWithProtocol(protocol: string, options: Http2ConnectOptions, listener?: () => void) { // node accepts a string `port`; @types/node only declares a number. - if (protocol === "http:") { - return net.connect(options as import("node:net").NetConnectOpts, listener); + switch (protocol) { + case "http:": + return net.connect(options as import("node:net").NetConnectOpts, listener); + case "https:": + return tls.connect(options as import("node:tls").ConnectionOptions, listener); + default: + throw $ERR_HTTP2_UNSUPPORTED_PROTOCOL(protocol); } - return tls.connect(options as import("node:tls").ConnectionOptions, listener); } function emitConnectNT(self, socket) { @@ -3770,6 +3853,27 @@ function rejectRequestNT(session, req) { emitStreamErrorNT(session, req, constants.NGHTTP2_PROTOCOL_ERROR, true, false); } +// Like node's onStreamClose, RST_STREAM(NO_ERROR) closes the stream but leaves buffered data for its reader. +function closeStreamResetWithNoError(session: Http2Session, stream: Http2Stream) { + stream[bunHTTP2StreamStatus] |= StreamState.NativeClosed; + if (!stream.closed) { + stream.rstCode = NGHTTP2_NO_ERROR; + if (abortIfWritableOpen(stream) && !stream.destroyed) stream.end(); + markStreamClosed(stream); + } + // An 'aborted' listener or a close-channel subscriber can destroy the stream. + if (stream.destroyed) return; + pushToStream(stream, null); + if (stream instanceof ServerHttp2Stream && !stream.readableDidRead && stream.readableFlowing === null) { + stream.resume(); + } else if (stream.writableFinished) { + stream.read(0); + } else { + stream.once("finish", readNothing); + } + destroyClosedStream(session, stream); +} + function emitStreamErrorNT(self, stream, error, destroy, destroy_self) { if (stream) { if (stream.destroyed && stream.listenerCount("error") === 0) { @@ -3800,11 +3904,9 @@ function emitStreamErrorNT(self, stream, error, destroy, destroy_self) { error_instance = error; } } - if (stream.readable) { - stream.resume(); // we have a error we consume and close - pushToStream(stream, null); - } - if (destroy) { + if (destroy && error === NGHTTP2_NO_ERROR && stream.readable && !self.destroyed) { + closeStreamResetWithNoError(self, stream); + } else if (destroy) { // node marks the stream closed (and publishes the close diagnostics channel) from inside // _destroy, so the publish observes destroyed === true; don't pre-mark it here. stream.destroy(error_instance, stream.rstCode); @@ -3829,13 +3931,20 @@ const kForbiddenConnectionHeaders = new SafeSet([ "proxy-connection", "transfer-encoding", ]); +// Only a `te` value is stringified here, so a user toString() on any other field runs once, in the native walk. function assertNoConnectionHeaders(headers): void { - for (const name in headers) { - const lower = name.toLowerCase(); - if (kForbiddenConnectionHeaders.has(lower) || (lower === "te" && headers[name] !== "trailers")) { - const err = new TypeError(`HTTP/1 Connection specific headers are forbidden: "${lower}"`); - err.code = "ERR_HTTP2_INVALID_CONNECTION_HEADERS"; - throw err; + const keys = ObjectKeys(headers); + for (let i = 0; i < keys.length; i++) { + let value = headers[keys[i]]; + if (value === undefined || ($isArray(value) && value.length === 0)) continue; + const lower = keys[i].toLowerCase(); + let forbidden = kForbiddenConnectionHeaders.has(lower); + if (!forbidden && lower === "te") { + if ($isArray(value) && value.length === 1) value = value[0]; + forbidden = `${value}` !== "trailers"; + } + if (forbidden) { + throw $ERR_HTTP2_INVALID_CONNECTION_HEADERS(`HTTP/1 Connection specific headers are forbidden: "${lower}"`); } } } @@ -3935,6 +4044,20 @@ function buildSensitiveNames(headers, sensitives): Record { return map; } +// Copies an array value so a repeated name is never pushed into the caller's array. +function headerListToObject(list: any[]) { + const headers = { __proto__: null }; + for (let i = 0; i < list.length; i += 2) { + const key = list[i]; + const value = list[i + 1]; + const existing = headers[key]; + if (existing === undefined) headers[key] = $isArray(value) ? ArrayPrototypeSlice.$call(value) : value; + else if ($isArray(existing)) existing.push(value); + else headers[key] = [existing, value]; + } + return headers; +} + function toHeaderObject(headers, sensitiveHeadersValue) { const obj = { __proto__: null, [sensitiveHeaders]: sensitiveHeadersValue }; for (let n = 0; n < headers.length; n += 2) { @@ -4052,8 +4175,8 @@ class ServerHttp2Session extends Http2Session { #pendingSettingsAckCount: number = 1; #maxOutstandingSettings: number = 10; // node's Http2Settings queue: one entry per outstanding SETTINGS (initial-preface - // seed = null), invoked after 'localSettings' when that submission's ACK arrives. - #pendingSettingsCallbacks: Array<[Function, number] | null> = [null]; + // seed = null), delivered by onSettingsAck when that submission's ACK arrives. + #pendingSettingsCallbacks: Array = [null]; #remoteSettings: Settings | null = null; #pingCallbacks: Array<[Function, number]> | null = null; #strictFieldWhitespaceValidation: boolean = true; @@ -4072,12 +4195,12 @@ class ServerHttp2Session extends Http2Session { // SETTINGS_MAX_CONCURRENT_STREAMS. nghttp2 answers with RST_STREAM REFUSED_STREAM and never // surfaces the stream to the JS layer. if (stream_id % 2 === 1 && self.#peerInitiatedStreams >= self.#advertisedMaxConcurrentStreams) { - self.#parser?.rstStream(stream_id, constants.NGHTTP2_REFUSED_STREAM); - return; + // The native caller sends the RST_STREAM and counts it against maxSessionInvalidFrames, like node. + return constants.NGHTTP2_REFUSED_STREAM; } self.#connections++; if (stream_id % 2 === 1) self.#peerInitiatedStreams++; - const stream = new ServerHttp2Stream(stream_id, self, null); + const stream = new ServerHttp2Stream(stream_id, self, undefined); // Returned to the native caller, which stores it as the stream context — no // setStreamContext host call needed. return stream; @@ -4089,21 +4212,18 @@ class ServerHttp2Session extends Http2Session { if (!self || typeof stream !== "object") return; // Emit the frameError event with the frame type and error code process.nextTick(emitFrameErrorEventNT, stream, frameType, errorCode); + setImmediate(closeAfterFrameError, self, stream, errorCode); }, - aborted(self: ServerHttp2Session, stream: ServerHttp2Stream, error: any, old_state: number) { + aborted(self: ServerHttp2Session, stream: ServerHttp2Stream, error: any) { if (!self || typeof stream !== "object") return; - stream.rstCode = constants.NGHTTP2_CANCEL; - // if writable and not closed emit aborted - if (old_state != 5 && old_state != 7) { - stream[kAborted] = true; - stream.emit("aborted"); - } + abortOnPeerCancel(stream); self.#connections--; if (stream.id % 2 === 1) self.#peerInitiatedStreams--; process.nextTick(emitStreamErrorNT, self, stream, error, true, self.#connections === 0 && self.#closed); }, streamError(self: ServerHttp2Session, stream: ServerHttp2Stream, error: number) { if (!self || typeof stream !== "object") return; + stream[bunHTTP2StreamStatus] |= StreamState.Reset; self.#connections--; if (stream.id % 2 === 1) self.#peerInitiatedStreams--; process.nextTick(emitStreamErrorNT, self, stream, error, true, self.#connections === 0 && self.#closed); @@ -4117,18 +4237,11 @@ class ServerHttp2Session extends Http2Session { process.nextTick(ServerHttp2Session.#Handlers.streamEnd, self, stream, state); return; } - if (state == 6 || state == 7) { - if (stream.readable) { - endInboundHalf(stream); - - // If the user hasn't tried to consume the stream then dump the incoming data so the - // stream can finish — but at half-close only when nothing is buffered: a consumer may - // attach a tick later (e.g. a CONNECT tunnel piping once its socket connects) and - // resuming with buffered data would silently discard it. At full close, dump as before. - if ((state == 7 || stream.readableLength === 0) && stream.readableFlowing === null) { - stream.resume(); - } - } + if (state == 6) { + endReadableAtHalfClose(stream); + } else if (state == 7 && stream.readable) { + // Keep the 'end' this schedules out of the AsyncLocalStorage store of the respond(), end() or close() call that closed the stream. + runInFrame(stream[bunHTTP2AsyncContextFrame], endServerReadableAtClose, undefined, stream); } // 7 = closed, in this case we already send everything and received everything if (state === 7) { @@ -4136,23 +4249,7 @@ class ServerHttp2Session extends Http2Session { markStreamClosed(stream); self.#connections--; if (stream.id % 2 === 1) self.#peerInitiatedStreams--; - if (stream.readable && !stream.rstCode) { - // Clean close while data is still buffered on the readable side (e.g. the response - // ended before the request body was consumed): node defers the destroy until the - // consumer drains it ('end'), so the buffered request body is not lost. - stream.once("end", destroySelfOnEnd); - } else if ( - (stream.writableEnded || stream[kEndingWithChunk]) && - !stream.writableFinished && - !stream.destroyed - ) { - // Writable side is mid-finish (an in-flight _final/_write carrying END_STREAM settled - // native synchronously, re-entering before Writable.end() set kEnding): destroying now - // swallows 'finish'. Node's kMaybeDestroy waits for writable to finish first. - stream.once("finish", destroySelfOnEnd); - } else { - stream.destroy(); - } + destroyClosedStream(self, stream); if (self.#connections === 0 && self.#closed) { if (self.#pendingSettingsAckCount > 0 || (self.#pingCallbacks !== null && self.#pingCallbacks.length > 0)) { scheduleSettingsAckGraceNT(self); @@ -4169,9 +4266,6 @@ class ServerHttp2Session extends Http2Session { streamData(self: ServerHttp2Session, stream: ServerHttp2Stream, data: Buffer) { if (!self || typeof stream !== "object" || !data) return; pushToStream(stream, data); - // Request data arriving unread after the response finished: the peer is uploading - // into a receive window nothing will reopen (see maybeCloseUnreadServerStream). - maybeCloseUnreadServerStream(stream); }, streamHeaders( self: ServerHttp2Session, @@ -4179,7 +4273,13 @@ class ServerHttp2Session extends Http2Session { headersTuple: [string[], Record, string[] | undefined], flags: number, ) { - if (!self || typeof stream !== "object" || self.closed || stream.closed) return; + if (!self || typeof stream !== "object" || stream.closed) return; + if (self.closed && (stream[bunHTTP2StreamStatus] & StreamState.Delivered) === 0) { + // Refuse a request that crossed our GOAWAY, or its stream would keep the session open. + self.#parser?.rstStream(stream.id, constants.NGHTTP2_REFUSED_STREAM); + return; + } + if (self[kTimeout]) self[kTimeout].refresh(); const requestPerf = stream[kPerfState]; if (requestPerf !== undefined && requestPerf.firstHeader === 0) { requestPerf.firstHeader = performance.now() - requestPerf.start; @@ -4195,15 +4295,10 @@ class ServerHttp2Session extends Http2Session { headers = toHeaderObject(filtered, headersTuple[2] || []); } } - // Remember the request headers on the stream (pushStream derives :scheme/:authority defaults - // from them). kRequestHeaders survives respond() overwriting the bunHTTP2Headers slot. Only - // the first HEADERS block counts - this handler also fires for trailers. + // Only the first HEADERS block is the request. This handler also fires for trailers. if (stream[kRequestHeaders] === undefined) { stream[kRequestHeaders] = headers; } - if (stream[bunHTTP2Headers] == null) { - stream[bunHTTP2Headers] = headers; - } if (headers[HTTP2_HEADER_METHOD] === HTTP2_METHOD_HEAD) { stream[kHeadRequest] = true; } @@ -4221,6 +4316,7 @@ class ServerHttp2Session extends Http2Session { // user handler — in particular, losing WantTrailer/FinalCalled breaks // any later `sendTrailers()` with ERR_HTTP2_TRAILERS_NOT_READY. stream[bunHTTP2StreamStatus] |= StreamState.StreamResponded | StreamState.Delivered; + if (endOfStream) stream[bunHTTP2StreamStatus] |= StreamState.EndAfterHeaders; if (onServerStreamCreatedChannel.hasSubscribers) { onServerStreamCreatedChannel.publish({ stream, headers }); } @@ -4254,20 +4350,18 @@ class ServerHttp2Session extends Http2Session { captureHttp2PerfFrameSnapshot(self, self[bunHTTP2Native]); setImmediate(destroyIfNotDestroyedNT, self); } - const queued = self.#pendingSettingsCallbacks.shift(); - // Node's settingsCallback (lib/internal/http2/core.js) invokes the settings() - // callback first and emits 'localSettings' after it. - if (queued != null) queued[0](null, settings, Date.now() - queued[1]); - self.emit("localSettings", settings); + onSettingsAck(self, settings, self.#pendingSettingsCallbacks.shift()); }, remoteSettings(self: ServerHttp2Session, settings: Settings) { if (!self) return; + if (self[kTimeout]) self[kTimeout].refresh(); self.#remoteSettings = settings; self.emit("remoteSettings", settings); }, ping(self: ServerHttp2Session, payload: Buffer, isACK: boolean) { if (!self) return; if (!isACK) { + if (self[kTimeout]) self[kTimeout].refresh(); // node emits 'ping' only for pings initiated by the peer, not for ACKs of our own. self.emit("ping", payload); } @@ -4352,7 +4446,6 @@ class ServerHttp2Session extends Http2Session { // Windows agents the frame deterministically arrived first). self.destroy(); } else { - self.#parser?.emitErrorToAllStreams(errorCode); // Like Node, destroy with an error but send our own goaway with // NGHTTP2_NO_ERROR since this side had no error. self.destroy(sessionErrorFromCode(errorCode), constants.NGHTTP2_NO_ERROR); @@ -4379,15 +4472,20 @@ class ServerHttp2Session extends Http2Session { this.#parser?.read(data); } #onClose() { - // The teardown below completes the writes in flight, and each stream then sends what it has buffered. this[kTransportClosed] = true; const parser = this.#parser; + // Read before the sweep and close() below, which make a lost transport look like a graceful close. + const closedAndIdle = this.#closed && this.#connections === 0; if (parser) { - parser.emitAbortToAllStreams(); + // Frames corked before the transport closed count as sent, so a response that already ended closes cleanly. + parser.flush(); + // detach() completes the queued writes, so destroy the streams first or they emit 'drain'. + parser.forEachStream(streamCancel); parser.forEachStream(streamSocketClosed); parser.detach(); this.#parser = null; } + settleUnreadClosedStreams(this, null, closedAndIdle, true); // Like Node's socketOnClose, a dead socket always tears the session down // (close() followed by closeSession() upstream). close() alone is not // enough: it early-returns once a received GOAWAY has already marked the @@ -4424,10 +4522,6 @@ class ServerHttp2Session extends Http2Session { this.destroy(error); } #onTimeout() { - const parser = this.#parser; - if (parser) { - parser.forEachStream(emitTimeout); - } this.emit("timeout"); } #onDrain() { @@ -4540,9 +4634,7 @@ class ServerHttp2Session extends Http2Session { this.#advertisedMaxConcurrentStreams = advertisedMaxConcurrentStreams; } - if (options?.settings !== undefined) { - validateSettings(options.settings); - } + validateSettings(options?.settings); const nativeSettings = serverNativeSettings(options); this.#localSettings = initialLocalSettings(nativeSettings); this.#parser = new H2FrameParser({ @@ -4561,6 +4653,7 @@ class ServerHttp2Session extends Http2Session { socket.on("close", this.#onClose.bind(this)); socket.on("error", this.#onError.bind(this)); socket.on("timeout", this.#onTimeout.bind(this)); + closeDuplexTransportOnEnd(socket); initHttp2SessionPerf(this, "server"); socket.on("data", this.#onRead.bind(this)); socket.on("drain", this.#onDrain.bind(this)); @@ -4631,7 +4724,7 @@ class ServerHttp2Session extends Http2Session { return this.#socket_proxy; } get state() { - return this.#parser?.getCurrentState(); + return this.#parser?.getCurrentState() ?? {}; } get [bunHTTP2Native]() { @@ -4650,7 +4743,7 @@ class ServerHttp2Session extends Http2Session { setTimeout(msecs, callback) { // node's setStreamTimeout: the session owns its own unref'd idle timer under kTimeout // (refreshed on inbound frames and writes), it does not arm the socket's timeout. - return setSessionTimeout.$call(this, msecs, callback); + return setIdleTimeout.$call(this, msecs, callback, this, sessionTimerExpired); } ping(payload, callback) { @@ -4702,15 +4795,13 @@ class ServerHttp2Session extends Http2Session { settings(settings: Settings, callback?) { if (this.destroyed) throw $ERR_HTTP2_INVALID_SESSION(); - if (callback !== undefined && typeof callback !== "function") { - throw $ERR_INVALID_ARG_TYPE("callback", "function", callback); - } // node treats an omitted/undefined settings object as an empty update. if (settings === undefined) settings = {} as Settings; // Validate the caller-supplied object FIRST so null / arrays / primitives // still throw ERR_INVALID_ARG_TYPE — spreading ({ ...null }) would hide // these from the type guard in validateSettings. validateSettings(settings); + if (callback) validateFunction(callback, "callback"); // RFC 9113 §6.5.2: a server MUST NOT advertise SETTINGS_ENABLE_PUSH != 0. // Force-override whatever the caller passes so a mid-connection SETTINGS // frame stays compliant (the initial SETTINGS frame already clamps this @@ -4728,16 +4819,7 @@ class ServerHttp2Session extends Http2Session { return; } this.#pendingSettingsAck = true; - this.#parser?.settings(settings); - // The frame is queued on the native session; flush it now (as close() does for its - // GOAWAY) instead of waiting for the next unrelated write. Node schedules a session - // write for every settings() call, so its SETTINGS goes out with the current batch - - // a late frame here means the ACK trails the peer's response and a session closed at - // that response's end never resolves the settings callback. - this.#parser?.flush?.(); - // Queue the callback so this call's own ACK invokes it (after 'localSettings'); - // the null seed absorbs the initial-preface ACK that has no user callback. - this.#pendingSettingsCallbacks.push(typeof callback === "function" ? [callback, Date.now()] : null); + submitSettings(this, this.#parser, this.#pendingSettingsCallbacks, settings, callback); } // Gracefully closes the Http2Session, allowing any existing streams to complete on their own and preventing new Http2Stream instances from being created. Once closed, http2session.destroy() might be called if there are no open Http2Stream instances. @@ -4769,14 +4851,7 @@ class ServerHttp2Session extends Http2Session { } destroy(error: Error | number | undefined = NGHTTP2_NO_ERROR, code?: number) { - // Node's destroy() is idempotent - "if (this.destroyed) return;" is its - // first line - so a second destroy (e.g. the received-GOAWAY handler - // destroying with a session error after a socket error's destroy already - // ran re-entrantly out of the 'goaway' emit) never re-runs the teardown or - // re-emits 'error' on a session whose one-shot listeners are consumed. - // Guard on a latch, not the destroyed getter: that getter reads "socket - // detached", which #onError sets before calling in here, and the - // error-carrying destroy must still run once. + // Idempotent like Node's. Uses a latch because `destroyed` only flips once the socket detaches below. // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/http2/core.js (Http2Session#destroy) if (this.#destroying) { return; @@ -4808,33 +4883,11 @@ class ServerHttp2Session extends Http2Session { const socket = this[bunHTTP2Socket]; if (!this.#connected) return; + const closedAndIdle = this.#closed && this.#connections === 0; this.#closed = true; - this.#connected = false; - if (socket) { - if (!this[kGoawaySent] || code) { - // close() already announced a graceful shutdown - re-sending NO_ERROR would be redundant - // and double-fires the peer's 'goaway' event. An error code is new information, though: - // a destroy(err) after close() must still put the error GOAWAY on the wire. - this.goaway(code || constants.NGHTTP2_NO_ERROR, 0, Buffer.alloc(0)); - } - if (error) { - // node's finishSessionClose destroys the socket when the session dies - // with an error (a misbehaving peer must observe the connection going - // away) - but it still ends first and destroys a tick later, so the - // final GOAWAY flushes behind a FIN instead of an abortive close (see - // endThenDestroySessionSocket). - endThenDestroySessionSocket(socket, error); - } else { - // Node's finishSessionClose: "If we're gracefully closing the socket, - // call resume() so we can detect the peer closing in case - // binding.Http2Session is already gone." Without a reader, unread - // inbound bytes (a late GOAWAY from the peer) turn the close into an - // RST, which the peer surfaces as read ECONNRESET (routine on Windows - // loopback - the same reason Node delays the error-path destroy). - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/http2/core.js#L1188 - socket.resume(); - socket.end(); - } + if (socket && (!this[kGoawaySent] || code)) { + // close() already sent GOAWAY(NO_ERROR), so only an error code is worth a second frame. + this.goaway(code || constants.NGHTTP2_NO_ERROR, 0, Buffer.alloc(0)); } const parser = this.#parser; if (parser) { @@ -4848,8 +4901,21 @@ class ServerHttp2Session extends Http2Session { FunctionPrototypeBind.$call(destroyStreamForSessionDestroy, undefined, error, streamRstCode), ); parser.emitErrorToAllStreams(streamRstCode); - parser.detach(); + // A Duplex transport can deliver the peer's answer from inside the flush below. Drop it. this.#parser = null; + // detach() flushes the corked GOAWAY, and #Handlers.write drops it once the socket has ended. + parser.detach(!error && this.#closeCalled); + } + settleUnreadClosedStreams(this, error, closedAndIdle, !socket || socket.destroyed); + this.#connected = false; + if (socket) { + if (error || !this.#closeCalled) { + endThenDestroySessionSocket(socket, error); + } else { + // Unread inbound bytes (a late GOAWAY from the peer) would turn the close into an RST. + socket.resume(); + socket.end(); + } } } catch (e) { // A throwing destroy did not destroy: argument validation (goaway's @@ -4859,6 +4925,8 @@ class ServerHttp2Session extends Http2Session { throw e; } this[bunHTTP2Socket] = null; + // No ACK can arrive now, so a retained session shouldn't pin its callers' callbacks and stores. + this.#pendingSettingsCallbacks.length = 0; // Read-and-clear the frame first: emitting 'error' with no listener throws, // which would skip the clear and leave a retained session pinning the store. @@ -4872,20 +4940,21 @@ class ServerHttp2Session extends Http2Session { process.nextTick(emitSessionCloseNT, this, asyncFrame); } } -function emitTimeout(session: ClientHttp2Session) { - session.emit("timeout"); -} // Outbound-progress snapshot taken the last time the session's idle timer expired while writes // were still pending (see sessionTimerExpired / node's chunksSentSinceLastWrite). const kTimeoutBytesSnapshot = Symbol("timeoutBytesSnapshot"); const kTimeoutWrittenSnapshot = Symbol("timeoutWrittenSnapshot"); +function sessionBytesWritten(session: Http2Session | null) { + const socket = session?.[bunHTTP2Socket]; + return socket ? socketBytesWritten(socket) : 0; +} let sessionHasPendingWrite = false; function checkStreamWritePending(stream: Http2Stream) { if (stream.writableLength > 0) sessionHasPendingWrite = true; } -// node setStreamTimeout (lib/internal/stream_base_commons.js): the session owns an unref'd idle +// node setStreamTimeout (lib/internal/stream_base_commons.js): a session or stream owns an unref'd idle // timer stored under kTimeout; activity (inbound frames, writes) refreshes it. -function setSessionTimeout(this: Http2Session, msecs, callback) { +function setIdleTimeout(this: any, msecs, callback, session, onExpired) { if (this.destroyed) return this; this.timeout = msecs; msecs = getTimerDuration(msecs, "msecs"); @@ -4901,9 +4970,8 @@ function setSessionTimeout(this: Http2Session, msecs, callback) { // only refreshes if bytes actually went out during the period (see // sessionTimerExpired for why the native handle's counter, not the JS // getter's drain-driven mirror). - this[kTimeoutWrittenSnapshot] = - this[bunHTTP2Socket]?._handle?.bytesWritten ?? this[bunHTTP2Socket]?.bytesWritten ?? 0; - this[kTimeout] = setTimeout(sessionTimerExpired, msecs, this).unref(); + this[kTimeoutWrittenSnapshot] = sessionBytesWritten(session); + this[kTimeout] = setTimeout(onExpired, msecs, this).unref(); if (callback !== undefined) { validateFunction(callback, "callback"); this.once("timeout", callback); @@ -4912,7 +4980,7 @@ function setSessionTimeout(this: Http2Session, msecs, callback) { return this; } -// Node's finishSessionClose error path: socket.end() flushes and sends the FIN +// Node's finishSessionClose without close(): socket.end() flushes and sends the FIN // first, and the hard destroy runs a tick later - "If session.destroy() was // called, destroy the underlying socket. Delay it a bit to try to avoid // ECONNRESET on Windows" - so the peer reads our final frames off a FIN'd @@ -4926,6 +4994,15 @@ function destroySessionSocketDelayedNT(socket, error) { function endThenDestroySessionSocket(socket, error) { socket.end(() => setImmediate(destroySessionSocketDelayedNT, socket, error)); } +function destroyTransportSoon(this: DuplexStream) { + if (this.writable) this.end(); + if (this.writableFinished) this.destroy(); + else this.once("finish", this.destroy); +} +// Node wraps a transport that isn't a net.Socket in a JSStreamSocket, which has allowHalfOpen off. +function closeDuplexTransportOnEnd(socket) { + if (!(socket instanceof net.Socket)) socket.on("end", destroyTransportSoon); +} // node callTimeout (lib/internal/http2/core.js): when the timer expires while writes are still in // flight and bytes have reached the wire since the previous expiry, the session is not idle — // refresh instead of emitting 'timeout' (a large flow-controlled response trickling out to a slow @@ -4942,8 +5019,7 @@ function sessionTimerExpired(session: Http2Session) { // getter only mirrors it on drain events, which the native write path does // not raise - reading the mirror missed every native write on Windows. // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/http2/core.js (callTimeout) - const sessionSocket = session[bunHTTP2Socket]; - const bytesWritten = sessionSocket?._handle?.bytesWritten ?? sessionSocket?.bytesWritten ?? 0; + const bytesWritten = sessionBytesWritten(session); if (bytesWritten !== (session[kTimeoutWrittenSnapshot] ?? 0)) { session[kTimeoutWrittenSnapshot] = bytesWritten; session[kTimeout]?.refresh(); @@ -4962,13 +5038,67 @@ function sessionTimerExpired(session: Http2Session) { return; } } - parser.forEachStream(emitTimeout); } session.emit("timeout"); } +// A stream whose pending write still reaches the wire isn't idle (node's callTimeout). +function streamTimerExpired(stream: Http2Stream) { + if (stream.destroyed) return; + if (stream.writableLength > 0) { + const bytesWritten = sessionBytesWritten(stream[bunHTTP2Session]); + if (bytesWritten !== stream[kTimeoutWrittenSnapshot]) { + stream[kTimeoutWrittenSnapshot] = bytesWritten; + stream[kTimeout]?.refresh(); + return; + } + } + stream.emit("timeout"); +} function destroySelfOnEnd(this: Http2Stream) { this.destroy(); } +// Like node's kMaybeDestroy, wait for 'finish' too. destroy() would swallow it. +function destroySelfOnEndAndFinish(this: Http2Stream) { + if (this.writableEnded && !this.writableFinished) this.once("finish", destroySelfOnEnd); + else this.destroy(); +} +// An errored stream never emits 'end', so a write() after end() would leave it open forever. +function destroySelfIfErrored(this: Http2Stream) { + if (this.errored != null) process.nextTick(destroyIfNotDestroyedNT, this); +} +// The native side fully closed the stream and freed it: streamEnd(7), or RST_STREAM(NO_ERROR) from the peer. +function destroyClosedStream(session: Http2Session, stream: Http2Stream) { + if (stream.readable && !stream.rstCode && !session.destroyed) { + // Like node's onStreamClose, wait for 'end' so a reader that attaches late still gets the buffered data. + stream.once("end", destroySelfOnEndAndFinish); + stream.on(EventEmitter.errorMonitor, destroySelfIfErrored); + (session[kUnreadClosedStreams] ??= new SafeSet()).add(stream); + } else if (!stream.writableFinished && !stream.destroyed) { + // END_STREAM closed the stream from inside end(), or from respond() just before its end(). Destroying now would swallow 'finish'. + if (!stream[kEndingWithChunk]) stream.end(); + stream.once("finish", destroySelfOnEnd); + } else { + stream.destroy(); + } +} +// forEachStream() no longer finds these streams, so session teardown destroys them here or they never emit 'close'. +function settleUnreadClosedStreams( + session: Http2Session, + error: Error | null | undefined, + closedAndIdle: boolean, + transportGone: boolean, +) { + const streams = session[kUnreadClosedStreams]; + if (streams === null) return; + session[kUnreadClosedStreams] = null; + // A graceful close finishes without waiting for unread streams, so they stay readable. + if (error == null && closedAndIdle) return; + for (const stream of streams) { + // When the transport just went away, a stream with a reader keeps its buffered data. + if (error == null && transportGone && stream.readableFlowing !== null) continue; + stream.destroy(error != null && stream.listenerCount("error") > 0 ? error : undefined); + } +} function streamCancel(stream: Http2Stream) { stream.close(NGHTTP2_CANCEL); } @@ -4993,7 +5123,8 @@ function streamSocketClosed(stream: Http2Stream) { // A stream whose session was close()d before the socket finished connecting never reached the // peer; node destroys it with ERR_HTTP2_GOAWAY_SESSION (no $ERR intrinsic exists for this code). function rejectStreamAboveGoawayLastId(lastStreamId: number, stream: Http2Stream) { - if (typeof stream?.id === "number" && stream.id > lastStreamId) { + // RFC 9113 §6.8: the last-stream-id only covers streams this side opened, not pushed ones. + if (typeof stream?.id === "number" && stream.id > lastStreamId && !stream[kPush]) { streamRejectedByGoawaySession(stream); } } @@ -5021,7 +5152,7 @@ class ClientHttp2Session extends Http2Session { #socket_proxy: import("node:net").Socket | import("node:tls").TLSSocket | undefined; #parser: typeof H2FrameParser | null; - #url: URL; + #defaultScheme: string; #authority: string; #alpnProtocol: string | undefined = undefined; #localSettings: Settings | null = null; @@ -5033,37 +5164,27 @@ class ClientHttp2Session extends Http2Session { #pendingSettingsAckCount: number = 1; #maxOutstandingSettings: number = 10; // node's Http2Settings queue: one entry per outstanding SETTINGS (initial-preface - // seed = null), invoked after 'localSettings' when that submission's ACK arrives. - #pendingSettingsCallbacks: Array<[Function, number] | null> = [null]; + // seed = null), delivered by onSettingsAck when that submission's ACK arrives. + #pendingSettingsCallbacks: Array = [null]; #remoteSettings: Settings | null = null; #pingCallbacks: Array<[Function, number]> | null = null; // RFC 9113 reserved (pushed) streams the peer may have open at once (node session option). #maxReservedRemoteStreams: number = 200; #reservedStreamsCount: number = 0; + #maxOriginSetSize: number = 128; #strictFieldWhitespaceValidation: boolean = true; // Client-side SETTINGS_MAX_CONCURRENT_STREAMS accounting: requests whose HEADERS frame has been // submitted and whose stream has not closed yet, plus the queue of requests waiting for a slot // (node returns a pending stream with no id and submits it once a slot frees). #activeRequestCount: number = 0; - #pendingRequests: Array<{ - req: ClientHttp2Stream; - headers: HeadersObject; - wireHeaders: HeadersObject | any[]; - sensitiveNames: Record; - options: any; - }> | null = null; + // A Set keeps the order the requests were made in and lets a destroyed one leave in O(1). + #pendingRequests: Set | null = null; static #Handlers = { binaryType: "buffer", - streamStart(self: ClientHttp2Session, stream_id: number) { + streamStart(self: ClientHttp2Session) { if (!self) return; self.#connections++; - if (stream_id % 2 === 0) { - // A pushed (even-id) stream announced by the server: its context object must be a stream, - // not a session. Returned to the native caller, which stores it as the stream context. - const stream = new ClientHttp2Stream(stream_id, self, null); - return stream; - } }, streamPush( self: ClientHttp2Session, @@ -5072,7 +5193,8 @@ class ClientHttp2Session extends Http2Session { flags: number, ) { if (!self) return; - if (self.#reservedStreamsCount >= self.#maxReservedRemoteStreams) { + // Without a 'stream' listener nothing could read the stream or handle its 'error'. + if (self.listenerCount("stream") === 0 || self.#reservedStreamsCount >= self.#maxReservedRemoteStreams) { // Too many reserved (pushed) streams: refuse this one (node cancels it instead of // surfacing it). self.#parser?.rstStream(pushId, constants.NGHTTP2_CANCEL); @@ -5092,7 +5214,9 @@ class ClientHttp2Session extends Http2Session { headers = toHeaderObject(filtered, headersTuple[2] || []); } } - const pushedStream = new ClientHttp2Stream(pushId, self, headers); + // A client never sends on a pushed stream. Like node, its writable side has finished before 'stream', so closing it isn't an abort. + const pushedStream = new ClientHttp2Stream(pushId, self, undefined, false); + pushedStream[kRequestHeaders] = headers; pushedStream[kPush] = true; pushedStream.once("close", () => { self.#reservedStreamsCount--; @@ -5112,23 +5236,21 @@ class ClientHttp2Session extends Http2Session { if (!self || typeof stream !== "object") return; // Emit the frameError event with the frame type and error code process.nextTick(emitFrameErrorEventNT, stream, frameType, errorCode); + setImmediate(closeAfterFrameError, self, stream, errorCode); }, ), - aborted: withStreamFrame((self: ClientHttp2Session, stream: ClientHttp2Stream, error: any, old_state: number) => { + aborted: withStreamFrame((self: ClientHttp2Session, stream: ClientHttp2Stream, error: any) => { if (!self || typeof stream !== "object") return; - stream.rstCode = constants.NGHTTP2_CANCEL; - // if writable and not closed emit aborted - if (old_state != 5 && old_state != 7) { - stream[kAborted] = true; - stream.emit("aborted"); - } + abortOnPeerCancel(stream); self.#connections--; + self.#releaseRequestSlot(stream); process.nextTick(emitStreamErrorNT, self, stream, error, true, self.#connections === 0 && self.#closed); }), streamError: withStreamFrame((self: ClientHttp2Session, stream: ClientHttp2Stream, error: number) => { if (!self || typeof stream !== "object") return; - + stream[bunHTTP2StreamStatus] |= StreamState.Reset; self.#connections--; + self.#releaseRequestSlot(stream); process.nextTick(emitStreamErrorNT, self, stream, error, true, self.#connections === 0 && self.#closed); }), streamEnd: withStreamFrame((self: ClientHttp2Session, stream: ClientHttp2Stream, state: number) => { @@ -5140,13 +5262,11 @@ class ClientHttp2Session extends Http2Session { process.nextTick(ClientHttp2Session.#Handlers.streamEnd, self, stream, state); return; } - if (state == 6 || state == 7) { - if (stream.readable) { - // Push a null so the stream can end whenever the client consumes - // it completely. - endInboundHalf(stream); - stream.read(0); - } + if (state == 6) { + endReadableAtHalfClose(stream); + } else if (state == 7 && stream.readable) { + endInboundHalf(stream); + stream.read(0); } // 7 = closed, in this case we already send everything and received everything @@ -5154,23 +5274,8 @@ class ClientHttp2Session extends Http2Session { stream[bunHTTP2StreamStatus] |= StreamState.NativeClosed; markStreamClosed(stream); self.#connections--; - if (stream.readable && !stream.rstCode) { - // Clean close while data is still buffered on the readable side: node defers the - // destroy until the consumer drains it ('end'), so a late-attaching reader does not - // lose data. - stream.once("end", destroySelfOnEnd); - } else if ( - (stream.writableEnded || stream[kEndingWithChunk]) && - !stream.writableFinished && - !stream.destroyed - ) { - // Writable side is mid-finish (an in-flight _final/_write carrying END_STREAM settled - // native synchronously, re-entering before Writable.end() set kEnding): destroying now - // swallows 'finish'. Node's kMaybeDestroy waits for writable to finish first. - stream.once("finish", destroySelfOnEnd); - } else { - stream.destroy(); - } + self.#releaseRequestSlot(stream); + destroyClosedStream(self, stream); if (self.#connections === 0 && self.#closed) { // Deferred like close()'s own destroy: runs inside a native dispatch batch and // not-yet-dispatched frames must still reach JS. An outstanding settings() ACK or @@ -5198,7 +5303,9 @@ class ClientHttp2Session extends Http2Session { headersTuple: [string[], Record, string[] | undefined], flags: number, ) => { - if (!self || typeof stream !== "object" || stream.rstCode) return; + // close() and destroy() send their RST_STREAM later, so the rest of this read still arrives for the stream. + if (!self || typeof stream !== "object" || stream.rstCode || stream.closed) return; + if (self[kTimeout]) self[kTimeout].refresh(); let rawheaders = headersTuple[0]; let headers = headersTuple[1]; if (self.#strictFieldWhitespaceValidation) { @@ -5275,14 +5382,11 @@ class ClientHttp2Session extends Http2Session { captureHttp2PerfFrameSnapshot(self, self[bunHTTP2Native]); setImmediate(destroyIfNotDestroyedNT, self); } - const queued = self.#pendingSettingsCallbacks.shift(); - // Node's settingsCallback (lib/internal/http2/core.js) invokes the settings() - // callback first and emits 'localSettings' after it. - if (queued != null) queued[0](null, settings, Date.now() - queued[1]); - self.emit("localSettings", settings); + onSettingsAck(self, settings, self.#pendingSettingsCallbacks.shift()); }, remoteSettings(self: ClientHttp2Session, settings: Settings) { if (!self) return; + if (self[kTimeout]) self[kTimeout].refresh(); self.#remoteSettings = settings; self.emit("remoteSettings", settings); // The peer may have raised maxConcurrentStreams: queued requests might fit now. @@ -5291,6 +5395,7 @@ class ClientHttp2Session extends Http2Session { ping(self: ClientHttp2Session, payload: Buffer, isACK: boolean) { if (!self) return; if (!isACK) { + if (self[kTimeout]) self[kTimeout].refresh(); // node emits 'ping' only for pings initiated by the peer, not for ACKs of our own. self.emit("ping", payload); } @@ -5309,15 +5414,7 @@ class ClientHttp2Session extends Http2Session { clearTimeout(self[kSettingsAckGraceTimer]); self[kSettingsAckGraceTimer] = undefined; } - if ( - self.#closed && - self.#connections === 0 && - (self.#pendingRequests === null || self.#pendingRequests.length === 0) && - !self.destroyed - ) { - captureHttp2PerfFrameSnapshot(self, self[bunHTTP2Native]); - setImmediate(destroyIfNotDestroyedNT, self); - } + self.#maybeDestroy(); } } } @@ -5365,19 +5462,15 @@ class ClientHttp2Session extends Http2Session { self.#parser?.forEachStream(rejectStreamAboveGoawayLastId.bind(null, lastStreamId)); // Requests still queued behind the concurrency limit never got a stream id; they can never // be submitted on this session, so reject them the same way. - const pendingRequests = self.#pendingRequests; - self.#pendingRequests = null; - if (pendingRequests !== null) { - for (let i = 0; i < pendingRequests.length; i++) { - streamRejectedByGoawaySession(pendingRequests[i].req); - } - } + self.#rejectPendingRequests(); // A GOAWAY carrying an error code is a session error: the session and every open stream // error with ERR_HTTP2_SESSION_ERROR; like Node, our own goaway goes out with // NGHTTP2_NO_ERROR since this side had no error. A graceful GOAWAY (NO_ERROR) begins a // shutdown: no new streams permitted, existing streams may finish naturally. if (errorCode === constants.NGHTTP2_NO_ERROR) { - self.close(); + // A closed session may have been waiting only for the requests rejected above. + if (self.#closed) self.#maybeDestroy(); + else self.close(); } else if (self.closed && self.#connections === 0) { // Mutual teardown: this side already sent its own graceful GOAWAY // (close()) and every stream is done, so the peer's error code cannot @@ -5401,20 +5494,20 @@ class ClientHttp2Session extends Http2Session { // node.js emits value, origin, streamId self.emit("altsvc", value, origin, streamId); }, - origin(self: ClientHttp2Session, origin: string | Array | undefined) { + origin(self: ClientHttp2Session, origin: string | Array) { if (!self) return; const socket = self[bunHTTP2Socket]; if (self.encrypted && socket) { const originSet = initOriginSet(self, socket); - if ($isArray(origin)) { - for (const item of origin) { - originSet.add(item); + const origins = $isArray(origin) ? origin : [origin]; + for (let i = 0; i < origins.length; i++) { + if (originSet.size >= self.#maxOriginSetSize) { + self.destroy($ERR_HTTP2_TOO_MANY_ORIGINS(self.#maxOriginSetSize)); + return; } - self.emit("origin", origin); - } else if (origin) { - originSet.add(origin); - self.emit("origin", [origin]); + originSet.add(origins[i]); } + self.emit("origin", origins); } }, write(self: ClientHttp2Session, buffer: Buffer, transportClosed: boolean) { @@ -5464,21 +5557,19 @@ class ClientHttp2Session extends Http2Session { this.#parser.setNativeSocket(nativeSocket); this[kDeferWriteCallback] = deferWriteCallbackForSocket(nativeSocket); } - process.nextTick(emitConnectNT, this, socket); this.#parser.flush(); + } + + // Requests made while connecting go out after the 'connect' listeners, which can still destroy() or close() the session. + #emitConnect(socket) { + this.emit("connect", this, socket); if (this.#closed) { // close() was called while the socket was still connecting: requests made in the meantime // never reached the peer, so node rejects them with ERR_HTTP2_GOAWAY_SESSION once the // connect completes and then lets the session finish closing. - const pendingRequests = this.#pendingRequests; - this.#pendingRequests = null; - if (pendingRequests !== null) { - for (let i = 0; i < pendingRequests.length; i++) { - streamRejectedByGoawaySession(pendingRequests[i].req); - } - } - this.#parser?.forEachStream(streamRejectedByGoawaySession); - this.destroy(); + this.#rejectPendingRequests(); + // A 'connect' listener can open a request and then close(). That request still completes. + if (this.#connections === 0) this.destroy(); return; } // Requests made while the socket was still connecting were queued; submit them now. @@ -5489,12 +5580,17 @@ class ClientHttp2Session extends Http2Session { this[kTransportClosed] = true; const parser = this.#parser; const err = this.connecting ? $ERR_SOCKET_CLOSED() : null; + // Read before streamCancel, which can close the last open stream. + const closedAndIdle = this.#closed && this.#connections === 0; if (parser) { parser.forEachStream(streamCancel); parser.forEachStream(streamSocketClosed); parser.detach(); this.#parser = null; } + settleUnreadClosedStreams(this, err, closedAndIdle, true); + // Node's socketOnClose calls close() on a session that's still attached to the socket. + if (!this.destroyed) this.#closeCalled = true; this.destroy(err, NGHTTP2_NO_ERROR); this[bunHTTP2Socket] = null; } @@ -5505,7 +5601,6 @@ class ClientHttp2Session extends Http2Session { if (this.destroyed) { return; } - this[bunHTTP2Socket] = null; if (this.#closed) { this.destroy(); return; @@ -5526,10 +5621,6 @@ class ClientHttp2Session extends Http2Session { this.destroy(error); } #onTimeout() { - const parser = this.#parser; - if (parser) { - parser.forEachStream(emitTimeout); - } this.emit("timeout"); } #onDrain() { @@ -5594,7 +5685,7 @@ class ClientHttp2Session extends Http2Session { setTimeout(msecs, callback) { // node's setStreamTimeout: the session owns its own unref'd idle timer under kTimeout // (refreshed on inbound frames and writes), it does not arm the socket's timeout. - return setSessionTimeout.$call(this, msecs, callback); + return setIdleTimeout.$call(this, msecs, callback, this, sessionTimerExpired); } ping(payload, callback) { if (this.destroyed) throw $ERR_HTTP2_INVALID_SESSION(); @@ -5646,17 +5737,15 @@ class ClientHttp2Session extends Http2Session { return this.#socket_proxy; } get state() { - return this.#parser?.getCurrentState(); + return this.#parser?.getCurrentState() ?? {}; } settings(settings: Settings, callback?) { if (this.destroyed) throw $ERR_HTTP2_INVALID_SESSION(); - if (callback !== undefined && typeof callback !== "function") { - throw $ERR_INVALID_ARG_TYPE("callback", "function", callback); - } // node treats an omitted/undefined settings object as an empty update. if (settings === undefined) settings = {} as Settings; validateSettings(settings); + if (callback) validateFunction(callback, "callback"); // node: when more SETTINGS are submitted than maxOutstandingSettings allows un-ACKed, the // session is destroyed with ERR_HTTP2_MAX_PENDING_SETTINGS_ACK (surfaced via 'error'). this.#pendingSettingsAckCount++; @@ -5665,19 +5754,10 @@ class ClientHttp2Session extends Http2Session { return; } this.#pendingSettingsAck = true; - this.#parser?.settings(settings); - // The frame is queued on the native session; flush it now (as close() does for its - // GOAWAY) instead of waiting for the next unrelated write. Node schedules a session - // write for every settings() call, so its SETTINGS goes out with the current batch - - // a late frame here means the ACK trails the peer's response and a session closed at - // that response's end never resolves the settings callback. - this.#parser?.flush?.(); - // Queue the callback so this call's own ACK invokes it (after 'localSettings'); - // the null seed absorbs the initial-preface ACK that has no user callback. - this.#pendingSettingsCallbacks.push(typeof callback === "function" ? [callback, Date.now()] : null); + submitSettings(this, this.#parser, this.#pendingSettingsCallbacks, settings, callback); } - constructor(url: string | URL, options?: Http2ConnectOptions, listener?: Function) { + constructor(url: string | URL, options?: Http2ConnectOptions, listener?: (...args: any[]) => void) { super(); if (typeof options === "function") { @@ -5688,6 +5768,11 @@ class ClientHttp2Session extends Http2Session { assertIsObject(options, "options"); options = { ...options }; + if (options.maxOriginSetSize != null) { + validateNumber(options.maxOriginSetSize, "options.maxOriginSetSize", 0); + this.#maxOriginSetSize = options.maxOriginSetSize; + } + assertIsArray(options.remoteCustomSettings, "options.remoteCustomSettings"); if (options.remoteCustomSettings) { options.remoteCustomSettings = [...options.remoteCustomSettings]; @@ -5711,16 +5796,9 @@ class ClientHttp2Session extends Http2Session { this.#strictFieldWhitespaceValidation = false; } this[kStrictSingleValueFields] = options.strictSingleValueFields; - this.#url = url; const protocol = url.protocol || options?.protocol || "https:"; - switch (protocol) { - case "http:": - case "https:": - break; - default: - throw $ERR_HTTP2_UNSUPPORTED_PROTOCOL(protocol); - } + this.#defaultScheme = StringPrototypeSlice.$call(protocol, 0, -1); const port = url.port ? parseInt(url.port, 10) : protocol === "http:" ? 80 : 443; let host = "localhost"; @@ -5745,7 +5823,9 @@ class ClientHttp2Session extends Http2Session { this.#authority = needsBrackets ? `[${authorityHost}]:${port}` : `${authorityHost}:${port}`; } + let constructorThrew = false; function onConnect() { + if (constructorThrew) return; // The parser's construction re-enters JS and can drain the tick queue, so a // connect that fires from that drain arrives before the constructor finished. if (this.#parser === undefined) { @@ -5753,11 +5833,13 @@ class ClientHttp2Session extends Http2Session { return; } try { - this.#onConnect(arguments); - listener?.$call(this, this); + this.#onConnect(); } catch (e) { this.destroy(e); } + // Outside the try, so a listener that throws doesn't destroy the session. + if (connectOnNextTick) this.#emitConnect(socket); + else process.nextTick(() => this.#emitConnect(socket)); } // h2 with ALPNProtocols @@ -5801,22 +5883,29 @@ class ClientHttp2Session extends Http2Session { const nativeSocket = socket._handle; this[kDeferWriteCallback] = deferWriteCallbackForSocket(nativeSocket); - if (options?.settings !== undefined) { - validateSettings(options.settings); + try { + validateSettings(options?.settings); + const nativeSettings = { ...options, ...options?.settings }; + this.#localSettings = initialLocalSettings(nativeSettings); + // #onConnect attaches the native socket; frames written before that (the preface) queue. + this.#parser = new H2FrameParser({ + context: this, + settings: nativeSettings, + handlers: ClientHttp2Session.#Handlers, + }); + } catch (e) { + // The caller never gets this session, so close the socket it started connecting. + constructorThrew = true; + this[bunHTTP2Socket] = null; + socket.destroy(); + throw e; } - const nativeSettings = { ...options, ...options?.settings }; - this.#localSettings = initialLocalSettings(nativeSettings); - // #onConnect attaches the native socket; frames written before that (the preface) queue. - this.#parser = new H2FrameParser({ - context: this, - settings: nativeSettings, - handlers: ClientHttp2Session.#Handlers, - }); socket.on("data", this.#onRead.bind(this)); socket.on("drain", this.#onDrain.bind(this)); socket.on("close", this.#onClose.bind(this)); socket.on("error", this.#onError.bind(this)); socket.on("timeout", this.#onTimeout.bind(this)); + closeDuplexTransportOnEnd(socket); initHttp2SessionPerf(this, "client"); if (connectOnNextTick) { // Queued only now that the session is fully built: the parser's construction @@ -5824,6 +5913,7 @@ class ClientHttp2Session extends Http2Session { // would then run against a session whose #parser is not assigned yet. process.nextTick(onConnect.bind(this)); } + if (typeof listener === "function") this.once("connect", listener); } // Gracefully closes the Http2Session, allowing any existing streams to complete on their own and preventing new Http2Stream instances from being created. Once closed, http2session.destroy() might be called if there are no open Http2Stream instances. @@ -5853,27 +5943,46 @@ class ClientHttp2Session extends Http2Session { // bounded: an ACK is mandatory (RFC 9113 6.5.3) and arrives one round-trip later; the // localSettings dispatch finishes the deferred destroy, and a dead socket still tears // the session down. destroy() remains immediate. - if (this.#connections === 0 && (this.#pendingRequests === null || this.#pendingRequests.length === 0)) { - // An unACKed ping gets the same bounded grace as an unACKed SETTINGS: node always - // delivers the ping callback its RTT on a healthy session, never a cancel error. - if (this.#pendingSettingsAckCount > 0 || (this.#pingCallbacks !== null && this.#pingCallbacks.length > 0)) { - scheduleSettingsAckGraceNT(this); - } else { - captureHttp2PerfFrameSnapshot(this, this.#parser); - setImmediate(destroyIfNotDestroyedNT, this); - } + this.#maybeDestroy(); + } + + // Finishes a graceful close() once no stream is open and no request is queued. + #maybeDestroy() { + if (!this.#closed || this.#destroying || this.destroyed) return; + if (this.#connections !== 0 || (this.#pendingRequests !== null && this.#pendingRequests.size > 0)) return; + // An unACKed ping gets the same bounded grace as an unACKed SETTINGS, so its callback gets an RTT, not a cancel error. + if (this.#pendingSettingsAckCount > 0 || (this.#pingCallbacks !== null && this.#pingCallbacks.length > 0)) { + scheduleSettingsAckGraceNT(this); + } else { + captureHttp2PerfFrameSnapshot(this, this.#parser); + setImmediate(destroyIfNotDestroyedNT, this); + } + } + + [kRemoveQueuedRequest](req: ClientHttp2Stream) { + req[kQueuedRequest] = undefined; + if (this.#pendingRequests?.delete(req)) this.#maybeDestroy(); + } + + // Like nghttp2, a reset drops a request that waits for a stream slot. Returns false for one made before the connect, which node still sends. + [kCancelQueuedRequest](req: ClientHttp2Stream) { + if (!this.#connected || req[kQueuedRequest] === undefined) return false; + scheduleDestroyIfNotDestroyed(req); + this[kRemoveQueuedRequest](req); + return true; + } + + #rejectPendingRequests() { + const pendingRequests = this.#pendingRequests; + if (pendingRequests === null) return; + this.#pendingRequests = null; + for (const req of pendingRequests) { + runInFrame(req[bunHTTP2AsyncContextFrame], streamRejectedByGoawaySession, undefined, req); } } destroy(error?: Error | number | null, code?: number) { - // Node's destroy() is idempotent - "if (this.destroyed) return;" is its - // first line - so a second destroy (e.g. the received-GOAWAY handler - // destroying with a session error after a socket error's destroy already - // ran re-entrantly out of the 'goaway' emit) never re-runs the teardown or - // re-emits 'error' on a session whose one-shot listeners are consumed. - // Guard on a latch, not the destroyed getter: that getter reads "socket - // detached", which #onError sets before calling in here, and the - // error-carrying destroy must still run once. + // Idempotent like Node's. Uses a latch because `destroyed` only flips once the socket detaches below. // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/http2/core.js (Http2Session#destroy) if (this.#destroying) { return; @@ -5911,44 +6020,29 @@ class ClientHttp2Session extends Http2Session { // Streams torn down by this destroy surface the same session error (node semantics). this[kSessionDestroyError] = error; } + const closedAndIdle = this.#closed && this.#connections === 0; this.#closed = true; - this.#connected = false; { // Requests still queued (waiting for connect or for a concurrency slot) never reached the // wire; node cancels them with ERR_HTTP2_STREAM_CANCEL (carrying the session error, if any, // as its cause). const pendingRequests = this.#pendingRequests; this.#pendingRequests = null; - if (pendingRequests !== null && pendingRequests.length > 0) { + if (pendingRequests !== null && pendingRequests.size > 0) { const cancelError = createPendingStreamCancelError(error); - for (let i = 0; i < pendingRequests.length; i++) { - const req = pendingRequests[i].req; + const sessionCode = this[kGoawayCode] || code; + for (const req of pendingRequests) { if (!req.destroyed) { - req.rstCode = code !== undefined ? code : constants.NGHTTP2_CANCEL; + // Without a code, Http2Stream#_destroy picks NGHTTP2_INTERNAL_ERROR for the cancel error, like Node. + if (sessionCode && !req.rstCode) req.rstCode = sessionCode; req.destroy(cancelError); } } } } - if (socket) { - if (!this[kGoawaySent] || code) { - // close() already announced a graceful shutdown - re-sending NO_ERROR would be redundant - // and double-fires the peer's 'goaway' event. An error code is new information, though: - // a destroy(err) after close() must still put the error GOAWAY on the wire. - this.goaway(code || constants.NGHTTP2_NO_ERROR, 0, Buffer.alloc(0)); - } - if (error) { - // See the client session: end first, destroy a tick later (node's - // finishSessionClose Windows-ECONNRESET avoidance). - endThenDestroySessionSocket(socket, error); - } else { - // See the client session's destroy: Node's finishSessionClose resumes - // the socket on a graceful close so unread inbound bytes cannot turn - // the FIN teardown into an RST. - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/http2/core.js#L1188 - socket.resume(); - socket.end(); - } + if (socket && (!this[kGoawaySent] || code)) { + // close() already sent GOAWAY(NO_ERROR), so only an error code is worth a second frame. + this.goaway(code || constants.NGHTTP2_NO_ERROR, 0, Buffer.alloc(0)); } const parser = this.#parser; if (parser) { @@ -5960,13 +6054,33 @@ class ClientHttp2Session extends Http2Session { } // Like Node's Http2Stream._destroy: a received GOAWAY's code takes // precedence over the destroy code when streams are torn down. + const streamRstCode = this[kGoawayCode] || (code !== undefined ? code : constants.NGHTTP2_CANCEL); + // The native sweep completes queued writes, so destroy the streams first or they emit 'drain'. + if (typeof streamRstCode === "number") { + parser.forEachStream( + FunctionPrototypeBind.$call(cancelStreamForSessionDestroy, undefined, this, streamRstCode), + ); + } this[bunHTTP2SessionTeardownFrame] = $getInternalField($asyncContext, 0); try { - parser.emitErrorToAllStreams(this[kGoawayCode] || (code !== undefined ? code : constants.NGHTTP2_CANCEL)); + parser.emitErrorToAllStreams(streamRstCode); } finally { this[bunHTTP2SessionTeardownFrame] = kNoSessionTeardown; } - parser.detach(); + this.#parser = null; + // Flushes the corked GOAWAY, so the socket ends afterwards (see the server session's destroy). + parser.detach(!error && this.#closeCalled); + } + settleUnreadClosedStreams(this, error, closedAndIdle, !socket || socket.destroyed); + this.#connected = false; + if (socket) { + if (error || !this.#closeCalled) { + endThenDestroySessionSocket(socket, error); + } else { + // Unread inbound bytes (a late GOAWAY from the peer) would turn the close into an RST. + socket.resume(); + socket.end(); + } } } catch (e) { // A throwing destroy did not destroy: argument validation (goaway's @@ -5977,6 +6091,8 @@ class ClientHttp2Session extends Http2Session { } this.#parser = null; this[bunHTTP2Socket] = null; + // No ACK can arrive now, so a retained session shouldn't pin its callers' callbacks and stores. + this.#pendingSettingsCallbacks.length = 0; // Read-and-clear the frame first: emitting 'error' with no listener throws, // which would skip the clear and leave a retained session pinning the store. @@ -5994,13 +6110,14 @@ class ClientHttp2Session extends Http2Session { // Set once a stream id was allocated (streamStart incremented #connections); validation // throws before that point must not decrement. let connectionsCounted = false; + let slotHolder: ClientHttp2Stream | undefined; try { // node validates arguments synchronously and only defers session-state failures // (destroyed/closed/GOAWAY) to the returned stream, so bad options throw even on // a destroyed session (lib/internal/http2/core.js request()). if (options !== undefined) { if (options.endStream !== undefined) validateBoolean(options.endStream, "options.endStream"); - if (options.parent !== undefined) validateNumber(options.parent, "options.parent"); + if (options.parent !== undefined) validateNumber(options.parent, "options.parent", 0); if (options.exclusive !== undefined) validateBoolean(options.exclusive, "options.exclusive"); if (options.silent !== undefined) validateBoolean(options.silent, "options.silent"); } @@ -6064,8 +6181,7 @@ class ClientHttp2Session extends Http2Session { additionalPseudoHeaders.push(HTTP2_HEADER_AUTHORITY, authority); } if (scheme === undefined) { - const urlProtocol: string = this.#url?.protocol || options?.protocol || "https:"; - scheme = urlProtocol === "http:" ? "http" : urlProtocol === "https:" ? "https" : urlProtocol; + scheme = this.#defaultScheme; additionalPseudoHeaders.push(HTTP2_HEADER_SCHEME, scheme); } if (path === undefined) { @@ -6077,15 +6193,7 @@ class ClientHttp2Session extends Http2Session { if (path !== undefined) throw $ERR_HTTP2_CONNECT_PATH(); } rawHeadersList = additionalPseudoHeaders.length ? additionalPseudoHeaders.concat(raw) : raw; - const headersObject = { __proto__: null }; - for (let i = 0; i < rawHeadersList.length; i += 2) { - const key = rawHeadersList[i]; - const value = rawHeadersList[i + 1]; - const existing = headersObject[key]; - if (existing === undefined) headersObject[key] = value; - else if ($isArray(existing)) existing.push(value); - else headersObject[key] = [existing, value]; - } + const headersObject = headerListToObject(rawHeadersList); if (raw[sensitiveHeaders] !== undefined) { headersObject[sensitiveHeaders] = raw[sensitiveHeaders]; } @@ -6105,7 +6213,7 @@ class ClientHttp2Session extends Http2Session { if ($isObject(options) && "weight" in options) { // RFC 9113 deprecated priority signalling: node emits DEP0194 when the option is present - // and ignores it (the request always goes out with the default weight). + // and ignores it. HEADERS frames never carry priority fields. if (!priorityWeightDeprecationWarned) { priorityWeightDeprecationWarned = true; process.emitWarning( @@ -6114,7 +6222,6 @@ class ClientHttp2Session extends Http2Session { "DEP0194", ); } - delete options.weight; } const sensitives = headers[sensitiveHeaders]; @@ -6133,7 +6240,7 @@ class ClientHttp2Session extends Http2Session { const headerNames = ObjectKeys(headers); for (let i = 0; i < headerNames.length; i++) { const name = headerNames[i]; - if (name === "") continue; + if (name === "" || headers[name] === undefined) continue; if (name.charCodeAt(0) === 0x3a /* ':' */) { // Unknown pseudo-header names throw synchronously (node's mapToHeaders); known ones // are still re-checked by the native encoder at submission time. @@ -6148,10 +6255,10 @@ class ClientHttp2Session extends Http2Session { // Validate single-value constraints before anything is encoded (a mid-encode throw would // desync the shared HPACK table from the peer). if (this[kStrictSingleValueFields] !== false) assertSingleValueHeaders(headers); + assertNoConnectionHeaders(headers); // node keeps the never-index list visible on the request's sentHeaders (symbol keys are // not iterated by the wire-encoding path, so re-attaching is safe). if (sensitives !== undefined) headers[sensitiveHeaders] = sensitives; - const url = this.#url; // RFC 9113 §8.5: CONNECT must carry an explicit :authority and no :scheme/:path — validated // before any defaults are applied. @@ -6184,20 +6291,8 @@ class ClientHttp2Session extends Http2Session { } if (method !== HTTP2_METHOD_CONNECT || headers[":protocol"] !== undefined) { - let scheme = headers[":scheme"]; - if (!scheme) { - let protocol: string = url.protocol || options?.protocol || "https:"; - switch (protocol) { - case "https:": - scheme = "https"; - break; - case "http:": - scheme = "http"; - break; - default: - scheme = protocol; - } - headers[":scheme"] = scheme; + if (!headers[":scheme"]) { + headers[":scheme"] = this.#defaultScheme; } if (headers[":path"] == undefined) { @@ -6215,14 +6310,15 @@ class ClientHttp2Session extends Http2Session { } else if (options.endStream === undefined) { options = { ...options, endStream: true }; } - // nghttp2 refuses content-length on a request whose HEADERS carry END_STREAM (no payload - // can follow): reset with PROTOCOL_ERROR after creation (an async stream error, not a - // throw). An explicit endStream:false keeps the body legal, so only the ended case rejects. + // nghttp2 resets a request whose HEADERS carry END_STREAM and a nonzero content-length (async PROTOCOL_ERROR). if (options.endStream) { for (const key of Object.keys(headers)) { if (key.toLowerCase() === "content-length") { - rejectContentLengthOnNoPayload = true; - break; + const value = headers[key]; + if (value !== undefined && String(value) !== "0") { + rejectContentLengthOnNoPayload = true; + break; + } } } } @@ -6292,27 +6388,25 @@ class ClientHttp2Session extends Http2Session { const maxConcurrentStreams = this.#remoteSettings?.maxConcurrentStreams; if ( !this.#connected || - (this.#pendingRequests !== null && this.#pendingRequests.length > 0) || + (this.#pendingRequests !== null && this.#pendingRequests.size > 0) || (typeof maxConcurrentStreams === "number" && this.#activeRequestCount >= maxConcurrentStreams) ) { const req = new ClientHttp2Stream(undefined, this, headers); req.authority = authority; req[kHeadRequest] = method === HTTP2_METHOD_HEAD; - if (onClientStreamCreatedChannel.hasSubscribers) { - onClientStreamCreatedChannel.publish({ stream: req, headers }); - } - if (this.#pendingRequests === null) { - this.#pendingRequests = []; - } // Preserve both forms: the on-wire (array) form keeps duplicate-header interleaving the // object form cannot represent; the object form is what diagnostics channels publish. - this.#pendingRequests.push({ - req, + req[kQueuedRequest] = { headers, wireHeaders: rawHeadersList !== null ? rawHeadersList : headers, sensitiveNames, options, - }); + }; + (this.#pendingRequests ??= new SafeSet()).add(req); + // Published after queueing, like node, so a subscriber that destroys the request also dequeues it. + if (onClientStreamCreatedChannel.hasSubscribers) { + onClientStreamCreatedChannel.publish({ stream: req, headers }); + } // node corks every Http2Stream until its native handle is assigned; same here so // synchronous writes after a queued request() batch through _writev once the slot frees. req.cork(); @@ -6335,98 +6429,109 @@ class ClientHttp2Session extends Http2Session { onClientStreamCreatedChannel.publish({ stream: req, headers }); } const wireHeaders = rawHeadersList !== null ? rawHeadersList : headers; + this.#takeRequestSlot(req); + slotHolder = req; if (typeof options === "undefined") { this.#parser.request(stream_id, req, wireHeaders, sensitiveNames); } else { this.#parser.request(stream_id, req, wireHeaders, sensitiveNames, options); } + slotHolder = undefined; if (onClientStreamStartChannel.hasSubscribers) { onClientStreamStartChannel.publish({ stream: req, headers }); } - this.#trackActiveRequest(req); - // node corks every Http2Stream until its native handle is assigned (always at least one tick - // after request() returns), so body chunks written synchronously after request() are buffered - // and flushed together through _writev. + // Bun's own one-tick cork. Node uncorks inside request() on a connected session. Chunks written in this tick go out together through _writev. req.cork(); process.nextTick(uncorkNT, req); setupRequestEndAndSignal(req, options, signal); process.nextTick(emitEventNT, req, "ready"); return req; } catch (e: any) { + // Nothing reached the wire, so the session is still usable. Node only throws here. + if (slotHolder !== undefined) this.#releaseRequestSlot(slotHolder); if (connectionsCounted) { this.#connections--; - process.nextTick(emitErrorNT, this, e, this.#connections === 0 && this.#closed); + if (this.#connections === 0 && this.#closed) setImmediate(destroyIfNotDestroyedNT, this); } throw e; } } - // Counts a submitted request against the peer's SETTINGS_MAX_CONCURRENT_STREAMS limit until its - // stream closes, then tries to submit queued requests. - #trackActiveRequest(req: ClientHttp2Stream) { + #takeRequestSlot(req: ClientHttp2Stream) { this.#activeRequestCount++; - req.once("close", () => { - this.#activeRequestCount--; - this.#flushPendingRequests(); - }); + req[bunHTTP2StreamStatus] |= StreamState.HoldsRequestSlot; + } + + // Runs when native reports the stream closed. 'close' can come before the RST_STREAM is written or long after END_STREAM. + #releaseRequestSlot(req: ClientHttp2Stream) { + if ((req[bunHTTP2StreamStatus] & StreamState.HoldsRequestSlot) === 0) return; + req[bunHTTP2StreamStatus] &= ~StreamState.HoldsRequestSlot; + this.#activeRequestCount--; + this.#flushPendingRequests(); } // Submits requests queued behind the peer's SETTINGS_MAX_CONCURRENT_STREAMS limit while slots // are available, in the order they were made. #flushPendingRequests() { const queue = this.#pendingRequests; - if (queue === null || queue.length === 0) return; - while (queue.length > 0) { + if (queue === null || queue.size === 0) return; + for (const req of queue) { + const queued = req[kQueuedRequest]; + // A GOAWAY or destroy() during this loop detaches the queue and destroys what's left in it. + if (queued === undefined) continue; const parser = this.#parser; if (this.destroyed || !parser) { // The session is gone: queued requests never reached the wire, cancel them. - const { req } = queue.shift()!; - if (!req.destroyed) { - req.rstCode = constants.NGHTTP2_CANCEL; - req.destroy(); - } + req.rstCode = constants.NGHTTP2_CANCEL; + req.destroy(); continue; } const maxConcurrentStreams = this.#remoteSettings?.maxConcurrentStreams; if (typeof maxConcurrentStreams === "number" && this.#activeRequestCount >= maxConcurrentStreams) { break; } - const { req, headers, wireHeaders, sensitiveNames, options } = queue.shift()!; - // A pending stream that was close()d (but not destroyed) is still submitted: node sends its - // HEADERS and follows up with the RST_STREAM once the id is known (see close()). - if (req.destroyed) continue; - const stream_id: number = parser.getNextStream(); - if (stream_id < 0) { - process.nextTick(emitOutofStreamErrorNT, req); - continue; - } - req[kSetStreamId](stream_id); - try { - if (typeof options === "undefined") { - parser.request(stream_id, req, wireHeaders, sensitiveNames); - } else { - parser.request(stream_id, req, wireHeaders, sensitiveNames, options); - } - } catch (err) { - // Native request() can reject headers the pre-queue validation does not cover (invalid - // tokens, bad value bytes); fail this queued request like the immediate path and keep - // flushing the rest. The native context was never attached, so the reset dispatch cannot - // release the connection slot - do it here, like the immediate path's catch. The HEADERS - // never reached the wire either, so the teardown must not write RST_STREAM for an id the - // peer considers idle. - this.#connections--; - req[kNeverAnnounced] = true; - if (!req.destroyed) req.destroy(err as Error); - continue; - } - if (onClientStreamStartChannel.hasSubscribers) { - onClientStreamStartChannel.publish({ stream: req, headers }); + queue.delete(req); + req[kQueuedRequest] = undefined; + // Like node, submit in the request() caller's async context, not the context of whatever freed the slot. + runInFrame(req[bunHTTP2AsyncContextFrame], this.#submitQueuedRequest, this, parser, req, queued); + } + // A closed session's last queued request can fail above without ever opening a stream. + this.#maybeDestroy(); + } + + #submitQueuedRequest( + parser: typeof H2FrameParser, + req: ClientHttp2Stream, + { headers, wireHeaders, sensitiveNames, options }: NonNullable, + ) { + const stream_id: number = parser.getNextStream(); + if (stream_id < 0) { + process.nextTick(emitOutofStreamErrorNT, req); + return; + } + req[kSetStreamId](stream_id); + this.#takeRequestSlot(req); + try { + if (typeof options === "undefined") { + parser.request(stream_id, req, wireHeaders, sensitiveNames); + } else { + parser.request(stream_id, req, wireHeaders, sensitiveNames, options); } - this.#trackActiveRequest(req); - process.nextTick(emitEventNT, req, "ready"); + } catch (err) { + // Native rejected the headers before it attached the stream or wrote HEADERS, so no dispatch frees the slot and no RST_STREAM may go out. + this.#connections--; + req[bunHTTP2StreamStatus] &= ~StreamState.HoldsRequestSlot; + this.#activeRequestCount--; + req[kNeverAnnounced] = true; + if (!req.destroyed) req.destroy(err as Error); + return; } + if (onClientStreamStartChannel.hasSubscribers) { + onClientStreamStartChannel.publish({ stream: req, headers }); + } + process.nextTick(emitEventNT, req, "ready"); } - static connect(url: string | URL, options?: Http2ConnectOptions, listener?: Function) { + static connect(url: string | URL, options?: Http2ConnectOptions, listener?: (...args: any[]) => void) { return new ClientHttp2Session(url, options, listener); } @@ -6435,7 +6540,7 @@ class ClientHttp2Session extends Http2Session { } } -function connect(url: string | URL, options?: Http2ConnectOptions, listener?: Function) { +function connect(url: string | URL, options?: Http2ConnectOptions, listener?: (...args: any[]) => void) { return ClientHttp2Session.connect(url, options, listener); } @@ -6620,7 +6725,7 @@ class Http2Server extends (net.Server as unknown as Http2ServerBase) { return this; } updateSettings(settings) { - assertSettings(settings); + validateSettings(settings); const options = this[bunSocketServerOptions]; if (options) { options.settings = { ...options.settings, ...settings }; @@ -6638,9 +6743,7 @@ Http2Server.prototype[EventEmitter.captureRejectionSymbol] = function (err, even switch (event) { case "stream": { const { 0: stream } = args; - // node checks sentHeaders here; Bun's server streams keep the request headers in that slot - // until respond(), so headersSent is the equivalent "has the response been sent" check. - if (stream.headersSent) { + if (stream.sentHeaders) { stream.destroy(err); } else { stream.respond({ [HTTP2_HEADER_STATUS]: 500 }); @@ -6677,18 +6780,29 @@ function onErrorSecureServerSession(err, socket) { } function emitFrameErrorEventNT(stream, frameType, errorCode) { - stream.emit("frameError", frameType, errorCode); + stream.emit("frameError", frameType, errorCode, stream.id); +} +function closeAfterFrameError(session: ServerHttp2Session | ClientHttp2Session, stream: Http2Stream, code: number) { + stream.close(code); + session.close(); +} +// Starts the headersTimeout / requestTimeout sweep for an allowHTTP1 server's HTTP/1 connections. +function startHttp1ConnectionsCheck(this: Http2SecureServer) { + require("node:_http_server").setupConnectionsTracking.$call(this); } class Http2SecureServer extends (tls.Server as unknown as Http2SecureServerBase) { declare keepAliveTimeout: number | undefined; declare headersTimeout: number | undefined; declare requestTimeout: number | undefined; + declare keepAliveTimeoutBuffer: number | undefined; + declare connectionsCheckingInterval: number | undefined; declare maxHeadersCount: number | null; declare maxRequestsPerSocket: number | undefined; declare maxHeaderSize: number | undefined; declare insecureHTTPParser: boolean | undefined; declare httpValidation: string | undefined; declare joinDuplicateHeaders: boolean | undefined; + declare requireHostHeader: boolean | undefined; timeout = 0; [kSessions] = new SafeSet(); constructor(options, onRequestHandler) { @@ -6708,8 +6822,14 @@ class Http2SecureServer extends (tls.Server as unknown as Http2SecureServerBase) this.keepAliveTimeout = http1Options.keepAliveTimeout ?? 5000; this.headersTimeout = http1Options.headersTimeout ?? 60000; this.requestTimeout = http1Options.requestTimeout ?? 300000; + this.keepAliveTimeoutBuffer = http1Options.keepAliveTimeoutBuffer; + this.connectionsCheckingInterval = http1Options.connectionsCheckingInterval; + this.on("listening", startHttp1ConnectionsCheck); this.maxHeadersCount = http1Options.maxHeadersCount ?? null; this.maxRequestsPerSocket = http1Options.maxRequestsPerSocket ?? 0; + const requireHostHeader = http1Options.requireHostHeader; + if (requireHostHeader !== undefined) validateBoolean(requireHostHeader, "options.requireHostHeader"); + this.requireHostHeader = requireHostHeader ?? true; const joinDuplicateHeaders = http1Options.joinDuplicateHeaders; if (joinDuplicateHeaders !== undefined) validateBoolean(joinDuplicateHeaders, "options.joinDuplicateHeaders"); this.joinDuplicateHeaders = joinDuplicateHeaders; @@ -6735,7 +6855,7 @@ class Http2SecureServer extends (tls.Server as unknown as Http2SecureServerBase) return this; } updateSettings(settings) { - assertSettings(settings); + validateSettings(settings); const options = this[bunSocketServerOptions]; if (options) { options.settings = { ...options.settings, ...settings }; @@ -6745,7 +6865,10 @@ class Http2SecureServer extends (tls.Server as unknown as Http2SecureServerBase) // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/http2/core.js#L3475-L3487 close(callback?: (err?: Error) => void) { super.close(callback); - if (this[bunSocketServerOptions]?.allowHTTP1 === true) this.closeIdleConnections(); + if (this[bunSocketServerOptions]?.allowHTTP1 === true) { + this.closeIdleConnections(); + clearInterval(this[http1ServerPipeline.kConnectionsCheckingInterval]); + } closeAllSessions(this); } closeIdleConnections() { diff --git a/src/js/node/https.ts b/src/js/node/https.ts index 91df9effe069..5de70410554f 100644 --- a/src/js/node/https.ts +++ b/src/js/node/https.ts @@ -3,7 +3,7 @@ // https://github.com/nodejs/node/blob/v26.3.0/lib/https.js const http = require("node:http"); const { isIP } = require("internal/net/isIP"); -const { urlToHttpOptions } = require("internal/url"); +const { isURL, urlToHttpOptions } = require("internal/url"); const { kEmptyObject, once } = require("internal/shared"); const { validateObject } = require("internal/validators"); const { @@ -11,6 +11,7 @@ const { checkShouldUseProxy, kWaitForProxyTunnel, kPerRequestCheckServerIdentity, + getMaxHTTPHeaderSize, isTlsSymbol, } = require("internal/http"); const { validateHeaderValue } = require("node:_http_common"); @@ -39,7 +40,7 @@ function request(...args: [input?: unknown, options?: unknown, cb?: unknown]) { if (typeof args[0] === "string") { const urlStr = ArrayPrototypeShift.$call(args); options = urlToHttpOptions(new URL(urlStr)); - } else if (args[0] instanceof URL) { + } else if (isURL(args[0])) { options = urlToHttpOptions(ArrayPrototypeShift.$call(args)); } @@ -106,6 +107,7 @@ function getTunnelConfigForProxiedHttps(agent, reqOptions) { const result = { __proto__: null, proxyTunnelPayload: payload, + maxHeaderSize: reqOptions.maxHeaderSize || getMaxHTTPHeaderSize(), requestOptions: { // Options used for the request sent after the tunnel is established. __proto__: null, @@ -121,7 +123,7 @@ function getTunnelConfigForProxiedHttps(agent, reqOptions) { } function establishTunnel(agent, socket, options, tunnelConfig, afterSocket) { - const { proxyTunnelPayload } = tunnelConfig; + const { proxyTunnelPayload, maxHeaderSize } = tunnelConfig; // Once the tunnel outcome is decided the raw socket belongs to the TLS // wrap; stop reading from it and never re-register the readable listener. let tunnelDone = false; @@ -158,14 +160,20 @@ function establishTunnel(agent, socket, options, tunnelConfig, afterSocket) { // Read the headers from the chunks and check for the status code. If it fails we // clean up the socket and return an error. Otherwise we establish the tunnel. - let buffer = ""; + let buffer; function onProxyData(chunk) { - const str = chunk.toString(); - $debug("onProxyData", str); - buffer += str; + $debug("onProxyData", chunk.length); + buffer = buffer === undefined ? chunk : Buffer.concat([buffer, chunk], buffer.length + chunk.length); const headerEndIndex = buffer.indexOf("\r\n\r\n"); + const headerLength = headerEndIndex === -1 ? buffer.length : headerEndIndex + 4; + if (headerLength > maxHeaderSize) { + $debug(`Proxy response headers exceed ${maxHeaderSize} bytes, cleaning up`); + cleanup(); + afterSocket($ERR_PROXY_TUNNEL(`Proxy response headers exceeded ${maxHeaderSize} bytes`), socket); + return headerLength; + } if (headerEndIndex === -1) return headerEndIndex; - const statusLine = buffer.substring(0, buffer.indexOf("\r\n")); + const statusLine = buffer.subarray(0, buffer.indexOf("\r\n")).toString(); const statusCode = statusLine.split(" ")[1]; if (statusCode !== "200") { $debug(`onProxyData receives ${statusCode}, cleaning up`); diff --git a/src/js/node/url.ts b/src/js/node/url.ts index eae623ea73cb..1a2725c1499a 100644 --- a/src/js/node/url.ts +++ b/src/js/node/url.ts @@ -26,7 +26,7 @@ "use strict"; const { URL, URLSearchParams, URLPattern } = globalThis; -const { domainToASCII, domainToUnicode, idnaToASCII, urlToHttpOptions } = require("internal/url"); +const { domainToASCII, domainToUnicode, idnaToASCII, isURL, urlToHttpOptions } = require("internal/url"); const { validateString, validateObject } = require("internal/validators"); const ObjectSetPrototypeOf = Object.setPrototypeOf; @@ -1321,14 +1321,6 @@ function hexByteToNumber(byte: number): number { return -1; } -// Node's isURL (lib/internal/url.js): a duck-type check rather than -// `instanceof`, so cross-realm URLs and compatible foreign implementations -// are accepted; `auth`/`path` must be absent to exclude legacy `url.parse` -// objects, which carry both. -function isURL(self: any): boolean { - return Boolean(self?.href && self.protocol && self.auth === undefined && self.path === undefined); -} - function fileURLToPathBuffer(path: unknown, options?: { windows?: boolean }): Buffer { const windows = options?.windows; if (typeof path === "string") { diff --git a/src/js/private.d.ts b/src/js/private.d.ts index 55f4fdd073bc..b480e95fbaf3 100644 --- a/src/js/private.d.ts +++ b/src/js/private.d.ts @@ -88,6 +88,7 @@ declare module "bun" { namespace Serve { interface NodeHTTPServeOptions extends HostnamePortServeOptions { unix?: string; + fd?: number; onNodeHTTPRequest( bunServer: Server, url: string, diff --git a/src/js/thirdparty/node-fetch.ts b/src/js/thirdparty/node-fetch.ts index 9ac061e6ead5..744afd9e4ece 100644 --- a/src/js/thirdparty/node-fetch.ts +++ b/src/js/thirdparty/node-fetch.ts @@ -12,10 +12,11 @@ interface WebResponseMembers { json(): Promise; arrayBuffer(): Promise; } -type WebResponseConstructor = new ( - body?: ConstructorParameters[0], - init?: ConstructorParameters[1], -) => Omit & WebResponseMembers; +type WebResponseConstructor = Pick & + (new ( + body?: ConstructorParameters[0], + init?: ConstructorParameters[1], + ) => Omit & WebResponseMembers); interface WebRequestMembers { readonly url: string; } @@ -80,6 +81,25 @@ function readableFromOldStyleStream(source: import("node:stream").Stream) { return passthrough; } +// Pipes the source on the first read, so a Request that is only inspected leaves a form-data body unread. +function webStreamFromOldStyleStream(source: import("node:stream").Stream) { + let reader: ReadableStreamDefaultReader | undefined; + return new ReadableStream( + { + async pull(controller) { + reader ??= require("node:stream").Readable.toWeb(readableFromOldStyleStream(source)).getReader(); + const { done, value } = await reader!.read(); + if (done) controller.close(); + else controller.enqueue(value); + }, + cancel(reason) { + return reader?.cancel(reason); + }, + }, + { highWaterMark: 0 }, + ); +} + class Response extends WebResponse { [kBody]: any; [kHeaders]; @@ -133,12 +153,21 @@ class Response extends WebResponse { return new $Buffer(await super.arrayBuffer()); } - get type() { - if (!super.ok) { - return "error"; - } + static error() { + return Object.setPrototypeOf(super.error(), ResponsePrototype); + } + + static json(data, init) { + return Object.setPrototypeOf(super.json(data, init), ResponsePrototype); + } - return "default"; + static redirect(url, status) { + return Object.setPrototypeOf(super.redirect(url, status), ResponsePrototype); + } + + // https://github.com/node-fetch/node-fetch/blob/8b3320d2a7c07bce4afc6b2bf6c3bbddda85b01f/src/response.js#L121-L125 + get type() { + return super.type === "error" ? "error" : "default"; } } var ResponsePrototype = Response.prototype; @@ -161,6 +190,15 @@ class Request extends WebRequest { [kUrl]?: string; constructor(input, init) { + const body = init?.body; + if ( + typeof body?.pipe === "function" && + !body[Symbol.asyncIterator] && + body instanceof require("node:stream").Stream + ) { + init = { ...init, body: webStreamFromOldStyleStream(body) }; + } + // node-fetch is relaxed with the URL, for example, it allows "/" as a valid URL. // If it's not a valid URL, use a placeholder URL during construction. // See: https://github.com/oven-sh/bun/issues/4947 @@ -207,18 +245,54 @@ async function fetch( const tls = tlsFromAgent((init as any).agent, url); if (tls !== undefined) init = { ...init, tls } as any; } - const response = await nativeFetch.$call(undefined, url, init); + let response; + try { + response = await nativeFetch.$call(undefined, url, init); + } catch (error) { + throw toNodeFetchError(error); + } Object.setPrototypeOf(response, ResponsePrototype); response[kFetched] = true; return response; } +// https://github.com/node-fetch/node-fetch/blob/8b3320d2a7c07bce4afc6b2bf6c3bbddda85b01f/src/index.js#L70-L217 +function toNodeFetchError(error) { + // An abort with a reason of its own, such as AbortSignal.timeout(), keeps rejecting with that reason. + if (error instanceof DOMException) return error.name === "AbortError" ? new AbortError(error.message) : error; + // Native fetch() sets a numeric `errno` on a request that failed, and none on a bad argument. + if (!(error instanceof TypeError) || typeof (error as any).errno !== "number") return error; + const { code, path } = error as any; + const message = `request to ${path} failed, reason: ${error.message}`; + switch (code) { + case "UnexpectedRedirect": + return new FetchError( + `uri requested responds with a redirect, redirect mode is set to error: ${path}`, + "no-redirect", + ); + case "TooManyRedirects": + return new FetchError(`maximum redirect reached at: ${path}`, "max-redirect"); + case "RedirectURLInvalid": + case "InvalidRedirectURL": + case "RedirectURLTooLong": + return new FetchError(message, "invalid-redirect"); + case "UnsupportedRedirectProtocol": + return error; + } + return new FetchError(message, "system", error); +} + +// Stays a DOMException, unlike node-fetch's AbortError, so `instanceof DOMException` still matches an aborted fetch(). class AbortError extends DOMException { - constructor(message) { + type: string; + + constructor(message, type = "aborted") { super(message, "AbortError"); + this.type = type; } } +// https://github.com/node-fetch/node-fetch/blob/8b3320d2a7c07bce4afc6b2bf6c3bbddda85b01f/src/errors/base.js class FetchBaseError extends Error { type: string; @@ -226,12 +300,28 @@ class FetchBaseError extends Error { super(message); this.type = type; } + + get name() { + return this.constructor.name; + } + + get [Symbol.toStringTag]() { + return this.constructor.name; + } } +// https://github.com/node-fetch/node-fetch/blob/8b3320d2a7c07bce4afc6b2bf6c3bbddda85b01f/src/errors/fetch-error.js class FetchError extends FetchBaseError { - constructor(message, type, systemError) { + declare code?: string; + declare errno?: string; + declare erroredSysCall?: string; + + constructor(message, type, systemError?) { super(message, type); - this.code = systemError?.code; + if (systemError) { + this.code = this.errno = systemError.code; + this.erroredSysCall = systemError.syscall; + } } } diff --git a/src/js/thirdparty/undici.js b/src/js/thirdparty/undici.js index 3e2756f0c2e5..0639eecf856c 100644 --- a/src/js/thirdparty/undici.js +++ b/src/js/thirdparty/undici.js @@ -1,12 +1,14 @@ const EventEmitter = require("node:events"); const dc = require("node:diagnostics_channel"); const { isArrayBuffer } = require("node:util/types"); +const { checkIsHttpToken } = require("internal/validators"); const { _ReadableFromWeb: ReadableFromWeb } = require("internal/webstreams_adapters"); const ObjectCreate = Object.create; const kEmptyObject = ObjectCreate(null); +const noop = () => {}; -var fetch = Bun.fetch; +const nativeFetch = Bun.fetch; const bindings = $cpp("Undici.cpp", "createUndiciInternalBinding"); const Response = bindings[0]; const Request = bindings[1]; @@ -61,14 +63,17 @@ function closeEmptyBody(controller) { class BodyReadable extends ReadableFromWeb { #response; #bodyUsed; + #onFinished; - constructor(response, options = {}) { + constructor(response, onFinished) { // A response with no body (204, HEAD) still gets a body, as in undici: // https://github.com/nodejs/undici/blob/v6.21.3/lib/api/api-request.js#L118-L126 - super({ ...options, responseBody: true }, response.body ?? new ReadableStream({ start: closeEmptyBody })); + super({ responseBody: true }, response.body ?? new ReadableStream({ start: closeEmptyBody })); this.#response = response; this.#bodyUsed = response.bodyUsed; + this.#onFinished = onFinished; + this.on("close", onFinished); } get bodyUsed() { @@ -76,41 +81,200 @@ class BodyReadable extends ReadableFromWeb { return this.#bodyUsed; } - #consume() { + async #consume(method) { if (this.#bodyUsed) throw new TypeError("unusable"); this.#bodyUsed = true; + try { + return await this.#response[method](); + } finally { + this.#onFinished(); + } + } + + arrayBuffer() { + return this.#consume("arrayBuffer"); + } + + blob() { + return this.#consume("blob"); + } + + formData() { + return this.#consume("formData"); + } + + json() { + return this.#consume("json"); + } + + text() { + return this.#consume("text"); + } + + bytes() { + return this.#consume("bytes"); + } + + // https://github.com/nodejs/undici/blob/v6.21.3/lib/api/readable.js#L158-L199 + async dump(opts) { + const signal = opts?.signal; + if (signal != null && (typeof signal !== "object" || !("aborted" in signal))) { + throw new InvalidArgumentError("signal must be an AbortSignal"); + } + const limit = Number.isFinite(opts?.limit) ? opts.limit : 128 * 1024; + signal?.throwIfAborted(); + this.#bodyUsed = true; + if (this._readableState.closeEmitted) return null; + + return await new Promise((resolve, reject) => { + if (Number(this.#response.headers.get("content-length")) > limit) this.destroy(new AbortError()); + + const onAbort = () => this.destroy(signal.reason ?? new AbortError()); + signal?.addEventListener("abort", onAbort); + + let bytesRead = 0; + this.on("close", () => { + signal?.removeEventListener("abort", onAbort); + if (signal?.aborted) reject(signal.reason ?? new AbortError()); + else resolve(null); + }) + .on("error", noop) + .on("data", chunk => { + bytesRead += chunk.length; + if (bytesRead >= limit) this.destroy(); + }) + .resume(); + }); + } +} + +// https://github.com/nodejs/undici/blob/v6.21.3/lib/core/util.js#L134-L203 +function parseURL(url) { + if (typeof url === "string" || url instanceof URL) return new URL(url); + if (!url || typeof url !== "object") throw new TypeError("url must be a string, URL, or UrlObject"); + const port = url.port != null ? url.port : url.protocol === "https:" ? 443 : 80; + let origin = url.origin != null ? String(url.origin) : `${url.protocol || ""}//${url.hostname || ""}:${port}`; + let path = url.path != null ? url.path : `${url.pathname || ""}${url.search || ""}`; + if (origin[origin.length - 1] === "/") origin = origin.slice(0, -1); + if (path && path[0] !== "/") path = `/${path}`; + // new URL(path, origin) would let an absolute URL in `path` replace the origin. + return new URL(`${origin}${path}`); +} + +function parseOrigin(origin) { + if (!origin || (typeof origin !== "string" && typeof origin !== "object")) { + throw new InvalidArgumentError("Invalid URL: The URL argument must be a non-null object."); + } + const url = parseURL(origin); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new InvalidArgumentError("Invalid URL protocol: the URL must start with `http:` or `https:`."); + } + if (url.pathname !== "/" || url.search || url.hash) throw new InvalidArgumentError("invalid url"); + return url.origin; +} + +// https://github.com/nodejs/undici/blob/v6.21.3/lib/core/request.js#L159-L182 +function toHeaders(input) { + const headers = new Headers(); + if (input == null) return headers; + if (typeof input !== "object") throw new InvalidArgumentError("headers must be an object or an array"); + const append = (name, value) => { + if ($isJSArray(value)) for (let i = 0; i < value.length; i++) headers.append(name, value[i]); + else if (value !== undefined) headers.append(name, value === null ? "" : value); + }; + if ($isJSArray(input)) { + if (input.length % 2 !== 0) throw new InvalidArgumentError("headers array must be even"); + for (let i = 0; i < input.length; i += 2) append(input[i], input[i + 1]); + } else if (input[Symbol.iterator]) { + for (const { 0: name, 1: value } of input) append(name, value); + } else { + for (const name of Object.keys(input)) append(name, input[name]); } + return headers; +} - async arrayBuffer() { - this.#consume(); - return await this.#response.arrayBuffer(); +// Aborts `controller` on the signal's "abort" event, like undici. Returns the function that removes the listener. +function addAbortListener(signal, controller) { + if (!signal) return noop; + const onAbort = () => controller.abort(signal.reason ?? new RequestAbortedError()); + if (signal.aborted) { + onAbort(); + return noop; + } + if ("addEventListener" in signal) { + signal.addEventListener("abort", onAbort, { once: true }); + return () => signal.removeEventListener("abort", onAbort); } + signal.once("abort", onAbort); + return () => signal.removeListener("abort", onAbort); +} + +// Removes a body's abort listener when the body is dropped unread. The signal can outlive it. +let droppedBodies; - async blob() { - this.#consume(); - return await this.#response.blob(); +function callFunction(fn) { + fn(); +} + +// fetch() already decoded the body, so Content-Encoding and the encoded Content-Length no longer describe it. +function responseHeaders(response, method) { + const headers = response.headers.toJSON(); + if (method !== "HEAD" && headers["content-encoding"]) { + delete headers["content-encoding"]; + delete headers["content-length"]; } + return headers; +} + +function validateRequest({ method, body, maxRedirections, signal }) { + // https://github.com/nodejs/undici/blob/v6.21.3/lib/core/request.js#L59-L63 + if (!method) method = "GET"; + else if (typeof method !== "string") throw new InvalidArgumentError("method must be a string"); + else if (!checkIsHttpToken(method)) throw new InvalidArgumentError("invalid request method"); + method = method.toUpperCase(); + if (method === "CONNECT") throw new InvalidArgumentError("invalid method"); - async formData() { - this.#consume(); - return await this.#response.formData(); + if (body && (method === "GET" || method === "HEAD")) { + throw new Error("Body not allowed for GET or HEAD requests"); } - async json() { - this.#consume(); - return await this.#response.json(); + if (maxRedirections != null && (!Number.isInteger(maxRedirections) || maxRedirections < 0)) { + throw new Error("maxRedirections must be a positive number"); } - async text() { - this.#consume(); - return await this.#response.text(); + if (signal && !$isCallable(signal.on) && !$isCallable(signal.addEventListener)) { + throw new InvalidArgumentError("signal must be an EventEmitter or EventTarget"); } + return method; +} + +// Sends a request() or dispatch() request with native fetch(). +function send(dispatcher, url, method, { headers, body, maxRedirections, reset }, signal) { + const followRedirects = maxRedirections > 0; + return nativeFetch(url, { + ...dispatcher?.[kFetchOptions], + signal, + method, + headers: toHeaders(headers), + body, + redirect: followRedirects ? "follow" : "manual", + maxRedirects: followRedirects ? maxRedirections : undefined, + keepalive: !reset, + }); +} + +function callbackify(run, callback, opaque) { + if (callback === undefined) return run(); + if (!$isCallable(callback)) throw new InvalidArgumentError("invalid callback"); + run().then( + data => callback(null, data), + err => callback(err, { opaque }), + ); } // NOT IMPLEMENTED // * idempotent?: boolean; // * onInfo?: (info: { statusCode: number, headers: Object }) => void; -// * opaque?: *; // * responseHeader: 'raw' | null; // * headersTimeout?: number | null; // * bodyTimeout?: number | null; @@ -130,6 +294,7 @@ class BodyReadable extends ReadableFromWeb { * query?: Record; * reset?: boolean; * throwOnError?: boolean; + * opaque?: *; * }} [options] * @returns {{ * statusCode: number; @@ -140,100 +305,59 @@ class BodyReadable extends ReadableFromWeb { * context: Object; * }} */ -async function request( - url, - options = { - method: "GET", - signal: null, - headers: null, - query: null, - // idempotent: false, // GET and HEAD requests are idempotent by default - // blocking = false, - // upgrade = false, - // headersTimeout: 30000, - // bodyTimeout: 30000, - reset: false, - throwOnError: false, - body: null, - // dispatcher, - }, -) { - let { - method = "GET", - headers: inputHeaders, - query, - signal, - // idempotent, // GET and HEAD requests are idempotent by default - // blocking = false, - // upgrade = false, - // headersTimeout = 30000, - // bodyTimeout = 30000, - reset = false, - throwOnError = false, - body: inputBody, - maxRedirections, - // dispatcher, - } = options; - - // TODO: More validations - - if (typeof url === "string") { - if (query) url = new URL(url); - } else if (typeof url === "object" && url !== null) { - if (!(url instanceof URL)) { - // TODO: Parse undici UrlObject - throw new Error("not implemented"); +async function request(url, options = kEmptyObject) { + const { query, signal, throwOnError, opaque = kEmptyObject, dispatcher = globalDispatcher } = options; + + url = parseURL(url); + if (query) url.search = new URLSearchParams(query).toString(); + const method = validateRequest(options); + + const controller = new AbortController(); + const removeAbortListener = addAbortListener(signal, controller); + try { + const run = signal => + hasOwnDispatch(dispatcher) + ? sendWithDispatch(dispatcher, url, { ...options, method, query: undefined }, signal) + : send(dispatcher, url, method, options, signal); + /** @type {Response} */ + const resp = await (dispatcher?.[kTrack] ? dispatcher[kTrack](controller.signal, run) : run(controller.signal)); + const statusCode = resp.status; + + // Throw if received 4xx or 5xx response indicating HTTP error + if (throwOnError && statusCode >= 400 && statusCode < 600) { + throw new Error(`Request failed with status code ${statusCode}`); } - } else throw new TypeError("url must be a string, URL, or UrlObject"); - if (typeof url === "string" && query) url = new URL(url); - if (typeof url === "object" && url !== null && query) if (query) url.search = new URLSearchParams(query).toString(); - - method = method && typeof method === "string" ? method.toUpperCase() : null; - // idempotent = idempotent === undefined ? method === "GET" || method === "HEAD" : idempotent; - - if (inputBody && (method === "GET" || method === "HEAD")) { - throw new Error("Body not allowed for GET or HEAD requests"); - } - - if (maxRedirections != null && (!Number.isInteger(maxRedirections) || maxRedirections < 0)) { - throw new Error("maxRedirections must be a positive number"); + const body = new BodyReadable(resp, removeAbortListener); + if (signal) (droppedBodies ??= new FinalizationRegistry(callFunction)).register(body, removeAbortListener); + return { statusCode, headers: responseHeaders(resp, method), body, trailers: {}, opaque, context: kEmptyObject }; + } catch (err) { + removeAbortListener(); + throw err; } +} - if (signal && !(signal instanceof AbortSignal)) { - // TODO: Add support for event emitter signal - throw new Error("signal must be an instance of AbortSignal"); +// https://github.com/nodejs/undici/blob/v6.21.3/lib/api/api-stream.js +async function stream(url, options, factory) { + if ($isCallable(options)) { + factory = options; + options = undefined; } - - const followRedirects = maxRedirections != null && maxRedirections > 0; - - /** @type {Response} */ - const resp = await fetch(url, { - signal, - mode: "cors", - method, - headers: inputHeaders || kEmptyObject, - body: inputBody, - redirect: followRedirects ? "follow" : "manual", - maxRedirects: followRedirects ? maxRedirections : undefined, - keepalive: !reset, - }); - - const { status: statusCode, headers, trailers } = resp; - - // Throw if received 4xx or 5xx response indicating HTTP error - if (throwOnError && statusCode >= 400 && statusCode < 600) { - throw new Error(`Request failed with status code ${statusCode}`); + if (!$isCallable(factory)) throw new InvalidArgumentError("invalid factory"); + const { statusCode, headers, body, opaque, context } = await request(url, options); + try { + const writable = factory({ statusCode, headers, opaque, context }); + if (!writable || !$isCallable(writable.write) || !$isCallable(writable.end) || !$isCallable(writable.on)) { + throw new InvalidReturnValueError("expected Writable"); + } + await require("node:stream/promises").pipeline(body, writable); + } catch (err) { + body.destroy(); + throw err; } - - const body = new BodyReadable(resp); - - return { statusCode, headers: headers.toJSON(), body, trailers, opaque: kEmptyObject, context: kEmptyObject }; + return { opaque, trailers: {} }; } -function stream() { - notImplemented(); -} function pipeline() { notImplemented(); } @@ -256,33 +380,353 @@ class MockAgent { function mockErrors() {} -class Dispatcher extends EventEmitter {} -class Agent extends Dispatcher {} -class Pool extends Dispatcher { - request() {} +const kFetchOptions = Symbol("kFetchOptions"); +const kTrack = Symbol("kTrack"); +const kUsesFetch = Symbol("kUsesFetch"); + +class Dispatcher extends EventEmitter { + dispatch() { + throw new Error("not implemented"); + } + + close() { + throw new Error("not implemented"); + } + + destroy() { + throw new Error("not implemented"); + } } -class BalancedPool extends Dispatcher {} -class Client extends Dispatcher { - request() {} + +// Maps a dispatcher's `connect` options, which undici passes to tls.connect(), to native fetch() options. +function connectToFetchOptions(connect, socketPath = connect?.socketPath) { + const tls = connect && require("internal/tls").nodeClientTlsToNative({ __proto__: null, ...connect }); + if (!tls && !socketPath) return undefined; + return { __proto__: null, tls, unix: socketPath }; +} + +// https://github.com/nodejs/undici/blob/v7.16.0/lib/handler/unwrap-handler.js +function unwrapHandler(handler) { + if (!handler.onRequestStart) return handler; + let paused = false; + let aborted = false; + let reason = null; + let abortRequest; + let resumeBody; + const controller = { + pause() { + paused = true; + }, + resume() { + if (!paused) return; + paused = false; + resumeBody?.(); + }, + abort(err) { + if (aborted) return; + aborted = true; + reason = err; + abortRequest(err); + }, + get aborted() { + return aborted; + }, + get reason() { + return reason; + }, + get paused() { + return paused; + }, + }; + return { + onConnect(abort, context) { + abortRequest = abort; + handler.onRequestStart(controller, context); + }, + onHeaders(statusCode, rawHeaders, resume, statusText) { + resumeBody = resume; + handler.onResponseStart?.(controller, statusCode, parseRawHeaders(rawHeaders), statusText); + return !paused; + }, + onData(chunk) { + handler.onResponseData?.(controller, chunk); + return !paused; + }, + onComplete(rawTrailers) { + handler.onResponseEnd?.(controller, parseRawHeaders(rawTrailers)); + }, + onError(err) { + if (!handler.onResponseError) throw new InvalidArgumentError("invalid onError method"); + handler.onResponseError(controller, err); + }, + }; +} + +function parseRawHeaders(rawHeaders) { + const headers = {}; + for (let i = 0; i < rawHeaders.length; i += 2) { + const name = rawHeaders[i].toString().toLowerCase(); + const value = rawHeaders[i + 1].toString(); + const existing = headers[name]; + if (existing === undefined) headers[name] = value; + else if ($isJSArray(existing)) existing.push(value); + else headers[name] = [existing, value]; + } + return headers; } -class DispatcherBase extends EventEmitter {} +// Runs one native fetch() and reports it through a dispatch() handler's callbacks. +async function dispatchWithFetch(dispatcher, url, method, opts, handler, controller, signal) { + let paused = false; + let wake = noop; + const resume = () => { + paused = false; + wake(); + }; + signal.addEventListener("abort", resume, { once: true }); + + handler.onConnect?.(reason => controller.abort(reason ?? new RequestAbortedError()), null); + signal.throwIfAborted(); + + const response = await send(dispatcher, url, method, opts, signal); + + const headers = responseHeaders(response, method); + const rawHeaders = []; + for (const name of Object.keys(headers)) { + const value = headers[name]; + for (const line of $isJSArray(value) ? value : [value]) rawHeaders.push(Buffer.from(name), Buffer.from(line)); + } + paused = handler.onHeaders?.(response.status, rawHeaders, resume, response.statusText) === false; + + const whileNotPaused = async () => { + while (paused && !signal.aborted) await new Promise(resolve => (wake = resolve)); + signal.throwIfAborted(); + }; + const { body } = response; + if (body) { + for await (const chunk of body) { + await whileNotPaused(); + paused = handler.onData?.(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)) === false; + } + } + await whileNotPaused(); + handler.onComplete?.([]); +} +// https://github.com/nodejs/undici/blob/v6.21.3/lib/dispatcher/dispatcher-base.js +class DispatcherBase extends Dispatcher { + #origin; + #hasConnector; + #closed = false; + #destroyed = false; + #aborter = new AbortController(); + #pending = 0; + #onDrain = []; + + constructor(options, origin) { + super(); + this.#origin = origin; + this.#hasConnector = $isCallable(options?.connect); + this[kFetchOptions] = connectToFetchOptions(options?.connect, options?.socketPath); + } + + get closed() { + return this.#closed; + } + + get destroyed() { + return this.#destroyed; + } + + // Returns false if a subclass overrides dispatch(). fetch() then has to call it. + get [kUsesFetch]() { + return this.dispatch === DispatcherBase.prototype.dispatch; + } + + #url(opts) { + if (!opts || typeof opts !== "object") throw new InvalidArgumentError("invalid opts"); + // The connector may be what verifies the server, so don't connect without it. + if (this.#hasConnector) throw new NotSupportedError("A `connect` function is not yet implemented in Bun"); + const { origin, path } = opts; + if (this.#origin === undefined && (!origin || (typeof origin !== "string" && !(origin instanceof URL)))) { + throw new InvalidArgumentError("opts.origin must be a non-empty string or URL."); + } + if (typeof path !== "string") throw new InvalidArgumentError("path must be a string"); + if (path[0] !== "/") throw new InvalidArgumentError("path must be an absolute URL or start with a slash"); + // new URL(path, origin) would let a path like "//host/" replace the origin. + return new URL((this.#origin ?? parseOrigin(origin)) + path); + } + + #assertOpen() { + if (this.#destroyed) throw new ClientDestroyedError(); + if (this.#closed) throw new ClientClosedError(); + } + + // Runs one request with a signal that destroy() aborts too, even after `run` settled. close() waits for `run`. + async [kTrack](signal, run) { + this.#assertOpen(); + this.#pending++; + try { + return await run(AbortSignal.any([signal, this.#aborter.signal])); + } finally { + if (--this.#pending === 0) for (const callback of this.#onDrain.splice(0)) callback(); + } + } + + #whenDrained(callback) { + if (this.#pending === 0) queueMicrotask(callback); + else this.#onDrain.push(callback); + } + + close(callback) { + if (callback === undefined) { + return new Promise((resolve, reject) => this.close((err, data) => (err ? reject(err) : resolve(data)))); + } + if (!$isCallable(callback)) throw new InvalidArgumentError("invalid callback"); + if (this.#destroyed) { + queueMicrotask(() => callback(new ClientDestroyedError(), null)); + return; + } + this.#closed = true; + this.#whenDrained(() => { + this.#destroyed = true; + callback(null, null); + }); + } + + destroy(err, callback) { + if ($isCallable(err)) { + callback = err; + err = null; + } + if (callback === undefined) { + return new Promise((resolve, reject) => this.destroy(err, (err, data) => (err ? reject(err) : resolve(data)))); + } + if (!$isCallable(callback)) throw new InvalidArgumentError("invalid callback"); + this.#destroyed = true; + this.#aborter.abort(err || new ClientDestroyedError()); + this.#whenDrained(() => callback(null, null)); + } + + dispatch(opts, handler) { + if (!handler || typeof handler !== "object") throw new InvalidArgumentError("handler must be an object"); + handler = unwrapHandler(handler); + let url, method; + try { + if (!opts || typeof opts !== "object") throw new InvalidArgumentError("opts must be an object."); + this.#assertOpen(); + url = this.#url(opts); + const { query } = opts; + if (query) url.search = new URLSearchParams(query).toString(); + method = validateRequest(opts); + } catch (err) { + if (!$isCallable(handler.onError)) throw err; + handler.onError(err); + return false; + } + const controller = new AbortController(); + this[kTrack](controller.signal, async signal => { + try { + await dispatchWithFetch(this, url, method, opts, handler, controller, signal); + } catch (err) { + controller.abort(err); + handler.onError(err); + } + }); + return true; + } + + request(opts, callback) { + return callbackify(async () => request(this.#url(opts), { ...opts, dispatcher: this }), callback, opts?.opaque); + } + + stream(opts, factory, callback) { + return callbackify( + async () => stream(this.#url(opts), { ...opts, dispatcher: this }, factory), + callback, + opts?.opaque, + ); + } +} + +class Agent extends DispatcherBase { + constructor(options) { + super(options); + } +} + +class Client extends DispatcherBase { + constructor(origin, options) { + super(options, parseOrigin(origin)); + } +} + +class Pool extends DispatcherBase { + constructor(origin, options) { + super(options, parseOrigin(origin)); + } +} + +class BalancedPool extends Dispatcher {} + +// https://github.com/nodejs/undici/blob/v6.21.3/lib/dispatcher/proxy-agent.js#L28-L61 class ProxyAgent extends DispatcherBase { - constructor() { + constructor(opts) { + if (!opts || (typeof opts === "object" && !(opts instanceof URL) && !opts.uri)) { + throw new InvalidArgumentError("Proxy uri is mandatory"); + } super(); + const url = new URL(typeof opts === "string" || opts instanceof URL ? opts : opts.uri).href; + const { auth, token } = opts; + const headers = { ...opts.headers }; + if (auth && token) { + throw new InvalidArgumentError("opts.auth cannot be used in combination with opts.token"); + } else if (auth) { + headers["proxy-authorization"] = `Basic ${auth}`; + } else if (token) { + headers["proxy-authorization"] = token; + } + this[kFetchOptions] = { __proto__: null, ...connectToFetchOptions(opts.requestTls), proxy: { url, headers } }; } } +// Native fetch() reads the *_PROXY environment variables itself, on every redirect hop. class EnvHttpProxyAgent extends DispatcherBase { - constructor() { - super(); + constructor(options) { + super(options); } } class RetryAgent extends Dispatcher { - constructor() { + #agent; + + constructor(agent) { super(); + this.#agent = agent; + } + + get [kFetchOptions]() { + return this.#agent?.[kFetchOptions]; + } + + get [kUsesFetch]() { + return this.dispatch === RetryAgent.prototype.dispatch && this.#agent?.[kUsesFetch] === true; + } + + dispatch(opts, handler) { + return this.#agent.dispatch(opts, handler); + } + + request(opts, callback) { + return this.#agent.request(opts, callback); + } + + close(callback) { + return this.#agent.close(callback); + } + + destroy(err, callback) { + return this.#agent.destroy(err, callback); } } @@ -308,29 +752,119 @@ const interceptors = { dump: () => {}, }; -// Error classes -class UndiciError extends Error {} -class AbortError extends UndiciError {} -class HTTPParserError extends Error {} -class HeadersTimeoutError extends UndiciError {} -class HeadersOverflowError extends UndiciError {} -class BodyTimeoutError extends UndiciError {} -class RequestContentLengthMismatchError extends UndiciError {} -class ConnectTimeoutError extends UndiciError {} -class ResponseStatusCodeError extends UndiciError {} -class InvalidArgumentError extends UndiciError {} -class InvalidReturnValueError extends UndiciError {} -class RequestAbortedError extends AbortError {} -class ClientDestroyedError extends UndiciError {} -class ClientClosedError extends UndiciError {} -class InformationalError extends UndiciError {} -class SocketError extends UndiciError {} -class NotSupportedError extends UndiciError {} -class ResponseContentLengthMismatchError extends UndiciError {} -class BalancedPoolMissingUpstreamError extends UndiciError {} -class ResponseExceededMaxSizeError extends UndiciError {} -class RequestRetryError extends UndiciError {} -class SecureProxyConnectionError extends UndiciError {} +// https://github.com/nodejs/undici/blob/v6.21.3/lib/core/errors.js +class UndiciError extends Error { + constructor(message, options) { + super(message, options); + this.name = "UndiciError"; + this.code = "UND_ERR"; + } +} + +function defineError(className, code, defaultMessage, name = className, Base = UndiciError) { + const ErrorClass = class extends Base { + constructor(message) { + super(message || defaultMessage); + this.name = name; + this.code = code; + } + }; + Object.defineProperty(ErrorClass, "name", { value: className }); + return ErrorClass; +} + +const AbortError = defineError("AbortError", "UND_ERR_ABORT", "The operation was aborted"); +const HeadersTimeoutError = defineError("HeadersTimeoutError", "UND_ERR_HEADERS_TIMEOUT", "Headers Timeout Error"); +const HeadersOverflowError = defineError("HeadersOverflowError", "UND_ERR_HEADERS_OVERFLOW", "Headers Overflow Error"); +const BodyTimeoutError = defineError("BodyTimeoutError", "UND_ERR_BODY_TIMEOUT", "Body Timeout Error"); +const RequestContentLengthMismatchError = defineError( + "RequestContentLengthMismatchError", + "UND_ERR_REQ_CONTENT_LENGTH_MISMATCH", + "Request body length does not match content-length header", +); +const ConnectTimeoutError = defineError("ConnectTimeoutError", "UND_ERR_CONNECT_TIMEOUT", "Connect Timeout Error"); +const InvalidArgumentError = defineError("InvalidArgumentError", "UND_ERR_INVALID_ARG", "Invalid Argument Error"); +const InvalidReturnValueError = defineError( + "InvalidReturnValueError", + "UND_ERR_INVALID_RETURN_VALUE", + "Invalid Return Value Error", +); +const RequestAbortedError = defineError( + "RequestAbortedError", + "UND_ERR_ABORTED", + "Request aborted", + "AbortError", + AbortError, +); +const ClientDestroyedError = defineError("ClientDestroyedError", "UND_ERR_DESTROYED", "The client is destroyed"); +const ClientClosedError = defineError("ClientClosedError", "UND_ERR_CLOSED", "The client is closed"); +const InformationalError = defineError("InformationalError", "UND_ERR_INFO", "Request information"); +const NotSupportedError = defineError("NotSupportedError", "UND_ERR_NOT_SUPPORTED", "Not supported error"); +const ResponseContentLengthMismatchError = defineError( + "ResponseContentLengthMismatchError", + "UND_ERR_RES_CONTENT_LENGTH_MISMATCH", + "Response body length does not match content-length header", +); +const BalancedPoolMissingUpstreamError = defineError( + "BalancedPoolMissingUpstreamError", + "UND_ERR_BPL_MISSING_UPSTREAM", + "No upstream has been added to the BalancedPool", + "MissingUpstreamError", +); +const ResponseExceededMaxSizeError = defineError( + "ResponseExceededMaxSizeError", + "UND_ERR_RES_EXCEEDED_MAX_SIZE", + "Response content exceeded max size", +); + +class HTTPParserError extends Error { + constructor(message, code, data) { + super(message); + this.name = "HTTPParserError"; + this.code = code ? `HPE_${code}` : undefined; + this.data = data ? data.toString() : undefined; + } +} + +class ResponseStatusCodeError extends UndiciError { + constructor(message, statusCode, headers, body) { + super(message || "Response Status Code Error"); + this.name = "ResponseStatusCodeError"; + this.code = "UND_ERR_RESPONSE_STATUS_CODE"; + this.body = body; + this.status = statusCode; + this.statusCode = statusCode; + this.headers = headers; + } +} + +class SocketError extends UndiciError { + constructor(message, socket) { + super(message || "Socket error"); + this.name = "SocketError"; + this.code = "UND_ERR_SOCKET"; + this.socket = socket; + } +} + +class RequestRetryError extends UndiciError { + constructor(message, code, { headers, data } = kEmptyObject) { + super(message || "Request retry error"); + this.name = "RequestRetryError"; + this.code = "UND_ERR_REQ_RETRY"; + this.statusCode = code; + this.data = data; + this.headers = headers; + } +} + +class SecureProxyConnectionError extends UndiciError { + constructor(cause, message, options) { + super(message || "Secure Proxy Connection failed", { cause, ...options }); + this.name = "SecureProxyConnectionError"; + this.code = "UND_ERR_PRX_TLS"; + } +} const errors = { AbortError, @@ -445,8 +979,104 @@ function setGlobalDispatcher(dispatcher) { } function getGlobalDispatcher() { - return (globalDispatcher ??= new Dispatcher()); + return (globalDispatcher ??= new Agent()); +} + +const nullBodyStatus = [101, 204, 205, 304]; +const redirectStatus = [301, 302, 303, 307, 308]; + +// Returns true if `dispatcher` has its own dispatch(), which requests then have to go through. +function hasOwnDispatch(dispatcher) { + return dispatcher != null && !dispatcher[kUsesFetch] && $isCallable(dispatcher.dispatch); +} + +// Calls dispatcher.dispatch() and builds the Response from the handler callbacks. +function sendWithDispatch(dispatcher, url, options, signal, redirect) { + return new Promise((resolve, reject) => { + signal.throwIfAborted(); + const { method } = options; + let abort = noop; + let body; + const onAbort = () => abort(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + + dispatcher.dispatch( + { ...options, origin: url.origin, path: url.pathname + url.search }, + { + onConnect(abortRequest) { + abort = abortRequest; + if (signal.aborted) onAbort(); + }, + onHeaders(status, rawHeaders, resume, statusText) { + if (status < 200) return true; + if (redirect === "error" && redirectStatus.includes(status)) { + abort(new TypeError("unexpected redirect")); + return true; + } + const headers = new Headers(); + for (let i = 0; i < rawHeaders.length; i += 2) headers.append(`${rawHeaders[i]}`, `${rawHeaders[i + 1]}`); + const stream = + method === "HEAD" || nullBodyStatus.includes(status) + ? null + : new ReadableStream({ + start(controller) { + body = controller; + }, + pull: resume, + cancel(reason) { + body = undefined; + abort(reason); + }, + }); + const response = new Response(stream, { status, statusText, headers }); + Object.defineProperty(response, "url", { value: url.href }); + resolve(response); + return true; + }, + onData(chunk) { + if (!body) return true; + body.enqueue(chunk); + return body.desiredSize > 0; + }, + onComplete() { + signal.removeEventListener("abort", onAbort); + body?.close(); + }, + onError(err) { + signal.removeEventListener("abort", onAbort); + body?.error(err); + reject(err); + }, + }, + ); + }); +} + +async function fetchWithDispatch(dispatcher, input, init) { + const { url, method, headers, body, redirect, signal } = new Request(input, init); + const maxRedirections = redirect === "follow" ? 20 : 0; + const options = { method, headers: headers.toJSON(), body, maxRedirections }; + return await sendWithDispatch(dispatcher, new URL(url), options, signal, redirect); +} + +function fetch(input, init) { + const dispatcher = init?.dispatcher ?? globalDispatcher; + if (hasOwnDispatch(dispatcher)) return fetchWithDispatch(dispatcher, input, init); + const options = dispatcher?.[kFetchOptions]; + if (!options) return nativeFetch(input, init); + if (init instanceof Request) { + // A Request's fields are getters, so a spread would lose them. + try { + return nativeFetch(new Request(input, init), options); + } catch (err) { + return Promise.$reject(err); + } + } + const merged = { ...init }; + for (const name in options) merged[name] ??= options[name]; + return nativeFetch(input, merged); } +fetch.preconnect = nativeFetch.preconnect; // Add missing origin functions function setGlobalOrigin() {} diff --git a/src/jsc/AbortSignal.rs b/src/jsc/AbortSignal.rs index 84c2b5791c26..a47cddd050d8 100644 --- a/src/jsc/AbortSignal.rs +++ b/src/jsc/AbortSignal.rs @@ -118,6 +118,7 @@ impl AbortSignal { return Some(AbortReason::Common(match reason { 1 => CommonAbortReason::Timeout, 2 => CommonAbortReason::UserAbort, + 4 => CommonAbortReason::RequestBodyTooLarge, _ => CommonAbortReason::ConnectionClosed, })); } diff --git a/src/jsc/CommonStrings.rs b/src/jsc/CommonStrings.rs index 2a4e4ca735a1..2b205b290934 100644 --- a/src/jsc/CommonStrings.rs +++ b/src/jsc/CommonStrings.rs @@ -49,6 +49,7 @@ pub enum CommonStringsForRust { FetchOptionTls = 34, FetchOptionUnix = 35, FetchOptionVerbose = 36, + FetchBasic = 37, } unsafe extern "C" { @@ -115,6 +116,11 @@ impl<'a> CommonStrings<'a> { pub fn default(self) -> JSValue { CommonStringsForRust::FetchDefault.to_js(self.global_object) } + /// `"basic"` + #[inline] + pub fn basic(self) -> JSValue { + CommonStringsForRust::FetchBasic.to_js(self.global_object) + } /// `"error"` #[inline] pub fn error(self) -> JSValue { diff --git a/src/jsc/FetchHeaders.rs b/src/jsc/FetchHeaders.rs index 2543c7cbbccb..c57222ab364a 100644 --- a/src/jsc/FetchHeaders.rs +++ b/src/jsc/FetchHeaders.rs @@ -1,7 +1,6 @@ use core::ffi::c_void; use core::ptr::NonNull; -use crate::virtual_machine::VirtualMachine; use crate::{JSGlobalObject, JSValue, JsResult, VM, host_fn}; use bun_core::{EncodedSlice, String as BunString, StringPointer}; use bun_uws::ResponseKind; @@ -22,6 +21,7 @@ bun_opaque::opaque_ffi! { // (which may free) keep their `unsafe fn` body. unsafe extern "C" { safe fn WebCore__FetchHeaders__cast_(value0: JSValue, arg1: &VM) -> *mut FetchHeaders; + safe fn WebCore__FetchHeaders__castAsInit(value0: JSValue) -> *mut FetchHeaders; safe fn WebCore__FetchHeaders__cloneThis( arg0: &FetchHeaders, arg1: &JSGlobalObject, @@ -59,6 +59,8 @@ unsafe extern "C" { arg3: &JSGlobalObject, ); safe fn WebCore__FetchHeaders__isEmpty(arg0: &FetchHeaders) -> bool; + safe fn WebCore__FetchHeaders__setImpliedByBody(arg0: &mut FetchHeaders, implied: bool); + safe fn WebCore__FetchHeaders__isImpliedByBody(arg0: &FetchHeaders) -> bool; safe fn WebCore__FetchHeaders__toJS(arg0: &FetchHeaders, arg1: &JSGlobalObject) -> JSValue; // safe: `FetchHeaders` is an opaque ZST handle (`&mut` ≡ non-null `*mut`); // `arg2` is an opaque handle to a C++-owned uWS response (never dereferenced @@ -134,6 +136,15 @@ impl FetchHeaders { WebCore__FetchHeaders__isEmpty(self) } + /// Marks a list created only to hold its body's `Content-Type`. `clone_this` copies the mark. + pub fn set_implied_by_body(&mut self, implied: bool) { + WebCore__FetchHeaders__setImpliedByBody(self, implied) + } + + pub fn is_implied_by_body(&self) -> bool { + WebCore__FetchHeaders__isImpliedByBody(self) + } + pub fn create_from_uws(uws_request: *mut c_void) -> NonNull { NonNull::new(WebCore__FetchHeaders__createFromUWS(uws_request)) .expect("WebCore__FetchHeaders__createFromUWS returned null") @@ -239,11 +250,9 @@ impl FetchHeaders { NonNull::new(WebCore__FetchHeaders__cast_(value, vm)) } - pub fn cast(value: JSValue) -> Option> { - // SAFETY: `VirtualMachine::get()` is only called from the JS thread, where - // `global` is a live non-null JSGlobalObject for the VM's lifetime. - let global = VirtualMachine::get().global(); - Self::cast_(value, global.vm()) + /// [`Self::cast_`] for a HeadersInit. Returns `None` if `Symbol.iterator` isn't `Headers.prototype.entries`. + pub fn cast_as_init(value: JSValue) -> Option> { + NonNull::new(WebCore__FetchHeaders__castAsInit(value)) } pub fn to_js(&mut self, global_this: &JSGlobalObject) -> JSValue { diff --git a/src/jsc/JSGlobalObject.rs b/src/jsc/JSGlobalObject.rs index 613b6d5b8a7b..ed54ae52ef7d 100644 --- a/src/jsc/JSGlobalObject.rs +++ b/src/jsc/JSGlobalObject.rs @@ -1157,6 +1157,12 @@ impl JSGlobalObject { ZigGlobalObject__makeNapiEnvForFFI(self) } + /// Returns the `process.env` object, or `None` if JavaScript hasn't read `process.env` yet. + pub fn process_env_if_created(&self) -> Option { + let env = Bun__Process__getEnvObjectIfCreated(self); + (!env.is_empty()).then_some(env) + } + // returns false if it throws pub fn validate_object( &self, @@ -1444,6 +1450,7 @@ unsafe extern "C" { path: &BunString, ) -> bool; + safe fn Bun__Process__getEnvObjectIfCreated(global_object: &JSGlobalObject) -> JSValue; safe fn Bun__Process__emitWarning( global_object: &JSGlobalObject, warning: JSValue, diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 869f8eb4b873..ae4f0e61fc9f 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -387,6 +387,8 @@ pub struct VirtualMachine { /// `&mut VirtualMachine` while `&mut EventLoop` is live. Zero-valid /// (`Cell` is `repr(transparent)` over `bool`). pub is_inside_deferred_task_queue: core::cell::Cell, + /// A deferred task called JS, which gets no microtask checkpoint of its own. `Cell` for the same reason. + pub(crate) deferred_task_entered_js: core::cell::Cell, /// When true, drainMicrotasksWithGlobal is suppressed. `Cell` for the same /// reason as [`Self::is_inside_deferred_task_queue`]. pub(crate) suppress_microtask_drain: core::cell::Cell, diff --git a/src/jsc/bindings/BunCommonStrings.cpp b/src/jsc/bindings/BunCommonStrings.cpp index 506e6be85d46..c2fadd0a0f4c 100644 --- a/src/jsc/bindings/BunCommonStrings.cpp +++ b/src/jsc/bindings/BunCommonStrings.cpp @@ -222,6 +222,7 @@ enum class CommonStringsForRust : uint8_t { fetchOptionTls = 34, fetchOptionUnix = 35, fetchOptionVerbose = 36, + fetchBasic = 37, }; static JSC::JSValue toJS(Zig::GlobalObject* globalObject, CommonStringsForRust commonString) @@ -302,6 +303,8 @@ static JSC::JSValue toJS(Zig::GlobalObject* globalObject, CommonStringsForRust c return commonStrings.fetchOptionUnixString(); case CommonStringsForRust::fetchOptionVerbose: return commonStrings.fetchOptionVerboseString(); + case CommonStringsForRust::fetchBasic: + return commonStrings.fetchBasicString(); default: { ASSERT_NOT_REACHED(); return jsUndefined(); diff --git a/src/jsc/bindings/BunCommonStrings.h b/src/jsc/bindings/BunCommonStrings.h index fd38c91423a9..2487f1cd5acf 100644 --- a/src/jsc/bindings/BunCommonStrings.h +++ b/src/jsc/bindings/BunCommonStrings.h @@ -96,6 +96,7 @@ macro(binaryTypeNodeBuffer, "nodebuffer") \ macro(binaryTypeUint8Array, "uint8array") \ macro(buffer, "buffer") \ + macro(fetchBasic, "basic") \ macro(fetchCors, "cors") \ macro(fetchFollow, "follow") \ macro(fetchForceCache, "force-cache") \ diff --git a/src/jsc/bindings/BunString.cpp b/src/jsc/bindings/BunString.cpp index d278811da302..a6dad44ca2a3 100644 --- a/src/jsc/bindings/BunString.cpp +++ b/src/jsc/bindings/BunString.cpp @@ -640,14 +640,14 @@ extern "C" BunString URL__getHrefFromJS(EncodedJSValue encodedValue, JSC::JSGlob return Bun::toStringRef(url.string()); } -extern "C" BunString URL__getHref(const BunString* input) +extern "C" BunString URL__getHref(const BunString* input, bool excludeFragment) { auto&& str = input->toWTFString(); auto url = WTF::URL(str); if (!url.isValid() || url.isEmpty()) return { BunStringTag::Dead }; - return Bun::toStringRef(url.string()); + return Bun::toStringRef(excludeFragment ? url.stringWithoutFragmentIdentifier() : url.string()); } extern "C" BunString URL__pathFromFileURL(const BunString* input) @@ -660,7 +660,7 @@ extern "C" BunString URL__pathFromFileURL(const BunString* input) return Bun::toStringRef(url.fileSystemPath()); } -extern "C" BunString URL__getHrefJoin(const BunString* baseStr, const BunString* relativeStr) +extern "C" BunString URL__getHrefJoin(const BunString* baseStr, const BunString* relativeStr, bool excludeFragment) { auto base = baseStr->toWTFString(); auto relative = relativeStr->toWTFString(); @@ -668,7 +668,7 @@ extern "C" BunString URL__getHrefJoin(const BunString* baseStr, const BunString* if (!url.isValid() || url.isEmpty()) return { BunStringTag::Dead }; - return Bun::toStringRef(url.string()); + return Bun::toStringRef(excludeFragment ? url.stringWithoutFragmentIdentifier() : url.string()); } extern "C" BunString URL__fragmentIdentifier(WTF::URL* url) diff --git a/src/jsc/bindings/ErrorCode.cpp b/src/jsc/bindings/ErrorCode.cpp index cd5f87c1d9a9..5cf647b2e6b3 100644 --- a/src/jsc/bindings/ErrorCode.cpp +++ b/src/jsc/bindings/ErrorCode.cpp @@ -1819,6 +1819,9 @@ JSC::JSValue WebCore::toJS(JSC::JSGlobalObject* globalObject, CommonAbortReason case CommonAbortReason::ConnectionClosed: { return createDOMException(globalObject, ExceptionCode::AbortError, "The connection was closed."_s); } + case CommonAbortReason::RequestBodyTooLarge: { + return createDOMException(globalObject, ExceptionCode::AbortError, "Request body exceeded maxRequestBodySize"_s); + } default: { break; } @@ -1914,6 +1917,8 @@ static constexpr SimpleErrorMessage simpleErrorMessages[] = { { ErrorCode::ERR_HTTP2_SESSION_ERROR, 1, { "Session closed with error code "_s, ""_s, ""_s } }, { ErrorCode::ERR_HTTP2_PAYLOAD_FORBIDDEN, 1, { "Responses with "_s, " status must not have a payload"_s, ""_s } }, { ErrorCode::ERR_HTTP2_INVALID_INFO_STATUS, 1, { "Invalid informational status code: "_s, ""_s, ""_s } }, + { ErrorCode::ERR_HTTP2_UNSUPPORTED_PROTOCOL, 1, { "protocol \""_s, "\" is unsupported."_s, ""_s } }, + { ErrorCode::ERR_HTTP2_TOO_MANY_ORIGINS, 1, { "The server sent more ORIGIN frames than the allowed number of "_s, ""_s, ""_s } }, { ErrorCode::ERR_HTTP_INVALID_HEADER_VALUE, 2, { "Invalid value \""_s, "\" for header \""_s, "\""_s } }, { ErrorCode::ERR_HTTP_HEADERS_SENT, 1, { "Cannot "_s, " headers after they are sent to the client"_s, ""_s } }, { ErrorCode::ERR_UNESCAPED_CHARACTERS, 1, { ""_s, " contains unescaped characters"_s, ""_s } }, diff --git a/src/jsc/bindings/ErrorCode.ts b/src/jsc/bindings/ErrorCode.ts index adb168f20bcf..2bd09b77922c 100644 --- a/src/jsc/bindings/ErrorCode.ts +++ b/src/jsc/bindings/ErrorCode.ts @@ -119,6 +119,7 @@ const errors: ErrorCodeMapping = [ ["ERR_HTTP2_TRAILERS_NOT_READY", Error], ["ERR_HTTP2_TOO_MANY_CUSTOM_SETTINGS", Error], ["ERR_HTTP2_TOO_MANY_INVALID_FRAMES", Error], + ["ERR_HTTP2_TOO_MANY_ORIGINS", Error], ["ERR_HTTP2_UNSUPPORTED_PROTOCOL", Error], ["ERR_HTTP2_INVALID_SETTING_VALUE", TypeError, "TypeError", RangeError], ["ERR_ILLEGAL_CONSTRUCTOR", TypeError], diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.cpp b/src/jsc/bindings/JSEnvironmentVariableMap.cpp index 9ce1d26869bd..67e8ff941f8c 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.cpp +++ b/src/jsc/bindings/JSEnvironmentVariableMap.cpp @@ -1125,3 +1125,8 @@ JSObject* createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) #endif } } + +extern "C" JSC::EncodedJSValue Bun__Process__getEnvObjectIfCreated(JSC::JSGlobalObject* globalObject) +{ + return JSC::JSValue::encode(defaultGlobalObject(globalObject)->m_processEnvObject.get()); +} diff --git a/src/jsc/bindings/NodeHTTP.cpp b/src/jsc/bindings/NodeHTTP.cpp index b32299eca342..cb1f7bb20ae3 100644 --- a/src/jsc/bindings/NodeHTTP.cpp +++ b/src/jsc/bindings/NodeHTTP.cpp @@ -82,8 +82,8 @@ static bool svValueHasToken(std::string_view value, std::string_view lowerToken) // bitfield) to `args`, and capture the raw header bytes into `flatHeaders` // as [u32 nameLen][u32 valueLen][name][value]... so req.rawHeaders / // req.headers can be materialized lazily (Bun__NodeHTTP__buildRawHeadersArray, -// Bun__NodeHTTP__buildHeadersObject) only when user code reads them. -static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSValue methodString, MarkedArgumentBuffer& args, WTF::Vector& flatHeaders, JSC::JSGlobalObject* globalObject, JSC::VM& vm) +// Bun__NodeHTTP__buildHeadersObject) only when user code reads them. Returns the bitfield. +static uint32_t assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSValue methodString, MarkedArgumentBuffer& args, WTF::Vector& flatHeaders, JSC::JSGlobalObject* globalObject, JSC::VM& vm) { { std::string_view fullURLStdStr = request->getFullUrl(); @@ -160,6 +160,17 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal // The headers-object slot now carries the dispatch bitfield; rawHeaders // materialize lazily from the captured bytes, so no array is passed. args.append(jsNumber(bits)); + return bits; +} + +// Returns true if the JS dispatcher can pass `head` on: to 'connect', or to 'upgrade' for a request with no body that isn't pipelined. +static ALWAYS_INLINE bool canHandOffHead(JSC::VM& vm, JSValue methodString, uint32_t dispatchBits, bool hasBody, bool isPipelinedDispatch) +{ + static constexpr uint32_t upgradeBits = kDispatchHasUpgrade | kDispatchConnUpgrade; + // Bun__HTTPMethod__toJS returns this cell for every CONNECT. It's null until the first one, and then matches nothing. + if (methodString == JSValue(Bun::commonStrings(vm).m_httpCONNECT)) + return true; + return !hasBody && !isPipelinedDispatch && (dispatchBits & upgradeBits) == upgradeBits; } // A known header's value as a string: the one remembered from the last request when it holds the same bytes, @@ -411,7 +422,7 @@ static EncodedJSValue NodeHTTPServer__onRequest( // Typical request header sections are a few hundred bytes; the inline // capacity keeps the capture heap-allocation-free for the common case. WTF::Vector flatHeaders; - assignHeadersFromUWebSocketsForCall(request, methodString, args, flatHeaders, globalObject, vm); + const uint32_t dispatchBits = assignHeadersFromUWebSocketsForCall(request, methodString, args, flatHeaders, globalObject, vm); auto* httpResponseData = response->getHttpResponseData(); // Pipelined: an earlier response is in flight, so this one is queued and gets the connection at its turn (startPipelinedResponse). @@ -457,7 +468,7 @@ static EncodedJSValue NodeHTTPServer__onRequest( args.append(jsBoolean(request->isAncient())); // Pass pipelined data (head buffer) for Node.js compat (connect/upgrade events) - if (!request->head.empty()) { + if (!request->head.empty() && canHandOffHead(vm, methodString, dispatchBits, hasBody, isPipelinedDispatch)) { JSC::JSUint8Array* headBuffer = WebCore::createBuffer(globalObject, std::span(reinterpret_cast(request->head.data()), request->head.size())); RETURN_IF_EXCEPTION(scope, {}); args.append(headBuffer); @@ -579,8 +590,11 @@ static void writeFetchHeadersToUWSResponse(WebCore::FetchHeaders& headers, uWS:: // close the connection after the response. Mark the uWS state so // end()/tryEnd() shut the socket down instead of returning it to the // keep-alive pool. - if (header.key == WebCore::HTTPHeaderName::Connection && connectionValueHasClose(value)) { - data->state |= uWS::HttpResponseData::HTTP_CONNECTION_CLOSE; + if (header.key == WebCore::HTTPHeaderName::Connection) { + data->state |= uWS::HttpResponseData::HTTP_WROTE_CONNECTION_HEADER; + if (connectionValueHasClose(value)) { + data->state |= uWS::HttpResponseData::HTTP_CONNECTION_CLOSE; + } } writeResponseHeader(res, name, value); } @@ -604,6 +618,8 @@ static constexpr uint32_t kAutoHeaderKeepAliveTimeout = 1 << 3; // Node's _storeHeader emits chunked Transfer-Encoding after Connection/Keep-Alive, so it // cannot ride in the flat array (written first). Carry as an auto-header bit, rendered last. static constexpr uint32_t kAutoHeaderTransferEncodingChunked = 1 << 4; +// No header line. The handler set Transfer-Encoding: chunked, which overrides HTTP/1.0 and Content-Length framing. +static constexpr uint32_t kAutoHeaderBodyChunked = 1 << 5; // "Date: \r\n", rebuilt at most once per second. Hand-rolled // (not strftime) so the day/month names are locale-independent. @@ -669,6 +685,9 @@ static void writeAutoHeaders(uWS::HttpResponse* response, uint32_t autoHe // chunk-frames the body. response->getHttpResponseData()->state |= uWS::HttpResponseData::HTTP_WROTE_TRANSFER_ENCODING_HEADER; } + if (autoHeaderBits & kAutoHeaderBodyChunked) { + response->getHttpResponseData()->state &= ~(uWS::HttpResponseData::HTTP_ANCIENT_REQUEST | uWS::HttpResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER); + } } template @@ -690,10 +709,9 @@ static void NodeHTTPServer__writeHead( } response->writeStatus(std::string_view(statusMessage, statusMessageLength)); - // node:http's ServerResponse owns the Date header entirely (it honors - // res.sendDate / removeHeader("date") in JS), so never let uWS write its - // own Date header for these responses. - response->getHttpResponseData()->state |= uWS::HttpResponseData::HTTP_WROTE_DATE_HEADER; + // node:http's ServerResponse decides the Date and Connection headers in JS, so uWS must not add its own. + response->getHttpResponseData()->state |= uWS::HttpResponseData::HTTP_WROTE_DATE_HEADER + | uWS::HttpResponseData::HTTP_WROTE_CONNECTION_HEADER; // 204/304 responses must not carry any body framing, even when the user // explicitly set a Transfer-Encoding header (Node.js suppresses the diff --git a/src/jsc/bindings/ServerRouteList.cpp b/src/jsc/bindings/ServerRouteList.cpp index 44a6b085aa26..18a717f3e55d 100644 --- a/src/jsc/bindings/ServerRouteList.cpp +++ b/src/jsc/bindings/ServerRouteList.cpp @@ -135,7 +135,9 @@ class ServerRouteList final : public JSC::JSDestructibleObject { break; } case ':': { - startOfIdentifier = j + 1; + // uWS::HttpRouter only captures a segment that starts with ':'. + if (startOfIdentifier || (j && path[j - 1] == '/')) + startOfIdentifier = j + 1; break; } default: { diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 0f9b64fd3720..ab1850341a36 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -2369,6 +2369,11 @@ WebCore::FetchHeaders* WebCore__FetchHeaders__createFromJS(JSC::JSGlobalObject* auto throwScope = DECLARE_THROW_SCOPE(lexicalGlobalObject->vm()); throwScope.assertNoException(); + if (!argument0.value().isUndefined() && !argument0.value().isObject()) { + Bun::ERR::INVALID_ARG_INSTANCE(throwScope, lexicalGlobalObject, "headers"_s, "Headers, Array, or Object"_s, argument0.value()); + return nullptr; + } + // Note that we use IDLDOMString here rather than IDLByteString: while headers // should be ASCII only, we want the headers->fill implementation to discover // and error on invalid names and values @@ -2436,6 +2441,7 @@ WebCore::FetchHeaders* WebCore__FetchHeaders__cloneThis(WebCore::FetchHeaders* h { auto throwScope = DECLARE_THROW_SCOPE(lexicalGlobalObject->vm()); auto* clone = new WebCore::FetchHeaders({ WebCore::FetchHeaders::Guard::None, {} }); + clone->setImpliedByBody(headers->isImpliedByBody()); WebCore::propagateException(*lexicalGlobalObject, throwScope, clone->fill(*headers)); return clone; } @@ -2655,7 +2661,11 @@ extern "C" void WebCore__FetchHeaders__put(WebCore::FetchHeaders* headers, HTTPH auto throwScope = DECLARE_THROW_SCOPE(global->vm()); throwScope.assertNoException(); // can't throw an exception when there's already one. // `toWTFString()` refs a `WTFStringImpl`-tagged value instead of copying it. - WebCore::propagateException(*global, throwScope, headers->set(name, arg2->toWTFString())); + WTF::String value = arg2->toWTFString(); + // HTTPHeaderMap treats a null value as absent in get() and iteration, but not in has(). + if (value.isNull()) + value = WTF::emptyString(); + WebCore::propagateException(*global, throwScope, headers->set(name, value)); } void WebCore__FetchHeaders__fastRemove_(WebCore::FetchHeaders* headers, unsigned char headerName) { diff --git a/src/jsc/bindings/c-bindings.cpp b/src/jsc/bindings/c-bindings.cpp index 9e78869ada4c..d2fe3011db1d 100644 --- a/src/jsc/bindings/c-bindings.cpp +++ b/src/jsc/bindings/c-bindings.cpp @@ -574,6 +574,41 @@ size_t lshpack_wrapper_encode(lshpack_wrapper* self, return ptr - start; } +// Returns the bytes written, 0 if the block is refused (encoder untouched), or SIZE_MAX if lshpack ran out of memory mid-block. +size_t lshpack_wrapper_encode_block(lshpack_wrapper* self, + const unsigned char* bytes, const uint32_t* fields, size_t field_count, + unsigned char* dst, size_t dst_len) +{ + size_t needed = 0; + for (size_t i = 0; i < field_count; i++) { + size_t name_len = fields[i * 2] & 0x7fffffffu; + size_t val_len = fields[i * 2 + 1]; + if (name_len > LSXPACK_MAX_STRLEN || val_len > LSXPACK_MAX_STRLEN || name_len + val_len > LSHPACK_MAX_HEADER_SIZE) + return 0; + needed += name_len + val_len + 32; + } + if (dst_len < needed) + return 0; + + unsigned char* cursor = dst; + unsigned char* const end = dst + dst_len; + for (size_t i = 0; i < field_count; i++) { + size_t name_len = fields[i * 2] & 0x7fffffffu; + size_t val_len = fields[i * 2 + 1]; + lsxpack_header_t hdr; + lsxpack_header_set_offset2(&hdr, reinterpret_cast(bytes), 0, name_len, name_len, val_len); + if (fields[i * 2] >> 31) { + hdr.indexed_type = 2; + } + auto* next = lshpack_enc_encode(&self->enc, cursor, end, &hdr); + if (next == cursor) + return SIZE_MAX; + cursor = next; + bytes += name_len + val_len; + } + return cursor - dst; +} + size_t lshpack_wrapper_decode(lshpack_wrapper* self, const unsigned char* src, size_t src_len, lshpack_header* output) @@ -606,6 +641,11 @@ void lshpack_wrapper_enc_set_max_capacity(lshpack_wrapper* self, unsigned max_ca lshpack_enc_set_max_capacity(&self->enc, max_capacity); } +void lshpack_wrapper_dec_set_max_capacity(lshpack_wrapper* self, unsigned max_capacity) +{ + lshpack_dec_set_max_capacity(&self->dec, max_capacity); +} + void lshpack_wrapper_deinit(lshpack_wrapper* self) { lshpack_dec_cleanup(&self->dec); diff --git a/src/jsc/bindings/decodeURIComponentSIMD.cpp b/src/jsc/bindings/decodeURIComponentSIMD.cpp index f13fc2e13013..5e784a6f2067 100644 --- a/src/jsc/bindings/decodeURIComponentSIMD.cpp +++ b/src/jsc/bindings/decodeURIComponentSIMD.cpp @@ -77,7 +77,7 @@ WTF::String decodeURIComponentSIMD(std::span input) while (cursor < end) { if (*cursor == '%') { if (cursor + 2 >= end) { - result.append(replacementChar); + result.append('%'); cursor++; continue; } @@ -86,8 +86,8 @@ WTF::String decodeURIComponentSIMD(std::span input) uint8_t lowNibble = hexToInt(cursor[2]); if (highNibble > 15 || lowNibble > 15) { - result.append(replacementChar); - cursor += (cursor + 2 < end) ? 3 : 1; + result.append('%'); + cursor++; continue; } diff --git a/src/jsc/bindings/headers.h b/src/jsc/bindings/headers.h index d6d4b82bb96b..5b5f4adf1556 100644 --- a/src/jsc/bindings/headers.h +++ b/src/jsc/bindings/headers.h @@ -64,6 +64,7 @@ CPP_DECL WebCore::DOMFormData* _fromJS(JSC::EncodedJSValue JSValue0); #pragma mark - WebCore::FetchHeaders CPP_DECL WebCore::FetchHeaders* WebCore__FetchHeaders__cast_(JSC::EncodedJSValue JSValue0, JSC::VM* arg1); +CPP_DECL WebCore::FetchHeaders* WebCore__FetchHeaders__castAsInit(JSC::EncodedJSValue JSValue0); 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, uint32_t arg3, unsigned char* arg4, uint32_t arg5); CPP_DECL bool WebCore__FetchHeaders__count(WebCore::FetchHeaders* arg0, uint32_t* arg1, uint32_t* arg2); @@ -78,6 +79,8 @@ CPP_DECL bool WebCore__FetchHeaders__fastHas_(WebCore::FetchHeaders* arg0, unsig CPP_DECL void WebCore__FetchHeaders__fastRemove_(WebCore::FetchHeaders* arg0, unsigned char arg1); CPP_DECL void WebCore__FetchHeaders__get_(WebCore::FetchHeaders* arg0, const EncodedSlice* arg1, EncodedSlice* arg2, JSC::JSGlobalObject* arg3); CPP_DECL bool WebCore__FetchHeaders__isEmpty(WebCore::FetchHeaders* arg0); +CPP_DECL void WebCore__FetchHeaders__setImpliedByBody(WebCore::FetchHeaders* arg0, bool arg1); +CPP_DECL bool WebCore__FetchHeaders__isImpliedByBody(WebCore::FetchHeaders* arg0); CPP_DECL JSC::EncodedJSValue WebCore__FetchHeaders__toJS(WebCore::FetchHeaders* arg0, JSC::JSGlobalObject* arg1); CPP_DECL void WebCore__FetchHeaders__toUWSResponse(WebCore::FetchHeaders* arg0, UWSResponseKind kind, void* arg2); CPP_DECL void WebCore__FetchHeaders__toUWSResponseForWebSocketUpgrade(WebCore::FetchHeaders* arg0, bool isSSL, void* arg1); diff --git a/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp b/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp index 794721a63b45..6542dbf816e9 100644 --- a/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp +++ b/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp @@ -20,6 +20,7 @@ extern "C" void Bun__NodeHTTPResponse_grantConnection(void* zigResponse); extern "C" void Bun__NodeHTTPResponse_onReadParsed(void* zigResponse); extern "C" void Bun__NodeHTTPResponse_markTunneled(void* zigResponse); extern "C" void Bun__NodeHTTPResponse_spillPendingWrite(void* zigResponse); +extern "C" void Bun__NodeHTTPResponse_updateParked(void* zigResponse); extern "C" void Bun__NodeHTTPResponse_onClose(void* zigResponse, JSC::EncodedJSValue jsValue); extern "C" void us_socket_free_stream_buffer(us_socket_stream_buffer_t* streamBuffer); extern "C" uint64_t uws_res_get_remote_address_info(void* res, const char** dest, int* port, bool* is_ipv6); @@ -27,6 +28,8 @@ extern "C" uint64_t uws_res_get_local_address_info(void* res, const char** dest, extern "C" EncodedJSValue us_socket_buffered_js_write(void* socket, bool is_ssl, bool ended, bool hold, bool flushesBufferOnDrain, us_socket_stream_buffer_t* streamBuffer, JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue data, JSC::EncodedJSValue encoding); extern "C" int us_socket_is_ssl_handshake_finished(struct us_socket_t* s); extern "C" int us_socket_ssl_handshake_callback_has_fired(struct us_socket_t* s); +extern "C" void Bun__EventLoop__enter(Zig::GlobalObject*); +extern "C" void Bun__EventLoop__exit(Zig::GlobalObject*); namespace Bun { @@ -85,8 +88,7 @@ static void flushPartialResponseBeforeClose(us_socket_t* socket) // Only flush when an in-flight response wrote part of its body but never // ended: Node has already handed those res.write() bytes to the kernel by // the time destroy() runs, so they reach the client there. Ended - // responses (including the synthetic terminator written by abort()) keep - // the old behavior of being discarded with the close. + // responses keep the old behavior of being discarded with the close. if ((httpResponseData->state & uWS::HttpResponseData::HTTP_WRITE_CALLED) && !(httpResponseData->state & uWS::HttpResponseData::HTTP_END_CALLED)) { reinterpret_cast*>(socket)->uncork(); @@ -154,6 +156,8 @@ void JSNodeHTTPServerSocket::upgradeToTunnelMode(bool afterBody, JSNodeHTTPRespo * tearing the connection down, so the server can still write and decides * itself when to end the socket. */ socket->flags.allow_half_open = 1; + /* From here on the JS socket only carries tunnel bytes. */ + setRawReads(false); /* Reuse the CONNECT plumbing so the parser stops interpreting subsequent * bytes as HTTP and routes them to the ondata callback as opaque data. */ if (is_ssl) { @@ -183,35 +187,75 @@ static bool isTunnel(const JSNodeHTTPServerSocket* self) return self->is_ssl ? isInTunnelMode(socket) : isInTunnelMode(socket); } +template +static bool hasRawReadsFlag(us_socket_t* socket) +{ + return (reinterpret_cast*>(us_socket_ext(socket))->state & uWS::HttpResponseData::HTTP_NODE_RAW_READS) != 0; +} + +static bool hasRawReads(const JSNodeHTTPServerSocket* self) +{ + us_socket_t* socket = self->socket; + if (!socket || self->upgraded || us_socket_is_closed(socket)) { + return false; + } + return self->is_ssl ? hasRawReadsFlag(socket) : hasRawReadsFlag(socket); +} + void JSNodeHTTPServerSocket::applyTunnelReads() { - if (!isTunnel(this)) { + const bool tunnel = isTunnel(this); + if (!tunnel && !hasRawReads(this)) { return; } if (tunnelReadsPaused()) { us_socket_pause(socket); - } else { + } else if (tunnel) { us_socket_resume(socket); + } else if (is_ssl) { + // Not a bare resume: flood prevention can still hold the reads of an HTTP connection. + onNodeHttpReadsResumable(socket); + } else { + onNodeHttpReadsResumable(socket); } } void JSNodeHTTPServerSocket::readStop() { // After the end of the stream no _read() comes to start the reads again. - if (!isTunnel(this) || tunnelReadEnded) { + if ((!isTunnel(this) && !hasRawReads(this)) || tunnelReadEnded) { return; } tunnelReadsStopped = true; applyTunnelReads(); + updateTunnelIdle(); } void JSNodeHTTPServerSocket::readStart() { - if (!isTunnel(this)) { + if (!isTunnel(this) && !hasRawReads(this)) { return; } tunnelReadsStopped = false; applyTunnelReads(); + updateTunnelIdle(); +} + +void JSNodeHTTPServerSocket::setRawReads(bool enabled) +{ + /* Once a WebSocket adopts the connection, its ext block is no longer an HttpResponseData. */ + if (!socket || upgraded || us_socket_is_closed(socket)) { + return; + } + if (!enabled && hasRawReads(this)) { + // Without ondata, no _read() would call readStart() again. + readStart(); + } + if (is_ssl) { + reinterpret_cast*>(us_socket_ext(socket))->setFlag(uWS::HttpResponseData::HTTP_NODE_RAW_READS, enabled); + } else { + reinterpret_cast*>(us_socket_ext(socket))->setFlag(uWS::HttpResponseData::HTTP_NODE_RAW_READS, enabled); + } } void JSNodeHTTPServerSocket::didDeliverQueuedTunnelBytes(size_t length) @@ -363,6 +407,8 @@ static bool deferShutdownUntilResponseDrains(us_socket_t* socket, bool destroySo if (destroySoon) { /* And closes it there, even if the response never ends. */ httpResponseData->state |= uWS::HttpResponseData::HTTP_NODE_CLOSE_AFTER_DRAIN; + } else { + httpResponseData->state |= uWS::HttpResponseData::HTTP_NODE_SHUTDOWN_AFTER_DRAIN; } return true; } @@ -625,6 +671,19 @@ extern "C" void Bun__NodeHTTP__onReadsPaused(int ssl, us_socket_t* socket) } } +/* Called by NodeHTTPResponse::update_parked once reads are paused and nothing is left to send. */ +extern "C" void Bun__NodeHTTP__onRequestParked(int ssl, us_socket_t* socket) +{ + if (!socket || us_socket_is_closed(socket)) { + return; + } + if (ssl) { + reinterpret_cast*>(socket)->setNodeHttpTunnelIdle(true); + } else { + reinterpret_cast*>(socket)->setNodeHttpTunnelIdle(true); + } +} + template static bool notifyWhenNodeHttpReadParsed(us_socket_t* socket) { @@ -884,16 +943,30 @@ extern "C" bool Bun__NodeHTTPServerSocket__writeBehindResponse(us_socket_t* sock return is_ssl ? writeBehindResponse(socket, data, length) : writeBehindResponse(socket, data, length); } +template +static void updateTunnelIdleImpl(us_socket_t* socket, bool sent, bool readEnded, bool readsStopped) +{ + sent = sent && reinterpret_cast*>(socket)->hasFullyDrained(); + const bool canRead = !readEnded && isInTunnelMode(socket); + /* Only a tunnel becomes idle here. A parked request's connection gets no drain call, so a raw write that leaves bytes only ends its idle state. */ + if (readEnded || canRead || !sent) { + reinterpret_cast*>(socket)->setNodeHttpTunnelIdle(sent && (readEnded || readsStopped), canRead); + } +} + void JSNodeHTTPServerSocket::updateTunnelIdle() { - if (!tunnelReadEnded || upgraded || isClosed()) { + if (upgraded || isClosed()) { return; } const bool sent = streamBuffer.bufferedSize() == 0; if (is_ssl) { - reinterpret_cast*>(socket)->setNodeHttpTunnelIdle(sent && reinterpret_cast*>(socket)->hasFullyDrained()); + updateTunnelIdleImpl(socket, sent, tunnelReadEnded, tunnelReadsStopped); } else { - reinterpret_cast*>(socket)->setNodeHttpTunnelIdle(sent && reinterpret_cast*>(socket)->hasFullyDrained()); + updateTunnelIdleImpl(socket, sent, tunnelReadEnded, tunnelReadsStopped); + } + if (auto* res = currentResponse()) { + Bun__NodeHTTPResponse_updateParked(res->wrapped()); } } @@ -1006,6 +1079,34 @@ void JSNodeHTTPServerSocket::onData(const char* data, int length, bool last) } } +// Synchronous, unlike onData(). In a task, a 'data' listener added by this read's 'upgrade' or 'connect' listener would also get the request head. +void JSNodeHTTPServerSocket::onRawRead(const char* data, int length) +{ + auto* callbackObject = functionToCallOnData.get(); + if (!callbackObject) { + return; + } + auto* globalObject = defaultGlobalObject(this->globalObject()); + if (globalObject->scriptExecutionStatus(globalObject, this) != ScriptExecutionStatus::Running) { + return; + } + EnsureStillAliveScope ensureStillAlive(this); + MarkedArgumentBuffer args; + { + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(globalObject->vm()); + args.append(WebCore::createBuffer(globalObject, std::span(reinterpret_cast(data), length))); + if (auto* exception = scope.exception()) { + (void)scope.tryClearException(); + globalObject->reportUncaughtExceptionAtEventLoop(globalObject, exception); + RETURN_IF_EXCEPTION(scope, ); + return; + } + } + Bun__EventLoop__enter(globalObject); + callStoredCallback(globalObject, callbackObject, this, args); + Bun__EventLoop__exit(globalObject); +} + JSC::Structure* JSNodeHTTPServerSocket::createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject) { auto* structure = JSC::Structure::create(vm, globalObject, globalObject->objectPrototype(), JSC::TypeInfo(JSC::ObjectType, StructureFlags), JSNodeHTTPServerSocketPrototype::info()); @@ -1089,6 +1190,47 @@ extern "C" void Bun__NodeHTTP__onReadParsed(int ssl, us_socket_t* socket) } } +template +static void halfCloseAfterDrain(us_socket_t* socket) +{ + auto* serverSocket = getNodeHTTPServerSocket(socket); + if (!serverSocket) { + return; + } + auto* httpResponseData = reinterpret_cast*>(us_socket_ext(socket)); + const bool peerEnded = (httpResponseData->state & uWS::HttpResponseData::HTTP_NODE_PEER_ENDED) != 0; + httpResponseData->state |= uWS::HttpResponseData::HTTP_NODE_HALF_CLOSED; + serverSocket->halfClose(serverSocket->globalObject()); + // Both sides have ended, so no later event would close the socket. + if (peerEnded && !serverSocket->isClosed()) { + serverSocket->close(); + } +} + +// The bytes a socket.end() waited for are out (HTTP_NODE_SHUTDOWN_AFTER_DRAIN). The socket can be closed when this returns. +extern "C" void Bun__NodeHTTP__halfCloseAfterDrain(int ssl, us_socket_t* socket) +{ + if (ssl) { + halfCloseAfterDrain(socket); + } else { + halfCloseAfterDrain(socket); + } +} + +extern "C" void Bun__NodeHTTP__addBytesRead(void* socketData, int length) +{ + if (socketData) [[likely]] { + reinterpret_cast(socketData)->bytesRead += length; + } +} + +extern "C" void Bun__NodeHTTP__onRawRead(int ssl, us_socket_t* socket, const char* data, int length) +{ + if (auto* serverSocket = ssl ? getNodeHTTPServerSocket(socket) : getNodeHTTPServerSocket(socket)) { + serverSocket->onRawRead(data, length); + } +} + // Returns the JSNodeHTTPServerSocket already attached to this raw socket, or // creates (and attaches) one. Used for connections that have not produced a // parsed request yet: the 'clientError' path and the connection-accept path. diff --git a/src/jsc/bindings/node/JSNodeHTTPServerSocket.h b/src/jsc/bindings/node/JSNodeHTTPServerSocket.h index be9eff8308ee..cab8d446ef3d 100644 --- a/src/jsc/bindings/node/JSNodeHTTPServerSocket.h +++ b/src/jsc/bindings/node/JSNodeHTTPServerSocket.h @@ -68,6 +68,8 @@ class JSNodeHTTPServerSocket : public JSC::JSDestructibleObject { int closeReadError = 0; /* Whether the connection closed before the kernel had taken everything, also after an upgrade to a WebSocket. */ std::optional closedWithUnsentData; + /* socket.bytesRead: bytes read from the connection, after TLS decryption. */ + uint64_t bytesRead = 0; /* Tunnel bytes that onData() queued for JS in tasks that have not run yet. */ size_t queuedTunnelBytes = 0; const char* peerCertVerifyErrorCode = nullptr; @@ -146,11 +148,14 @@ class JSNodeHTTPServerSocket : public JSC::JSDestructibleObject { * body deliver it through the request first, like Node 26). */ void upgradeToTunnelMode(bool afterBody, JSNodeHTTPResponse* response); - /* Tunnel read backpressure, like net.Socket's handle. Both do nothing outside tunnel mode. */ + /* Read backpressure, like net.Socket's handle. Both do nothing unless ondata gets the reads: tunnel mode, or setRawReads(). */ void readStop(); void readStart(); bool tunnelReadsPaused() const { return tunnelReadsStopped || tunnelReadsQueuedFull; } - /* Tells uWS whether this tunnel is idle: at read EOF with nothing left to send. See HttpResponse::setNodeHttpTunnelIdle. */ + /* Outside tunnel mode: ondata gets the bytes of every read before the HTTP parser does (Node's parser.unconsume()). A tunnel turns it off. */ + void setRawReads(bool enabled); + void onRawRead(const char* data, int length); + /* Tells uWS whether this tunnel is idle: not reading (readStop() or read EOF) with nothing left to send. Also un-parks a request when a raw write leaves bytes to send. See HttpResponse::setNodeHttpTunnelIdle. */ void updateTunnelIdle(); /* uWS still holds bytes of an HTTP response on this connection. A raw write has to go through the same buffer, or it reaches the wire first. */ bool hasUnsentResponseBytes() const; diff --git a/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp b/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp index 13546415dab7..54b10ad26bdf 100644 --- a/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp +++ b/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp @@ -13,6 +13,7 @@ extern "C" EncodedJSValue us_socket_buffered_js_write(void* socket, bool is_ssl, extern "C" uint64_t uws_res_get_remote_address_info(void* res, const char** dest, int* port, bool* is_ipv6); extern "C" uint64_t uws_res_get_local_address_info(void* res, const char** dest, int* port, bool* is_ipv6); extern "C" void us_socket_shutdown(us_socket_t*); +extern "C" int us_socket_keepalive(us_socket_t*, int enabled, unsigned int delay); extern "C" JSC::EncodedJSValue Bun__socketReadErrorFromCloseCode(JSC::JSGlobalObject* globalObject, int code); namespace Bun { @@ -39,6 +40,7 @@ JSC_DECLARE_CUSTOM_SETTER(jsNodeHttpServerSocketSetterOnDrain); JSC_DECLARE_CUSTOM_SETTER(jsNodeHttpServerSocketSetterOnData); JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterOnData); JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterBytesWritten); +JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterBytesRead); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketClose); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketReset); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketWrite); @@ -46,10 +48,12 @@ JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketEnd); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketUpgradeToTunnel); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketReadStop); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketReadStart); +JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketSetRawReads); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketSetResponseTrailers); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketIsRequestTimedOut); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketStartPipelinedResponse); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketStopParsing); +JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketSetKeepAlive); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketCloseWhenDrained); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketCloseIfIdle); JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterResponse); @@ -80,6 +84,7 @@ static const JSC::HashTableValue JSNodeHTTPServerSocketPrototypeTableValues[] = { "ondrain"_s, static_cast(JSC::PropertyAttribute::CustomAccessor), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterOnDrain, jsNodeHttpServerSocketSetterOnDrain } }, { "ondata"_s, static_cast(JSC::PropertyAttribute::CustomAccessor), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterOnData, jsNodeHttpServerSocketSetterOnData } }, { "bytesWritten"_s, static_cast(JSC::PropertyAttribute::CustomAccessor), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterBytesWritten, noOpSetter } }, + { "bytesRead"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterBytesRead, noOpSetter } }, { "closed"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterClosed, noOpSetter } }, { "response"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterResponse, noOpSetter } }, { "duplex"_s, static_cast(JSC::PropertyAttribute::CustomAccessor), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterDuplex, jsNodeHttpServerSocketSetterDuplex } }, @@ -92,10 +97,12 @@ static const JSC::HashTableValue JSNodeHTTPServerSocketPrototypeTableValues[] = { "upgradeToTunnel"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketUpgradeToTunnel, 0 } }, { "readStop"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketReadStop, 0 } }, { "readStart"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketReadStart, 0 } }, + { "setRawReads"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketSetRawReads, 1 } }, { "setResponseTrailers"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketSetResponseTrailers, 1 } }, { "isRequestTimedOut"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketIsRequestTimedOut, 2 } }, { "startPipelinedResponse"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketStartPipelinedResponse, 3 } }, { "stopParsing"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketStopParsing, 0 } }, + { "setKeepAlive"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketSetKeepAlive, 2 } }, { "closeWhenDrained"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketCloseWhenDrained, 0 } }, { "closeIfIdle"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketCloseIfIdle, 0 } }, { "secureEstablished"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterIsSecureEstablished, noOpSetter } }, @@ -172,6 +179,14 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketReadStart, (JSC::JSGlobal return JSValue::encode(JSC::jsUndefined()); } +JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketSetRawReads, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + if (auto* thisObject = dynamicDowncast(callFrame->thisValue())) [[likely]] { + thisObject->setRawReads(callFrame->argument(0).isTrue()); + } + return JSValue::encode(JSC::jsUndefined()); +} + // node:http: set the trailer fields (pre-rendered "name: value\r\n" lines) to send // at the end of the current response's chunked body (response.addTrailers()). JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketSetResponseTrailers, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) @@ -266,6 +281,18 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketCloseIfIdle, (JSC::JSGlob return JSValue::encode(JSC::jsBoolean(thisObject && thisObject->closeIfIdle())); } +// The delay is in seconds. Anything but a positive int32 keeps the kernel's idle time. +JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketSetKeepAlive, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + auto* thisObject = dynamicDowncast(callFrame->thisValue()); + if (!thisObject || thisObject->isClosed()) [[unlikely]] { + return JSValue::encode(JSC::jsUndefined()); + } + JSValue delay = callFrame->argument(1); + us_socket_keepalive(thisObject->socket, callFrame->argument(0).toBoolean(globalObject), delay.isUInt32() ? delay.asUInt32() : 0); + return JSValue::encode(JSC::jsUndefined()); +} + JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketWrite, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { auto* thisObject = dynamicDowncast(callFrame->thisValue()); @@ -651,6 +678,15 @@ JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterBytesWritten, (JSC::JSGloba return JSValue::encode(JSC::jsNumber(thisObject->streamBuffer.totalBytesWritten() + thisObject->streamBuffer.bufferedSize())); } +JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterBytesRead, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName propertyName)) +{ + auto* thisObject = dynamicDowncast(JSC::JSValue::decode(thisValue)); + if (!thisObject) [[unlikely]] { + return JSValue::encode(JSC::jsUndefined()); + } + return JSValue::encode(JSC::jsNumber(thisObject->bytesRead)); +} + JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterResponse, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName propertyName)) { auto* thisObject = dynamicDowncast(JSC::JSValue::decode(thisValue)); diff --git a/src/jsc/bindings/node/http/JSConnectionsListPrototype.cpp b/src/jsc/bindings/node/http/JSConnectionsListPrototype.cpp index 50d6f6e71859..bc7c82f5f78f 100644 --- a/src/jsc/bindings/node/http/JSConnectionsListPrototype.cpp +++ b/src/jsc/bindings/node/http/JSConnectionsListPrototype.cpp @@ -90,9 +90,9 @@ JSC_DEFINE_HOST_FUNCTION(jsConnectionsList_expired, (JSGlobalObject * globalObje JSValue headersTimeoutValue = callFrame->argument(0); JSValue requestTimeoutValue = callFrame->argument(1); - uint64_t headersTimeout = headersTimeoutValue.toUInt32(globalObject) * 1000000; + uint64_t headersTimeout = static_cast(headersTimeoutValue.toUInt32(globalObject)) * 1000000; RETURN_IF_EXCEPTION(scope, {}); - uint64_t requestTimeout = requestTimeoutValue.toUInt32(globalObject) * 1000000; + uint64_t requestTimeout = static_cast(requestTimeoutValue.toUInt32(globalObject)) * 1000000; RETURN_IF_EXCEPTION(scope, {}); if (headersTimeout == 0 && requestTimeout == 0) { diff --git a/src/jsc/bindings/webcore/AbortSignal.h b/src/jsc/bindings/webcore/AbortSignal.h index 2a9260542033..c2b595ab36a8 100644 --- a/src/jsc/bindings/webcore/AbortSignal.h +++ b/src/jsc/bindings/webcore/AbortSignal.h @@ -54,6 +54,7 @@ enum class CommonAbortReason : uint8_t { Timeout, UserAbort, ConnectionClosed, + RequestBodyTooLarge, }; JSC::JSValue toJS(JSC::JSGlobalObject*, CommonAbortReason); diff --git a/src/jsc/bindings/webcore/FetchHeaders.h b/src/jsc/bindings/webcore/FetchHeaders.h index 4d8c1267fc61..8ee13e70e2d1 100644 --- a/src/jsc/bindings/webcore/FetchHeaders.h +++ b/src/jsc/bindings/webcore/FetchHeaders.h @@ -133,6 +133,10 @@ class FetchHeaders : public RefCounted { void setGuard(Guard); Guard guard() const { return m_guard; } + // True if the list was created only to hold its body's Content-Type and script hasn't seen it. + bool isImpliedByBody() const { return m_impliedByBody; } + void setImpliedByBody(bool implied) { m_impliedByBody = implied; } + FetchHeaders(Guard, HTTPHeaderMap&&); explicit FetchHeaders(const FetchHeaders&); @@ -159,6 +163,7 @@ class FetchHeaders : public RefCounted { }; Guard m_guard; + bool m_impliedByBody { false }; // How often fastRemove() ran. An iterator compares it with the counter to tell that nothing else changed the map. uint16_t m_nativeRemovals { 0 }; CountedHeaderMap m_headers; diff --git a/src/jsc/bindings/webcore/JSFetchHeaders.cpp b/src/jsc/bindings/webcore/JSFetchHeaders.cpp index 9ce9fd33a954..207b26bb0255 100644 --- a/src/jsc/bindings/webcore/JSFetchHeaders.cpp +++ b/src/jsc/bindings/webcore/JSFetchHeaders.cpp @@ -21,6 +21,7 @@ #include "config.h" #include "JSFetchHeaders.h" +#include "ErrorCode.h" #include "ExtendedDOMClientIsoSubspaces.h" #include "ExtendedDOMIsoSubspaces.h" #include "IDLTypes.h" @@ -133,15 +134,17 @@ template<> JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSFetchHeadersDOMConstru auto init = std::optional>, IDLRecord>>::ReturnType>(); if (argument0.value() && !argument0.value().isUndefined()) { - if (auto* existingJsFetchHeaders = dynamicDowncast(argument0.value())) { + if (auto* existingHeaders = JSFetchHeaders::toWrappedAsInit(argument0.value())) { auto* structure = structureForNewTarget(lexicalGlobalObject, callFrame); RETURN_IF_EXCEPTION(throwScope, {}); - auto newHeaders = FetchHeaders::create(existingJsFetchHeaders->wrapped()); + auto newHeaders = FetchHeaders::create(*existingHeaders); auto jsValue = toJSNewlyCreated>(*lexicalGlobalObject, *castedThis->globalObject(), throwScope, structure, WTF::move(newHeaders)); if constexpr (IsExceptionOr) RETURN_IF_EXCEPTION(throwScope, {}); return JSValue::encode(jsValue); } + if (!argument0.value().isObject()) + return Bun::ERR::INVALID_ARG_INSTANCE(throwScope, lexicalGlobalObject, "init"_s, "Headers, Array, or Object"_s, argument0.value()); init = std::optional>, IDLRecord>>::ReturnType>(convert>, IDLRecord>>(*lexicalGlobalObject, argument0.value())); } @@ -191,14 +194,10 @@ JSC_DEFINE_CUSTOM_GETTER(jsFetchHeadersGetterCount, (JSC::JSGlobalObject * globa return JSValue::encode(jsNumber(count)); } -JSC_DEFINE_HOST_FUNCTION(jsFetchHeadersPrototypeFunction_getAll, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +static inline JSC::EncodedJSValue jsFetchHeadersPrototypeFunction_getAllBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) { auto& vm = JSC::getVM(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); - JSFetchHeaders* castedThis = dynamicDowncast(callFrame->thisValue()); - if (!castedThis) [[unlikely]] { - return JSValue::encode(jsUndefined()); - } if (!callFrame->argumentCount()) [[unlikely]] { throwTypeError(lexicalGlobalObject, scope, "Missing argument"_s); @@ -257,6 +256,11 @@ JSC_DEFINE_HOST_FUNCTION(jsFetchHeadersPrototypeFunction_getAll, (JSGlobalObject return JSValue::encode(array); } +JSC_DEFINE_HOST_FUNCTION(jsFetchHeadersPrototypeFunction_getAll, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "getAll"); +} + JSC::EncodedJSValue fetchHeadersGetSetCookie(JSC::JSGlobalObject* lexicalGlobalObject, VM& vm, WebCore::FetchHeaders* impl) { auto scope = DECLARE_THROW_SCOPE(vm); @@ -283,15 +287,14 @@ JSC::EncodedJSValue fetchHeadersGetSetCookie(JSC::JSGlobalObject* lexicalGlobalO return JSValue::encode(array); } +static inline JSC::EncodedJSValue jsFetchHeadersPrototypeFunction_getSetCookieBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame*, typename IDLOperation::ClassParameter castedThis) +{ + return fetchHeadersGetSetCookie(lexicalGlobalObject, JSC::getVM(lexicalGlobalObject), &castedThis->wrapped()); +} + JSC_DEFINE_HOST_FUNCTION(jsFetchHeadersPrototypeFunction_getSetCookie, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) { - auto& vm = JSC::getVM(lexicalGlobalObject); - JSFetchHeaders* castedThis = dynamicDowncast(callFrame->thisValue()); - if (!castedThis) [[unlikely]] { - return JSValue::encode(jsUndefined()); - } - auto& impl = castedThis->wrapped(); - return fetchHeadersGetSetCookie(lexicalGlobalObject, vm, &impl); + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "getSetCookie"); } /* Hash table for prototype */ @@ -749,4 +752,45 @@ FetchHeaders* JSFetchHeaders::toWrapped(JSC::VM& vm, JSC::JSValue value) return &wrapper->wrapped(); return nullptr; } + +FetchHeaders* JSFetchHeaders::toWrappedAsInit(JSC::JSValue value) +{ + auto* wrapper = dynamicDowncast(value); + if (!wrapper) + return nullptr; + + // Looks up Symbol.iterator without running user code. + auto& vm = wrapper->vm(); + for (JSObject* object = wrapper;;) { + // A Proxy in the chain needs a real [[Get]], which the generic conversion does. + if (TypeInfo::overridesGetOwnPropertySlot(object->inlineTypeFlags()) || TypeInfo::hasStaticPropertyTable(object->inlineTypeFlags()) || object->structure()->typeInfo().overridesGetPrototype()) + return nullptr; + if (JSValue method = object->getDirect(vm, vm.propertyNames->iteratorSymbol)) { + // An accessor is a GetterSetter cell here, so it fails the cast. + auto* function = dynamicDowncast(method); + if (!function || !function->isHostFunction() || function->nativeFunction() != TaggedNativeFunction(jsFetchHeadersPrototypeFunction_entries)) + return nullptr; + return &wrapper->wrapped(); + } + JSValue prototype = object->getPrototypeDirect(); + if (!prototype.isObject()) + return nullptr; + object = asObject(prototype); + } +} +} + +extern "C" WebCore::FetchHeaders* WebCore__FetchHeaders__castAsInit(JSC::EncodedJSValue value) +{ + return WebCore::JSFetchHeaders::toWrappedAsInit(JSC::JSValue::decode(value)); +} + +extern "C" void WebCore__FetchHeaders__setImpliedByBody(WebCore::FetchHeaders* headers, bool implied) +{ + headers->setImpliedByBody(implied); +} + +extern "C" bool WebCore__FetchHeaders__isImpliedByBody(WebCore::FetchHeaders* headers) +{ + return headers->isImpliedByBody(); } diff --git a/src/jsc/bindings/webcore/JSFetchHeaders.h b/src/jsc/bindings/webcore/JSFetchHeaders.h index 19cd9ffb179b..b50f85f86fc2 100644 --- a/src/jsc/bindings/webcore/JSFetchHeaders.h +++ b/src/jsc/bindings/webcore/JSFetchHeaders.h @@ -38,6 +38,8 @@ class JSFetchHeaders : public JSDOMWrapper { static JSC::JSObject* createPrototype(JSC::VM&, JSDOMGlobalObject&); static JSC::JSObject* prototype(JSC::VM&, JSDOMGlobalObject&); static FetchHeaders* toWrapped(JSC::VM&, JSC::JSValue); + // toWrapped() for a HeadersInit. Returns null if Symbol.iterator isn't Headers.prototype.entries, so Web IDL converts through it. + static FetchHeaders* toWrappedAsInit(JSC::JSValue); static void destroy(JSC::JSCell*); DECLARE_INFO; diff --git a/src/jsc/bindings/webcore/JSWebSocket.cpp b/src/jsc/bindings/webcore/JSWebSocket.cpp index 4415adb8d938..cf56a0fd86e0 100644 --- a/src/jsc/bindings/webcore/JSWebSocket.cpp +++ b/src/jsc/bindings/webcore/JSWebSocket.cpp @@ -261,9 +261,9 @@ static void convertOptions(JSGlobalObject* lexicalGlobalObject, JSObject* object JSValue proxyHeadersValue = get(proxy, builtinNames.headersPublicName()); RETURN_IF_EXCEPTION(throwScope, ); - if (auto* jsHeaders = dynamicDowncast(proxyHeadersValue)) { + if (auto* headers = JSFetchHeaders::toWrappedAsInit(proxyHeadersValue)) { Vector> pairs; - auto iterator = jsHeaders->wrapped().createIterator(false); + auto iterator = headers->createIterator(false); while (auto value = iterator.next()) { pairs.append({ value->key, value->value }); } diff --git a/src/jsc/bindings/webcore/streams/BunStandaloneTextSink.h b/src/jsc/bindings/webcore/streams/BunStandaloneTextSink.h index 7a2e04544d19..1f6c4ef189b0 100644 --- a/src/jsc/bindings/webcore/streams/BunStandaloneTextSink.h +++ b/src/jsc/bindings/webcore/streams/BunStandaloneTextSink.h @@ -2,7 +2,7 @@ // `convertChunksToText` (BunStreamConsumers.cpp) allocates ONE of these and drives the // shared accumulator through it (the cell is the GC owner of the accumulated chunk // barriers). It is deliberately DISTINCT from `JSDirectStreamController`'s Text arm (the -// two have different BOM behaviors); the accumulation LOGIC is shared through the ONE +// two treat a BOM in a string chunk differently); the accumulation LOGIC is shared through the ONE // `BunTextAccumulator` value type below — "one implementation, two owners". // Internal cell: no prototype, no constructor, never exposed to JS. // DESTRUCTIBLE: the accumulator owns a WTF::StringBuilder + a WTF::Vector of barriers. diff --git a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp index 782980b46cdc..d487261a320e 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp @@ -236,8 +236,7 @@ WTF::String withoutUTF8BOM(const WTF::String& string) return string; } -// The generic toText result strip: the accumulator's rope-path strip followed by the -// end()-path strip of the sink pump this replaced (so "\uFEFF\uFEFF..." loses both). +// Strips twice, like the accumulator's rope path followed by the end() path of the sink pump this replaced. static WTF::String stripTextResultBOM(const WTF::String& string) { return withoutUTF8BOM(withoutUTF8BOM(string)); @@ -727,8 +726,7 @@ static JSValue textAccumulatorWrite(JSC::VM& vm, JSGlobalObject* globalObject, J return jsNumber(static_cast(byteLength)); } -// createTextStream.finishInternal (RSI:1463-1501). Does NOT strip the leading UTF-8 BOM on -// the buffer / mixed paths (only the pure-string rope path strips it) — see withoutUTF8BOM. +// createTextStream.finishInternal (RSI:1463-1501). Only the pure-string rope path strips the leading BOM. See withoutUTF8BOM. static WTF::String finishTextAccumulator(JSC::VM& vm, JSGlobalObject* globalObject, JSC::JSObject* owner, BunTextAccumulator& accumulator) { auto scope = DECLARE_THROW_SCOPE(vm); @@ -859,13 +857,13 @@ static JSValue intoArrayLoop(JSC::VM& vm, JSGlobalObject* globalObject, WebCore: } } -JSValue readableStreamIntoArray(JSGlobalObject* globalObject, WebCore::JSReadableStream* stream) +JSValue readableStreamIntoArray(JSGlobalObject* globalObject, WebCore::JSReadableStream* stream, ReaderOwner owner) { auto& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); stream->materializeIfNeeded(globalObject); RETURN_IF_EXCEPTION(scope, {}); - auto* reader = acquireReadableStreamDefaultReader(globalObject, stream); + auto* reader = acquireReadableStreamDefaultReader(globalObject, stream, owner); RETURN_IF_EXCEPTION(scope, {}); auto* chunks = constructEmptyArray(globalObject, nullptr); RETURN_IF_EXCEPTION(scope, {}); @@ -914,7 +912,7 @@ JSValue readableStreamIntoText(JSGlobalObject* globalObject, WebCore::JSReadable { auto& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - JSValue arrayResult = readableStreamIntoArray(globalObject, stream); + JSValue arrayResult = readableStreamIntoArray(globalObject, stream, ReaderOwner::Native); RETURN_IF_EXCEPTION(scope, {}); RELEASE_AND_RETURN(scope, convertChunkArrayPromise(vm, globalObject, arrayResult, ChunkArrayConversion::Text)); } @@ -1001,7 +999,7 @@ static JSValue consumeDirectStreamBody(JSC::VM& vm, JSGlobalObject* globalObject controller->m_closeOnPullSettled = true; stream->materializeIfNeeded(globalObject); RETURN_IF_EXCEPTION(scope, {}); - auto* reader = acquireReadableStreamDefaultReader(globalObject, stream); + auto* reader = acquireReadableStreamDefaultReader(globalObject, stream, ReaderOwner::Native); RETURN_IF_EXCEPTION(scope, {}); auto* context = InternalFieldTuple::create(vm, defaultGlobalObject(globalObject)->internalFieldTupleStructure(), stream, reader); RELEASE_AND_RETURN(scope, directConsumeLoopStep(vm, globalObject, context)); @@ -1170,7 +1168,7 @@ JSValue readableStreamToText(JSGlobalObject* globalObject, WebCore::JSReadableSt RELEASE_AND_RETURN(scope, readableStreamIntoText(globalObject, stream)); } -JSValue readableStreamToArray(JSGlobalObject* globalObject, WebCore::JSReadableStream* stream) +JSValue readableStreamToArray(JSGlobalObject* globalObject, WebCore::JSReadableStream* stream, ReaderOwner owner) { auto& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); @@ -1178,7 +1176,7 @@ JSValue readableStreamToArray(JSGlobalObject* globalObject, WebCore::JSReadableS RELEASE_AND_RETURN(scope, readableStreamToArrayDirect(globalObject, stream)); if (auto* error = unusableStreamError(globalObject, stream)) RELEASE_AND_RETURN(scope, promiseRejectedWith(globalObject, error)); - RELEASE_AND_RETURN(scope, readableStreamIntoArray(globalObject, stream)); + RELEASE_AND_RETURN(scope, readableStreamIntoArray(globalObject, stream, owner)); } // The chunk arrays these conversions consume are built by the array pump and never escape @@ -1257,7 +1255,7 @@ JSValue readableStreamToArrayBuffer(JSGlobalObject* globalObject, WebCore::JSRea RETURN_IF_EXCEPTION(scope, {}); if (fastPath) return fastPath; - JSValue arrayResult = readableStreamToArray(globalObject, stream); + JSValue arrayResult = readableStreamToArray(globalObject, stream, ReaderOwner::Native); RETURN_IF_EXCEPTION(scope, {}); RELEASE_AND_RETURN(scope, convertChunkArrayPromise(vm, globalObject, arrayResult, ChunkArrayConversion::ArrayBuffer)); } @@ -1274,7 +1272,7 @@ JSValue readableStreamToBytes(JSGlobalObject* globalObject, WebCore::JSReadableS RETURN_IF_EXCEPTION(scope, {}); if (fastPath) return fastPath; - JSValue arrayResult = readableStreamToArray(globalObject, stream); + JSValue arrayResult = readableStreamToArray(globalObject, stream, ReaderOwner::Native); RETURN_IF_EXCEPTION(scope, {}); RELEASE_AND_RETURN(scope, convertChunkArrayPromise(vm, globalObject, arrayResult, ChunkArrayConversion::Bytes)); } @@ -1323,7 +1321,7 @@ JSValue readableStreamToBlob(JSGlobalObject* globalObject, WebCore::JSReadableSt RETURN_IF_EXCEPTION(scope, {}); if (fastPath) return fastPath; - JSValue arrayResult = readableStreamToArray(globalObject, stream); + JSValue arrayResult = readableStreamToArray(globalObject, stream, ReaderOwner::Native); RETURN_IF_EXCEPTION(scope, {}); auto* arrayPromise = dynamicDowncast(arrayResult); if (!arrayPromise) [[unlikely]] { @@ -1384,7 +1382,7 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionReadableStreamToArray, (JSGlobalObject * glob auto* stream = dynamicDowncast(streamValue); if (!stream) [[unlikely]] return Bun::ERR::INVALID_ARG_TYPE(scope, globalObject, "stream"_s, "ReadableStream"_s, streamValue); - RELEASE_AND_RETURN(scope, JSValue::encode(Bun::WebStreams::readableStreamToArray(globalObject, stream))); + RELEASE_AND_RETURN(scope, JSValue::encode(Bun::WebStreams::readableStreamToArray(globalObject, stream, ReaderOwner::Script))); } JSC_DEFINE_HOST_FUNCTION(jsFunctionReadableStreamToArrayBuffer, (JSGlobalObject * globalObject, CallFrame* callFrame)) diff --git a/src/jsc/bindings/webcore/streams/BunStreamSource.cpp b/src/jsc/bindings/webcore/streams/BunStreamSource.cpp index 3749ba2be5a6..cf68e0aa4c5f 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamSource.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamSource.cpp @@ -1139,7 +1139,7 @@ static void rsisBegin(JSC::VM& vm, JSGlobalObject* globalObject, JSReadableStrea auto scope = DECLARE_THROW_SCOPE(vm); stream->materializeIfNeeded(globalObject); RETURN_IF_EXCEPTION(scope, ); - auto* reader = acquireReadableStreamDefaultReader(globalObject, stream); + auto* reader = acquireReadableStreamDefaultReader(globalObject, stream, ReaderOwner::Native); RETURN_IF_EXCEPTION(scope, ); // The sink consumes the stream as a body: it stays locked after rsisFinally releases the pump's reader. stream->markConsumedAsBody(); diff --git a/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp b/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp index a8ec19ecf04f..c7ec25c44da9 100644 --- a/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp +++ b/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp @@ -487,7 +487,6 @@ static String finishTextSink(JSC::VM& vm, JSGlobalObject* globalObject, JSDirect return emptyString(); 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) { String rope = accumulator.tryRopeString(); if (rope.isNull()) [[unlikely]] { @@ -553,7 +552,7 @@ static String finishTextSink(JSC::VM& vm, JSGlobalObject* globalObject, JSDirect throwOutOfMemoryError(globalObject, scope); return String(); } - return text; + return Bun::WebStreams::withoutUTF8BOM(text); } static JSValue endTextSink(JSC::VM& vm, JSGlobalObject* globalObject, JSDirectStreamController* controller) @@ -563,8 +562,7 @@ static JSValue endTextSink(JSC::VM& vm, JSGlobalObject* globalObject, JSDirectSt return jsEmptyString(vm); controller->m_calledDone = true; String result = finishTextSink(vm, globalObject, controller); - // The accumulated payload must not stay alive on the controller (it lives as long - // as the stream); the result string owns everything it needs. + // The controller lives as long as the stream, so it must not keep the accumulated chunks alive. { Locker locker { controller->cellLock() }; controller->m_textAccumulator.reset(locker); diff --git a/src/jsc/bindings/webcore/streams/JSDirectStreamController.h b/src/jsc/bindings/webcore/streams/JSDirectStreamController.h index a45ce2bfb0b4..4a925dddc457 100644 --- a/src/jsc/bindings/webcore/streams/JSDirectStreamController.h +++ b/src/jsc/bindings/webcore/streams/JSDirectStreamController.h @@ -137,8 +137,7 @@ class JSDirectStreamController final : public JSC::JSDestructibleObject { // Text sink: the ONE shared createTextStream accumulator value type // (BunStandaloneTextSink.h), also owned by the standalone JSBunStandaloneTextSink — one // implementation, two owners. Its `pieces` barrier container is mutated AND visited - // under THIS cell's cellLock() (see the visit-list comment above). This arm does NOT - // BOM-strip. + // under THIS cell's cellLock() (see the visit-list comment above). Bun::WebStreams::BunTextAccumulator m_textAccumulator; // Array sink. diff --git a/src/jsc/bindings/webcore/streams/JSReadableStream.cpp b/src/jsc/bindings/webcore/streams/JSReadableStream.cpp index 1222dfd927c5..97e9a25fe0c8 100644 --- a/src/jsc/bindings/webcore/streams/JSReadableStream.cpp +++ b/src/jsc/bindings/webcore/streams/JSReadableStream.cpp @@ -638,7 +638,7 @@ JSC_DEFINE_HOST_FUNCTION(jsReadableStreamPrototypeFunction_getReader, (JSGlobalO stream->materializeIfNeeded(lexicalGlobalObject); RETURN_IF_EXCEPTION(scope, {}); - auto* reader = acquireReadableStreamDefaultReader(lexicalGlobalObject, stream); + auto* reader = acquireReadableStreamDefaultReader(lexicalGlobalObject, stream, ReaderOwner::Script); RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(reader); } @@ -712,7 +712,7 @@ JSC_DEFINE_HOST_FUNCTION(jsReadableStreamPrototypeFunction_tee, (JSGlobalObject auto* stream = dynamicDowncast(callFrame->thisValue()); if (!stream) [[unlikely]] return Bun::ERR::INVALID_THIS(scope, lexicalGlobalObject, "ReadableStream"_s); - auto branches = readableStreamTee(lexicalGlobalObject, stream); + auto branches = readableStreamTee(lexicalGlobalObject, stream, /* cloneForBranch2 */ false); RETURN_IF_EXCEPTION(scope, {}); auto* array = constructEmptyArray(lexicalGlobalObject, nullptr, 2); RETURN_IF_EXCEPTION(scope, {}); @@ -748,7 +748,7 @@ JSC_DEFINE_HOST_FUNCTION(jsReadableStreamPrototypeFunction_values, (JSGlobalObje auto* domGlobalObject = defaultGlobalObject(lexicalGlobalObject); auto* iterator = JSReadableStreamAsyncIterator::create(vm, getDOMStructure(vm, *domGlobalObject)); - auto* reader = acquireReadableStreamDefaultReader(lexicalGlobalObject, stream); + auto* reader = acquireReadableStreamDefaultReader(lexicalGlobalObject, stream, ReaderOwner::Script); RETURN_IF_EXCEPTION(scope, {}); iterator->m_reader.set(vm, iterator, reader); iterator->m_preventCancel = preventCancel; diff --git a/src/jsc/bindings/webcore/streams/JSReadableStream.h b/src/jsc/bindings/webcore/streams/JSReadableStream.h index 188d9acbedba..d72bebef7986 100644 --- a/src/jsc/bindings/webcore/streams/JSReadableStream.h +++ b/src/jsc/bindings/webcore/streams/JSReadableStream.h @@ -66,6 +66,8 @@ class JSReadableStream final : public JSC::JSNonFinalObject { // Body.textStream(): the native source adapter decodes each chunk as UTF-8 // text before enqueue. bool m_nativeTextMode : 1 { false }; + // Set once a default reader is acquired with ReaderOwner::Native. Never cleared. + bool m_nonScriptConsumer : 1 { false }; // [[reader]] — a default reader, a BYOB reader, or null (undefined). JSC::WriteBarrier m_reader; diff --git a/src/jsc/bindings/webcore/streams/JSReadableStreamDefaultController.cpp b/src/jsc/bindings/webcore/streams/JSReadableStreamDefaultController.cpp index 8c0335dd053c..edbf0915d8b9 100644 --- a/src/jsc/bindings/webcore/streams/JSReadableStreamDefaultController.cpp +++ b/src/jsc/bindings/webcore/streams/JSReadableStreamDefaultController.cpp @@ -306,11 +306,19 @@ JSValue JSReadableStreamDefaultController::dequeueChunkForRead(JSGlobalObject* g { auto scope = DECLARE_THROW_SCOPE(getVM(globalObject)); ASSERT(!m_queue.isEmpty()); + // Copy before dequeuing, so a failed allocation leaves the chunk in the queue. + JSValue copy; + if (teeBranchCopiesChunks(this)) [[unlikely]] { + copy = copyByteChunk(globalObject, m_queue.first().value.get()); + RETURN_IF_EXCEPTION(scope, {}); + } JSValue chunk; { WTF::Locker locker { cellLock() }; chunk = m_queue.dequeueValue(locker); } + if (copy) + chunk = copy; if (m_closeRequested && m_queue.isEmpty()) { readableStreamDefaultControllerClearAlgorithms(this); readableStreamClose(globalObject, m_stream.get()); diff --git a/src/jsc/bindings/webcore/streams/JSReadableStreamDefaultReader.cpp b/src/jsc/bindings/webcore/streams/JSReadableStreamDefaultReader.cpp index 74b2d0d5d025..4e70681fd6fa 100644 --- a/src/jsc/bindings/webcore/streams/JSReadableStreamDefaultReader.cpp +++ b/src/jsc/bindings/webcore/streams/JSReadableStreamDefaultReader.cpp @@ -192,6 +192,7 @@ static double drainQueueEntriesInto(JSC::VM& vm, JSGlobalObject* globalObject, J double size = isByte ? byteController->m_queue.totalSize() : defaultController->m_queue.totalSize(); size_t queueLength = isByte ? byteController->m_queue.size() : defaultController->m_queue.size(); // [[queueTotalSize]] is deliberately NOT decremented while draining (see above). + const bool copiesChunks = !isByte && teeBranchCopiesChunks(defaultController); for (unsigned i = 0; i < queueLength; ++i) { JSValue chunk; if (isByte) { @@ -210,10 +211,16 @@ static double drainQueueEntriesInto(JSC::VM& vm, JSGlobalObject* globalObject, J chunk = JSUint8Array::create(globalObject, globalObject->typedArrayStructure(TypeUint8, resizable), WTF::move(buffer), byteOffset, byteLength); RETURN_IF_EXCEPTION(scope, size); } else { - WTF::Locker locker { defaultController->cellLock() }; - auto& entry = defaultController->m_queue.first(); - chunk = entry.value.get(); - defaultController->m_queue.removeFirst(locker); + { + WTF::Locker locker { defaultController->cellLock() }; + auto& entry = defaultController->m_queue.first(); + chunk = entry.value.get(); + defaultController->m_queue.removeFirst(locker); + } + if (copiesChunks) [[unlikely]] { + chunk = copyByteChunk(globalObject, chunk); + RETURN_IF_EXCEPTION(scope, size); + } } into->putDirectIndex(globalObject, base + i, chunk); RETURN_IF_EXCEPTION(scope, size); diff --git a/src/jsc/bindings/webcore/streams/JSStreamTeeState.h b/src/jsc/bindings/webcore/streams/JSStreamTeeState.h index a6e6a9c3a98c..4921fca75923 100644 --- a/src/jsc/bindings/webcore/streams/JSStreamTeeState.h +++ b/src/jsc/bindings/webcore/streams/JSStreamTeeState.h @@ -80,6 +80,8 @@ class JSStreamTeeState final : public JSC::JSInternalFieldObjectImpl<7> { // `canceled1` / `canceled2` bool m_canceled1 : 1 { false }; bool m_canceled2 : 1 { false }; + // Set by a body clone. Both queues hold the same chunk, and a branch copies it when script reads it (teeBranchCopiesChunks). + bool m_copyChunksForScript : 1 { false }; private: JSStreamTeeState(JSC::VM&, JSC::Structure*); diff --git a/src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp b/src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp index 3bd114817aff..6ca0c46d2010 100644 --- a/src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp +++ b/src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp @@ -630,7 +630,7 @@ void setUpReadableStreamBYOBReader(JSGlobalObject* globalObject, JSReadableStrea } // AcquireReadableStreamDefaultReader(stream) -JSReadableStreamDefaultReader* acquireReadableStreamDefaultReader(JSGlobalObject* globalObject, JSReadableStream* stream) +JSReadableStreamDefaultReader* acquireReadableStreamDefaultReader(JSGlobalObject* globalObject, JSReadableStream* stream, ReaderOwner owner) { auto& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); @@ -638,6 +638,8 @@ JSReadableStreamDefaultReader* acquireReadableStreamDefaultReader(JSGlobalObject auto* reader = JSReadableStreamDefaultReader::create(vm, WebCore::getDOMStructure(vm, *domGlobalObject)); setUpReadableStreamDefaultReader(globalObject, reader, stream); RETURN_IF_EXCEPTION(scope, nullptr); + if (owner == ReaderOwner::Native) + stream->m_nonScriptConsumer = true; return reader; } @@ -1000,7 +1002,7 @@ JSReadableStream* readableStreamTextDecodeFrom(JSGlobalObject* globalObject, JSR source->materializeIfNeeded(globalObject); RETURN_IF_EXCEPTION(scope, nullptr); - auto* reader = acquireReadableStreamDefaultReader(globalObject, source); + auto* reader = acquireReadableStreamDefaultReader(globalObject, source, ReaderOwner::Native); RETURN_IF_EXCEPTION(scope, nullptr); auto* stream = JSReadableStream::create(vm, WebCore::getDOMStructure(vm, *domGlobalObject)); @@ -1224,6 +1226,24 @@ static EncodedJSValue teeAbortWithError(JSGlobalObject* globalObject, JSStreamTe return JSValue::encode(jsUndefined()); } +bool teeBranchCopiesChunks(JSReadableStreamDefaultController* controller) +{ + if (controller->m_algorithms.kind != SourceKind::TeeBranch) + return false; + // A canceled sibling doesn't matter: in a clone chain every upstream tee's queue holds the same chunk. + auto* teeState = uncheckedDowncast(controller->m_algorithms.algorithmContext.get()); + return teeState->m_copyChunksForScript && !controller->m_stream->m_nonScriptConsumer; +} + +// Enqueue hands the chunk straight to a waiting read request and skips the queue, so the copy has to happen here. +static JSValue teeBranchChunkToEnqueue(JSGlobalObject* globalObject, JSReadableStreamDefaultController* controller, JSValue chunk) +{ + JSReadableStream* branch = controller->m_stream.get(); + if (!teeBranchCopiesChunks(controller) || !readableStreamHasDefaultReader(branch) || !readableStreamGetNumReadRequests(branch)) + return chunk; + return copyByteChunk(globalObject, chunk); +} + // The default-tee read request's chunk steps run as a microtask // (onDefaultTeeReadChunkMicrotask). Each canceled flag is re-read live, as the spec does. static EncodedJSValue defaultTeeChunkStepsMicrotask(JSGlobalObject* globalObject, JSValue chunk, JSStreamTeeState* teeState) @@ -1233,10 +1253,16 @@ static EncodedJSValue defaultTeeChunkStepsMicrotask(JSGlobalObject* globalObject teeState->m_readAgain1 = false; auto* controller1 = teeBranchDefaultController(teeState->branch1()); auto* controller2 = teeBranchDefaultController(teeState->branch2()); - if (!teeState->m_canceled1 && controller1) - readableStreamDefaultControllerEnqueue(globalObject, controller1, chunk); - if (!scope.exception() && !teeState->m_canceled2 && controller2) - readableStreamDefaultControllerEnqueue(globalObject, controller2, chunk); + if (!teeState->m_canceled1 && controller1) { + JSValue chunk1 = teeBranchChunkToEnqueue(globalObject, controller1, chunk); + if (!scope.exception()) + readableStreamDefaultControllerEnqueue(globalObject, controller1, chunk1); + } + if (!scope.exception() && !teeState->m_canceled2 && controller2) { + JSValue chunk2 = teeBranchChunkToEnqueue(globalObject, controller2, chunk); + if (!scope.exception()) + readableStreamDefaultControllerEnqueue(globalObject, controller2, chunk2); + } if (JSC::Exception* exception = scope.exception()) [[unlikely]] { TRY_CLEAR_EXCEPTION(scope, {}); RELEASE_AND_RETURN(scope, teeAbortWithError(globalObject, teeState, controller1, controller2, exception->value(), readableStreamDefaultControllerError)); @@ -1269,8 +1295,8 @@ static EncodedJSValue defaultTeeReaderClosedRejected(JSGlobalObject* globalObjec return JSValue::encode(jsUndefined()); } -// ReadableStreamDefaultTee(stream, cloneForBranch2 = false) -std::pair readableStreamDefaultTee(JSGlobalObject* globalObject, JSReadableStream* stream) +// ReadableStreamDefaultTee(stream, cloneForBranch2) +std::pair readableStreamDefaultTee(JSGlobalObject* globalObject, JSReadableStream* stream, bool cloneForBranch2) { auto& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); @@ -1278,13 +1304,18 @@ std::pair readableStreamDefaultTee(JSGloba auto* domGlobalObject = defaultGlobalObject(globalObject); std::pair failure { nullptr, nullptr }; - auto* reader = acquireReadableStreamDefaultReader(globalObject, stream); + // A tee of a body-clone branch copies too. Check before the Native acquire below turns copying off for `stream`. + bool copyChunksForScript = cloneForBranch2; + if (auto* sourceController = teeBranchDefaultController(stream); sourceController && teeBranchCopiesChunks(sourceController)) + copyChunksForScript = true; + auto* reader = acquireReadableStreamDefaultReader(globalObject, stream, ReaderOwner::Native); RETURN_IF_EXCEPTION(scope, failure); auto* teeState = WebCore::JSStreamTeeState::create(vm, runtime->teeStateStructure(domGlobalObject)); teeState->setStream(vm, stream); teeState->setReader(vm, reader); teeState->setCancelPromise(vm, JSPromise::create(vm, globalObject->promiseStructure())); + teeState->m_copyChunksForScript = copyChunksForScript; auto* branch1 = createReadableStream(globalObject, SourceKind::TeeBranch, teeState, jsUndefined()); RETURN_IF_EXCEPTION(scope, failure); @@ -1324,7 +1355,7 @@ static void byteTeePullWithDefaultReader(JSC::VM& vm, JSGlobalObject* globalObje ASSERT(byobReader->m_readIntoRequests.isEmpty()); readableStreamBYOBReaderRelease(globalObject, byobReader); RETURN_IF_EXCEPTION(scope, void()); - auto* defaultReader = acquireReadableStreamDefaultReader(globalObject, teeState->stream()); + auto* defaultReader = acquireReadableStreamDefaultReader(globalObject, teeState->stream(), ReaderOwner::Native); RETURN_IF_EXCEPTION(scope, void()); teeState->setReader(vm, defaultReader); byteTeeForwardReaderError(vm, globalObject, teeState, defaultReader); @@ -1511,7 +1542,7 @@ std::pair readableByteStreamTee(JSGlobalOb std::pair failure { nullptr, nullptr }; ASSERT(stream->m_controllerKind == ControllerKind::Byte); - auto* reader = acquireReadableStreamDefaultReader(globalObject, stream); + auto* reader = acquireReadableStreamDefaultReader(globalObject, stream, ReaderOwner::Native); RETURN_IF_EXCEPTION(scope, failure); auto* teeState = WebCore::JSStreamTeeState::create(vm, runtime->teeStateStructure(domGlobalObject)); @@ -1538,8 +1569,8 @@ std::pair readableByteStreamTee(JSGlobalOb return { branch1, branch2 }; } -// ReadableStreamTee(stream, cloneForBranch2 = false) -std::pair readableStreamTee(JSGlobalObject* globalObject, JSReadableStream* stream) +// ReadableStreamTee(stream, cloneForBranch2). The byte tee always copies branch2's chunks. +std::pair readableStreamTee(JSGlobalObject* globalObject, JSReadableStream* stream, bool cloneForBranch2) { auto& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); @@ -1548,7 +1579,7 @@ std::pair readableStreamTee(JSGlobalObject RETURN_IF_EXCEPTION(scope, failure); if (stream->m_controllerKind == ControllerKind::Byte) RELEASE_AND_RETURN(scope, readableByteStreamTee(globalObject, stream)); - RELEASE_AND_RETURN(scope, readableStreamDefaultTee(globalObject, stream)); + RELEASE_AND_RETURN(scope, readableStreamDefaultTee(globalObject, stream, cloneForBranch2)); } // ReadableStreamPipeTo(source, dest, preventClose, preventAbort, preventCancel[, signal]). @@ -1565,7 +1596,7 @@ JSPromise* readableStreamPipeTo(JSGlobalObject* globalObject, JSReadableStream* ASSERT(!isReadableStreamLocked(source)); ASSERT(!isWritableStreamLocked(destination)); - auto* reader = acquireReadableStreamDefaultReader(globalObject, source); + auto* reader = acquireReadableStreamDefaultReader(globalObject, source, ReaderOwner::Script); RETURN_IF_EXCEPTION(scope, nullptr); auto* writer = acquireWritableStreamDefaultWriter(globalObject, destination); RETURN_IF_EXCEPTION(scope, nullptr); diff --git a/src/jsc/bindings/webcore/streams/StreamsForward.h b/src/jsc/bindings/webcore/streams/StreamsForward.h index 2cc8d1108213..515791a62f89 100644 --- a/src/jsc/bindings/webcore/streams/StreamsForward.h +++ b/src/jsc/bindings/webcore/streams/StreamsForward.h @@ -106,6 +106,12 @@ enum class WritableStreamState : uint8_t { // Algorithm kind tags. SourceKind::Direct deliberately DOES NOT EXIST: a Bun `type:"direct"` // stream is a JSDirectStreamController (ControllerKind::Direct), never a spec controller. +// Native readers never hand a chunk object to script, so a body-clone tee branch gives them the shared chunk without a copy. +enum class ReaderOwner : uint8_t { + Script, + Native, +}; + // Which arm runs a readable controller's pull/cancel/start algorithms. No closures are stored. enum class SourceKind : uint8_t { JavaScript, // new ReadableStream({...}) — user underlyingSource (underlyingObject + methods) diff --git a/src/jsc/bindings/webcore/streams/WebStreamsExports.cpp b/src/jsc/bindings/webcore/streams/WebStreamsExports.cpp index 470f890bc075..95b6e31ae5e4 100644 --- a/src/jsc/bindings/webcore/streams/WebStreamsExports.cpp +++ b/src/jsc/bindings/webcore/streams/WebStreamsExports.cpp @@ -114,10 +114,8 @@ extern "C" bool ReadableStream__tee(JSC::EncodedJSValue possibleReadableStream, auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - // Body clone tees with cloneForBranch2 = false (share chunk refs) like Node/Chrome/Firefox; - // the spec's per-chunk StructuredClone makes an N-deep clone chain retain N copies of the body - // (whatwg/streams#1156). - auto branches = readableStreamTee(globalObject, stream); + // A branch copies a chunk when script reads it, not at tee time, so an N-deep clone chain keeps one copy of the body. + auto branches = readableStreamTee(globalObject, stream, /* cloneForBranch2 */ true); RETURN_IF_EXCEPTION(scope, false); *possibleReadableStream1 = JSValue::encode(branches.first); @@ -297,7 +295,7 @@ extern "C" JSC::EncodedJSValue ReadableStream__used(Zig::GlobalObject* globalObj readableStreamClose(globalObject, stream); RETURN_IF_EXCEPTION(scope, {}); } - acquireReadableStreamDefaultReader(globalObject, stream); + acquireReadableStreamDefaultReader(globalObject, stream, ReaderOwner::Native); RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(stream); } diff --git a/src/jsc/bindings/webcore/streams/WebStreamsInternals.h b/src/jsc/bindings/webcore/streams/WebStreamsInternals.h index fee997e682fa..20737ecfa0d2 100644 --- a/src/jsc/bindings/webcore/streams/WebStreamsInternals.h +++ b/src/jsc/bindings/webcore/streams/WebStreamsInternals.h @@ -124,7 +124,8 @@ bool canTransferArrayBuffer(JSC::ArrayBuffer&); // userJS: no — WebStreamsMisc // spec CanTransferArrayBuffer(O) — pure. // spec CloneAsUint8Array(O) — allocation-throws only. JSC::JSUint8Array* cloneAsUint8Array(JSC::JSGlobalObject*, JSC::JSArrayBufferView*); // userJS: no — WebStreamsMisc.cpp -// spec StructuredClone(v): no caller — every tee path passes cloneForBranch2 = false. +// Copies an ArrayBuffer, or the bytes a view covers, into a new buffer of the same type. Returns any other chunk as is. +JSC::JSValue copyByteChunk(JSC::JSGlobalObject*, JSC::JSValue chunk); // userJS: no (allocation-throws only) — WebStreamsMisc.cpp // spec CanCopyDataBlockBytes(toBuffer, toIndex, fromBuffer, fromIndex, count) — pure. bool canCopyDataBlockBytes(JSC::ArrayBuffer& toBuffer, size_t toIndex, JSC::ArrayBuffer& fromBuffer, size_t fromIndex, size_t count); // userJS: no — WebStreamsMisc.cpp @@ -273,7 +274,7 @@ void initializeReadableStream(JSReadableStream*); // userJS: no — ReadableStre bool isReadableStreamLocked(JSReadableStream*); // userJS: no (pure; includes Bun's m_lockedWithoutReader / detached-handle states) — ReadableStreamOperations.cpp // Readers. -JSReadableStreamDefaultReader* acquireReadableStreamDefaultReader(JSC::JSGlobalObject*, JSReadableStream*); // userJS: no (throws TypeError if locked) — ReadableStreamOperations.cpp +JSReadableStreamDefaultReader* acquireReadableStreamDefaultReader(JSC::JSGlobalObject*, JSReadableStream*, ReaderOwner); // userJS: no (throws TypeError if locked) — ReadableStreamOperations.cpp JSReadableStreamBYOBReader* acquireReadableStreamBYOBReader(JSC::JSGlobalObject*, JSReadableStream*); // userJS: no (throws TypeError) — ReadableStreamOperations.cpp void setUpReadableStreamDefaultReader(JSC::JSGlobalObject*, JSReadableStreamDefaultReader*, JSReadableStream*); // userJS: no — ReadableStreamOperations.cpp void setUpReadableStreamBYOBReader(JSC::JSGlobalObject*, JSReadableStreamBYOBReader*, JSReadableStream*); // userJS: no — ReadableStreamOperations.cpp @@ -304,10 +305,9 @@ bool readableStreamHasDefaultReader(JSReadableStream*); // userJS: no — Readab bool readableStreamHasBYOBReader(JSReadableStream*); // userJS: no — ReadableStreamOperations.cpp // Tee / from / pipe entry points. -// Bun: the spec's `cloneForBranch2` is not implemented; both branches share each chunk by -// reference (see WebStreamsExports.cpp). ALSO runs materializeIfNeeded first. -std::pair readableStreamTee(JSC::JSGlobalObject*, JSReadableStream*); // userJS: yes — ReadableStreamOperations.cpp -std::pair readableStreamDefaultTee(JSC::JSGlobalObject*, JSReadableStream*); // userJS: yes — ReadableStreamOperations.cpp +// `cloneForBranch2` is true for a body clone and false for tee(). See teeBranchCopiesChunks. ALSO runs materializeIfNeeded first. +std::pair readableStreamTee(JSC::JSGlobalObject*, JSReadableStream*, bool cloneForBranch2); // userJS: yes — ReadableStreamOperations.cpp +std::pair readableStreamDefaultTee(JSC::JSGlobalObject*, JSReadableStream*, bool cloneForBranch2); // userJS: yes — ReadableStreamOperations.cpp std::pair readableByteStreamTee(JSC::JSGlobalObject*, JSReadableStream*); // userJS: yes — ReadableStreamOperations.cpp // spec ReadableStreamFromIterable(asyncIterable) — `ReadableStream.from`. JSReadableStream* readableStreamFromIterable(JSC::JSGlobalObject*, JSC::JSValue asyncIterable); // userJS: yes — ReadableStreamOperations.cpp @@ -361,6 +361,8 @@ void readableStreamDefaultReaderRead(JSC::JSGlobalObject*, JSReadableStreamDefau // against unset branch slots). Callers skip a terminal branch. JSReadableStreamDefaultController* teeBranchDefaultController(JSReadableStream* branch); // userJS: no — ReadableStreamOperations.cpp JSReadableByteStreamController* teeBranchByteController(JSReadableStream* branch); // userJS: no — ReadableStreamOperations.cpp +// Returns true if `controller` is a body-clone tee branch that script reads. It hands out copyByteChunk() of each chunk. +bool teeBranchCopiesChunks(JSReadableStreamDefaultController*); // 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 @@ -642,7 +644,8 @@ void directStreamControllerClearSource(JSDirectStreamController*); // userJS: no // BunStreamConsumers.h. JSC::JSValue readableStreamToText(JSC::JSGlobalObject*, JSReadableStream*); // userJS: yes — BunStreamConsumers.cpp -JSC::JSValue readableStreamToArray(JSC::JSGlobalObject*, JSReadableStream*); // userJS: yes — BunStreamConsumers.cpp +// ReaderOwner::Script only for Bun.readableStreamToArray, whose chunk array reaches user code. +JSC::JSValue readableStreamToArray(JSC::JSGlobalObject*, JSReadableStream*, ReaderOwner); // userJS: yes — BunStreamConsumers.cpp JSC::JSValue readableStreamToArrayBuffer(JSC::JSGlobalObject*, JSReadableStream*); // userJS: yes — BunStreamConsumers.cpp JSC::JSValue readableStreamToBytes(JSC::JSGlobalObject*, JSReadableStream*); // userJS: yes — BunStreamConsumers.cpp JSC::JSValue readableStreamToJSON(JSC::JSGlobalObject*, JSReadableStream*); // userJS: yes — BunStreamConsumers.cpp @@ -659,8 +662,8 @@ JSC::JSValue tryUseReadableStreamBufferedFastPath(JSC::JSGlobalObject*, JSReadab // conversion (BunStreamConsumers.cpp convertChunksToText); BOM-strips its result. JSC::JSValue readableStreamIntoText(JSC::JSGlobalObject*, JSReadableStream*); // userJS: yes — BunStreamConsumers.cpp // toArray's generic path (getReader + readMany until done). -JSC::JSValue readableStreamIntoArray(JSC::JSGlobalObject*, JSReadableStream*); // userJS: yes — BunStreamConsumers.cpp -// Drop ONE leading U+FEFF, and only on the generic toText path. +JSC::JSValue readableStreamIntoArray(JSC::JSGlobalObject*, JSReadableStream*, ReaderOwner); // userJS: yes — BunStreamConsumers.cpp +// Drops one leading U+FEFF. WTF::String withoutUTF8BOM(const WTF::String&); // userJS: no — BunStreamConsumers.cpp // UTF-8 size / write via the simdutf-backed Buffer encoders. Lone surrogates count (and write) as // U+FFFD, so the pair always agrees; plain simdutf::utf8_length_from_utf16 does not. diff --git a/src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp b/src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp index 50807f6a2016..fead88d411c8 100644 --- a/src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp +++ b/src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -236,6 +237,61 @@ JSUint8Array* cloneAsUint8Array(JSGlobalObject* globalObject, JSArrayBufferView* RELEASE_AND_RETURN(scope, JSUint8Array::create(globalObject, globalObject->typedArrayStructure(TypeUint8, false), WTF::move(cloned), 0, byteLength)); } +JSValue copyByteChunk(JSGlobalObject* globalObject, JSValue chunk) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + if (auto* view = dynamicDowncast(chunk)) { + if (view->isShared() || view->isResizableOrGrowableShared() || view->isDetached()) + return chunk; + size_t byteLength = view->byteLength(); + RefPtr copy = ArrayBuffer::tryCreate(view->span()); + if (!copy) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return {}; + } + // Reuse the structure so a Buffer stays a Buffer, unless it has own properties, which a new cell has no storage for. + TypedArrayType type = typedArrayType(view->type()); + Structure* structure = view->structure(); + bool reuseStructure = !structure->outOfLineCapacity() && !structure->isDictionary(); + if (!reuseStructure) + structure = globalObject->typedArrayStructure(type, false); + JSArrayBufferView* result = nullptr; + switch (type) { +#define COPY_TYPED_ARRAY(name) \ + case Type##name: \ + result = JS##name##Array::create(globalObject, structure, WTF::move(copy), 0, byteLength / elementSize(Type##name)); \ + break; + FOR_EACH_TYPED_ARRAY_TYPE_EXCLUDING_DATA_VIEW(COPY_TYPED_ARRAY) +#undef COPY_TYPED_ARRAY + case TypeDataView: + result = JSDataView::create(globalObject, structure, WTF::move(copy), 0, byteLength); + break; + case NotTypedArray: + RELEASE_ASSERT_NOT_REACHED(); + } + RETURN_IF_EXCEPTION(scope, {}); + if (!reuseStructure) { + JSValue prototype = view->getPrototypeDirect(); + if (prototype != result->getPrototypeDirect()) + result->setPrototypeDirect(vm, prototype); + } + return result; + } + if (auto* jsBuffer = dynamicDowncast(chunk)) { + ArrayBuffer* buffer = jsBuffer->impl(); + if (buffer->isShared() || buffer->isResizableOrGrowableShared() || buffer->isDetached()) + return chunk; + RefPtr copy = ArrayBuffer::tryCreate(buffer->span()); + if (!copy) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return {}; + } + return JSArrayBuffer::create(vm, globalObject->arrayBufferStructure(ArrayBufferSharingMode::Default), WTF::move(copy)); + } + return chunk; +} + // spec CanCopyDataBlockBytes(toBuffer, toIndex, fromBuffer, fromIndex, count). Non-throwing leaf. bool canCopyDataBlockBytes(JSC::ArrayBuffer& toBuffer, size_t toIndex, JSC::ArrayBuffer& fromBuffer, size_t fromIndex, size_t count) { diff --git a/src/jsc/event_loop.rs b/src/jsc/event_loop.rs index 1763863bc57b..680de9321ab7 100644 --- a/src/jsc/event_loop.rs +++ b/src/jsc/event_loop.rs @@ -319,7 +319,9 @@ impl EventLoop { let count = self.entered_event_loop_count; bun_core::scoped_log!(EventLoop, "exit() = {}", count - 1); - if count == 1 && !self.vm_ref().is_inside_deferred_task_queue.get() { + if self.vm_ref().is_inside_deferred_task_queue.get() { + self.vm_ref().deferred_task_entered_js.set(true); + } else if count == 1 { let _ = self.drain_microtasks(); } @@ -389,7 +391,10 @@ impl EventLoop { bun_core::scoped_log!(EventLoop, "exit() = {}", count - 1); let inside_deferred = self.vm_ref().is_inside_deferred_task_queue.get(); - let result = if allow_drain_microtask && count == 1 && !inside_deferred { + let result = if inside_deferred { + self.vm_ref().deferred_task_entered_js.set(true); + Ok(()) + } else if allow_drain_microtask && count == 1 { self.drain_microtasks() } else { Ok(()) @@ -440,6 +445,15 @@ impl EventLoop { self.deferred_tasks.run(); vm.is_inside_deferred_task_queue.set(false); + // Drain again. Otherwise microtasks queued by that JS wait for the next event, or never run if the loop exits first. + if vm.deferred_task_entered_js.replace(false) { + match JSC__JSGlobalObject__drainMicrotasks(global_object) { + drain_result::SUCCESS | drain_result::PENDING_EXCEPTION => {} + drain_result::STOPPED => return Err(Stopped), + _ => unreachable!(), + } + } + // Guard on `event_loop_handle` being set, but drain via `uws_loop_mut()`: // on Windows the uSockets loop (`uws::Loop::get()`) is NOT // `event_loop_handle` (which is the libuv loop). diff --git a/src/jsc/rare_data.rs b/src/jsc/rare_data.rs index 9cb834e56697..dd38902509e2 100644 --- a/src/jsc/rare_data.rs +++ b/src/jsc/rare_data.rs @@ -252,6 +252,8 @@ pub struct RareData { /// `node:http2` PADDED DATA scratch; see [`Self::take_h2_padded_frame_buffer`]. h2_padded_frame_buffer: Option>, + /// `node:http2` outbound header block scratch; see [`Self::take_h2_header_scratch`]. + h2_header_scratch: Option, /// Output scratch for one JS-thread `CompressionStream` step; see [`Self::take_compression_scratch`]. compression_scratch: Option>, /// Inflated payload of one `new WebSocket()` client message; see [`Self::take_websocket_inflate_scratch`]. @@ -306,6 +308,7 @@ impl Default for RareData { listening_sockets_for_watch_mode: Mutex::new(Vec::new()), pipe_read_scratch: Box::new(bun_event_loop::PipeReadScratch::new()), h2_padded_frame_buffer: None, + h2_header_scratch: None, compression_scratch: None, websocket_inflate_scratch: None, libdeflate_decompressor: None, @@ -359,6 +362,13 @@ impl PathBuf { /// One max-size HTTP/2 PADDED DATA frame payload (pad-length byte + data + padding). pub type H2PaddedFrameBuffer = [u8; 16384]; +/// One outbound `node:http2` header block: the staged fields and their HPACK encoding. +#[derive(Default)] +pub struct H2HeaderScratch { + pub block: bun_http::lshpack::HeaderBlock, + pub encoded: Vec, +} + // ────────────────────────────────────────────────────────────────────────── // ProxyEnvStorage // ────────────────────────────────────────────────────────────────────────── @@ -747,6 +757,24 @@ impl RareData { self.h2_padded_frame_buffer.get_or_insert(buffer); } + /// Taken by value, like [`Self::take_h2_padded_frame_buffer`], because a header value's `toString` can re-enter. + pub fn take_h2_header_scratch(&mut self) -> H2HeaderScratch { + self.h2_header_scratch.take().unwrap_or_default() + } + + /// Returns a taken scratch. Keeps the first one returned and drops oversized ones. + pub fn put_back_h2_header_scratch(&mut self, mut scratch: H2HeaderScratch) { + const KEEP: usize = 64 * 1024; + if self.h2_header_scratch.is_none() + && scratch.block.capacity() <= KEEP + && scratch.encoded.capacity() <= KEEP + { + scratch.block.clear(); + scratch.encoded.clear(); + self.h2_header_scratch = Some(scratch); + } + } + /// Empty `Vec` with whatever capacity the last step left behind. pub fn take_compression_scratch(&mut self) -> Vec { self.compression_scratch.take().unwrap_or_default() diff --git a/src/jsc/webcore_types.rs b/src/jsc/webcore_types.rs index 139b48c5c785..b18904b0d555 100644 --- a/src/jsc/webcore_types.rs +++ b/src/jsc/webcore_types.rs @@ -381,15 +381,15 @@ impl Blob { // pulling the whole `BlobExt` trait into scope. // ──────────────────────────────────────────────────────────────────── - /// `Blob.hasContentTypeFromUser()` — `true` when the user set a type - /// explicitly *or* the store is file/S3-backed (whose mime is sniffed). + /// Returns true if the type isn't empty and the user set it or the store is file/S3-backed (whose mime is sniffed). #[inline] pub fn has_content_type_from_user(&self) -> bool { - self.content_type_was_set.get() - || self - .store() - .map(|s| matches!(s.data, store::Data::File(_) | store::Data::S3(_))) - .unwrap_or(false) + !self.content_type_slice().is_empty() + && (self.content_type_was_set.get() + || self + .store() + .map(|s| matches!(s.data, store::Data::File(_) | store::Data::S3(_))) + .unwrap_or(false)) } /// `Blob.contentTypeOrMimeType()` — explicit `content_type` if set, else diff --git a/src/libdeflate_sys/libdeflate.rs b/src/libdeflate_sys/libdeflate.rs index 19431b451b4f..646f89d75260 100644 --- a/src/libdeflate_sys/libdeflate.rs +++ b/src/libdeflate_sys/libdeflate.rs @@ -415,12 +415,7 @@ impl Decompressor { /// [`decompress_to_vec`](Self::decompress_to_vec) with a doubling retry loop. /// - /// Clears `out` first (libdeflate restarts decompression from scratch on - /// each call), then repeatedly doubles `out`'s capacity on - /// [`Status::InsufficientSpace`] — clamped at `max_capacity` — until - /// success, hard error, or `out.capacity() >= max_capacity` (returned as - /// the final `InsufficientSpace`). On success, `out.len() == result.written`. - /// `Err` when a growth step cannot be allocated. + /// Returns [`Status::InsufficientSpace`] at `max_capacity`, and `Err` when `out` can't grow. pub fn decompress_to_vec_grow( &mut self, input: &[u8], @@ -429,7 +424,6 @@ impl Decompressor { max_capacity: usize, ) -> core::result::Result { loop { - out.clear(); let result = self.decompress_to_vec(input, out, encoding); if result.status != Status::InsufficientSpace || out.capacity() >= max_capacity { return Ok(result); diff --git a/src/lsquic_sys/lib.rs b/src/lsquic_sys/lib.rs index 5997a5a2bcb8..489c0e8b0461 100644 --- a/src/lsquic_sys/lib.rs +++ b/src/lsquic_sys/lib.rs @@ -44,10 +44,12 @@ pub const LSCONN_ST_RESET: c_int = 5; pub const LSCONN_ST_ERROR: c_int = 7; pub const LSCONN_ST_VERNEG_FAILURE: c_int = 10; -pub const N_LSQVER: c_int = 8; +/// `enum lsquic_dg_status`, the `status` of `NqVtable::on_datagram_status`. +pub const LSQ_DG_ACKED: c_int = 0; +pub const LSQ_DG_LOST: c_int = 1; +pub const LSQ_DG_UNSENT: c_int = 2; -pub const LSQUIC_GLOBAL_CLIENT: c_int = 1; -pub const LSQUIC_GLOBAL_SERVER: c_int = 2; +pub const N_LSQVER: c_int = 8; /// **Layout must match `struct us_nq_vtable` in node_quic_shim.c exactly.** /// The first field of every conn-ctx and stream-ctx the Rust side returns @@ -81,8 +83,7 @@ pub struct NqVtable { pub on_dg_write: unsafe extern "C" fn(conn_ctx: *mut c_void, buf: *mut c_void, buf_sz: usize) -> isize, pub on_datagram: unsafe extern "C" fn(conn_ctx: *mut c_void, buf: *const c_void, sz: usize), - pub on_datagram_status: - unsafe extern "C" fn(conn_ctx: *mut c_void, count: c_uint, acked: c_int), + pub on_datagram_status: unsafe extern "C" fn(conn_ctx: *mut c_void, id: u64, status: c_int), pub on_early_data_failed: unsafe extern "C" fn(conn_ctx: *mut c_void), pub on_path_switch: unsafe extern "C" fn( conn_ctx: *mut c_void, @@ -106,12 +107,12 @@ pub struct NqVtable { ) -> *mut SSL_CTX, pub packets_out: unsafe extern "C" fn(owner: *mut c_void, specs: *const lsquic_out_spec, n: c_uint) -> c_int, - pub on_mini_conn_failed: - unsafe extern "C" fn(owner: *mut c_void, peer_sa: *const sockaddr, error_code: u64), + pub on_new_mini_conn: + unsafe extern "C" fn(owner: *mut c_void, c: *mut lsquic_conn) -> *mut c_void, + pub on_mini_conn_failed: unsafe extern "C" fn(conn_ctx: *mut c_void, error_code: u64), } unsafe extern "C" { - pub fn lsquic_global_init(flags: c_int) -> c_int; pub fn lsquic_engine_destroy(engine: *mut lsquic_engine); pub fn lsquic_engine_conn_count(engine: *const lsquic_engine) -> c_uint; pub fn lsquic_engine_cid_in_use( @@ -149,13 +150,14 @@ unsafe extern "C" { ) -> *mut lsquic_conn; pub fn lsquic_conn_get_ctx(c: *const lsquic_conn) -> *mut c_void; - pub fn lsquic_conn_get_peer_ctx(c: *const lsquic_conn, local_sa: *const c_void) -> *mut c_void; pub fn lsquic_conn_set_ctx(c: *mut lsquic_conn, ctx: *mut c_void); pub fn lsquic_conn_close(c: *mut lsquic_conn); fn lsquic_conn_going_away(c: *mut lsquic_conn); - pub fn lsquic_conn_status(c: *mut lsquic_conn, errbuf: *mut c_char, bufsz: usize) -> c_int; + fn lsquic_conn_status(c: *mut lsquic_conn, errbuf: *mut c_char, bufsz: usize) -> c_int; pub fn lsquic_conn_make_stream(c: *mut lsquic_conn); pub fn lsquic_conn_make_uni_stream(c: *mut lsquic_conn); + fn lsquic_conn_cancel_pending_streams(c: *mut lsquic_conn, n: c_uint) -> c_uint; + fn lsquic_conn_cancel_pending_uni_streams(c: *mut lsquic_conn, n: c_uint) -> c_uint; pub fn lsquic_conn_get_sockaddr( c: *mut lsquic_conn, local: *mut *const sockaddr, @@ -191,7 +193,6 @@ unsafe extern "C" { pub fn lsquic_stream_wantread(s: *mut lsquic_stream, is_want: c_int) -> c_int; pub fn lsquic_stream_wantwrite(s: *mut lsquic_stream, is_want: c_int) -> c_int; - pub fn us_nq_enable_logging(level: *const c_char); pub fn us_nq_vtable_size() -> usize; pub fn us_nq_settings_size() -> usize; pub fn us_nq_settings_init(s: *mut lsquic_engine_settings, is_server: c_int, is_http: c_int); @@ -375,18 +376,56 @@ settings_setters! { delay_onclose => us_nq_settings_set_delay_onclose : c_int, } +/// `conn` must be a live `lsquic_conn_t`. +unsafe fn sockaddrs(conn: *mut lsquic_conn) -> Option<(*const sockaddr, *const sockaddr)> { + let mut local = core::ptr::null(); + let mut peer = core::ptr::null(); + // SAFETY: caller contract; the out-params point at stack slots. + let found = unsafe { lsquic_conn_get_sockaddr(conn, &raw mut local, &raw mut peer) } == 0; + found.then_some((local, peer)) +} + +/// Borrowed mini conn (a server handshake). Most `Conn` methods would call through a NULL function pointer on it. +#[derive(Copy, Clone)] +pub struct MiniConn(core::ptr::NonNull); + +impl MiniConn { + /// `raw` must be a live mini conn for the lifetime of the returned value. + pub unsafe fn from_raw(raw: *mut lsquic_conn) -> Option { + core::ptr::NonNull::new(raw).map(Self) + } + /// Takes the application context off the conn. + pub fn clear_ctx(&self) { + // SAFETY: `self.0` is live (caller contract). + unsafe { lsquic_conn_set_ctx(self.0.as_ptr(), core::ptr::null_mut()) } + } + /// Makes the conn skip its TLS handshake and answer with a transport CONNECTION_CLOSE. + pub fn refuse(&self, code: c_uint) { + // SAFETY: as above. + unsafe { lsquic_conn_abort_error(self.0.as_ptr(), 0, code, core::ptr::null()) } + } + /// The `(local, peer)` sockaddrs of the conn's first packet. + pub fn sockaddrs(&self) -> Option<(*const sockaddr, *const sockaddr)> { + // SAFETY: as above. + unsafe { sockaddrs(self.0.as_ptr()) } + } +} + /// Borrowed `lsquic_conn_t`. lsquic owns the conn and frees it after /// `on_conn_closed` returns; callers must not hold a `Conn` past that point. #[derive(Copy, Clone)] pub struct Conn(*mut lsquic_conn); impl Conn { - /// # Safety - /// `raw` must be a live `lsquic_conn_t` for the lifetime of the returned - /// value (i.e. until `on_conn_closed` returns for it). + /// `raw` must be a live full conn (not a `MiniConn`) until `on_conn_closed` returns for it. pub unsafe fn from_raw(raw: *mut lsquic_conn) -> Option { (!raw.is_null()).then_some(Self(raw)) } + /// The `(local, peer)` sockaddrs of the conn's current path. + pub fn sockaddrs(&self) -> Option<(*const sockaddr, *const sockaddr)> { + // SAFETY: `self.0` is live (caller contract). + unsafe { sockaddrs(self.0) } + } pub fn close(&self) { // SAFETY: `self.0` is live (caller contract). unsafe { lsquic_conn_close(self.0) } @@ -425,6 +464,17 @@ impl Conn { // SAFETY: as above. unsafe { lsquic_conn_make_uni_stream(self.0) } } + /// Takes back one `make_stream` or `make_uni_stream` request that lsquic hasn't created a stream for yet. + pub fn cancel_pending_stream(&self, uni: bool) { + // SAFETY: as above. + unsafe { + if uni { + lsquic_conn_cancel_pending_uni_streams(self.0, 1); + } else { + lsquic_conn_cancel_pending_streams(self.0, 1); + } + } + } pub fn want_datagram_write(&self, want: bool) -> c_int { // SAFETY: as above. unsafe { lsquic_conn_want_datagram_write(self.0, want as c_int) } @@ -461,6 +511,14 @@ impl Conn { // SAFETY: `self.0` is a live conn (constructor contract). unsafe { lsquic_conn_use_preferred_address(self.0, on as c_int) } } + /// Only valid inside `on_dg_write`: the id `on_datagram_status` reports the frame under. + pub fn set_datagram_id(&self, id: u64) { + unsafe extern "C" { + fn lsquic_conn_set_datagram_id(c: *mut lsquic_conn, id: u64); + } + // SAFETY: `self.0` is a live conn (constructor contract). + unsafe { lsquic_conn_set_datagram_id(self.0, id) } + } pub fn datagram_early(&self) -> bool { unsafe extern "C" { fn lsquic_conn_datagram_early(c: *const lsquic_conn) -> c_int; @@ -482,6 +540,23 @@ impl Conn { // SAFETY: as above. unsafe { lsquic_conn_get_ssl(self.0) } } + /// Returns `lsquic_conn_status()`, its message, and the transport error code recorded for this side's CONNECTION_CLOSE. + pub fn status(&self) -> (c_int, Vec, Option) { + unsafe extern "C" { + fn lsquic_conn_local_transport_error(c: *const lsquic_conn, code: *mut u64) -> c_int; + } + let mut buf = [0 as c_char; 256]; + // SAFETY: as above. + let status = unsafe { lsquic_conn_status(self.0, buf.as_mut_ptr(), buf.len()) }; + // SAFETY: `buf` starts zeroed, and lsquic NUL-terminates what it writes. + let message = unsafe { core::ffi::CStr::from_ptr(buf.as_ptr()) } + .to_bytes() + .to_vec(); + let mut code = 0u64; + // SAFETY: as above. `code` is a stack out-param. + let recorded = unsafe { lsquic_conn_local_transport_error(self.0, &raw mut code) } != 0; + (status, message, recorded.then_some(code)) + } } /// Borrowed `lsquic_stream_t`. Same lifetime contract as [`Conn`] — invalid @@ -546,6 +621,22 @@ impl Stream { // SAFETY: as above. unsafe { lsquic_stream_wantwrite(self.0, want as c_int) } } + /// Receive backpressure, see `lsquic_stream_hold_window()` in lsquic.h. + pub fn hold_window(&self, hold: bool) { + unsafe extern "C" { + fn lsquic_stream_hold_window(s: *mut lsquic_stream, hold: c_int); + } + // SAFETY: as above. + unsafe { lsquic_stream_hold_window(self.0, hold as c_int) } + } + /// The smaller of the stream's and the connection's flow-control credit. lsquic only calls `on_write` above 0. + pub fn write_avail(&self) -> usize { + unsafe extern "C" { + fn lsquic_stream_write_avail(s: *mut lsquic_stream) -> usize; + } + // SAFETY: as above. + unsafe { lsquic_stream_write_avail(self.0) } + } pub fn reset(&self, code: u64) { // SAFETY: as above. unsafe { us_nq_stream_reset(self.0, code) } @@ -621,6 +712,14 @@ impl Stream { // SAFETY: `self.0` is a live stream (constructor contract). unsafe { lsquic_stream_has_unacked_data(self.0) != 0 } } + /// Returns true if both sides are closed and every frame is acknowledged. + pub fn is_finished(&self) -> bool { + unsafe extern "C" { + fn lsquic_stream_is_finished(s: *const lsquic_stream) -> c_int; + } + // SAFETY: `self.0` is a live stream (constructor contract). + unsafe { lsquic_stream_is_finished(self.0) != 0 } + } pub fn reset_received(&self) -> bool { unsafe extern "C" { fn lsquic_stream_reset_received(s: *const lsquic_stream) -> c_int; diff --git a/src/picohttp/lib.rs b/src/picohttp/lib.rs index 873daa1c30a1..2a75ddb14b4c 100644 --- a/src/picohttp/lib.rs +++ b/src/picohttp/lib.rs @@ -4,7 +4,7 @@ use core::fmt; use bstr::BStr; -use bun_core::fmt::{escape_control_chars, for_terminal}; +use bun_core::fmt::{EscapeControlChars, escape_control_chars, for_terminal, quote_posix_shell}; use bun_core::output::enable_ansi_colors_stderr; use bun_core::pretty_fmt; @@ -159,6 +159,15 @@ impl Header { } } +/// Starts every line of the verbose trace. The `[fetch]` tag only shows when stderr has colors. +pub fn trace_line_prefix() -> &'static str { + if enable_ansi_colors_stderr() { + pretty_fmt!("[fetch] ", true) + } else { + "" + } +} + // The `Display` impls here (except `Headers`: wire format) print peer-supplied text to a terminal. impl fmt::Display for Header { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -201,6 +210,23 @@ impl fmt::Display for Header { const _: () = assert!(core::mem::size_of::
() == core::mem::size_of::()); const _: () = assert!(core::mem::align_of::
() == core::mem::align_of::()); +/// One argument of the printed `curl` command. +struct CurlArg<'a>(&'a [&'a [u8]]); + +impl fmt::Display for CurlArg<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // cmd.exe has no single quotes. + if cfg!(windows) { + f.write_str("\"")?; + for part in self.0 { + write!(f, "{}", escape_control_chars(part))?; + } + return f.write_str("\""); + } + write!(f, "{}", EscapeControlChars(quote_posix_shell(self.0))) + } +} + struct HeaderCurlFormatter<'a> { header: &'a Header, } @@ -209,14 +235,9 @@ impl fmt::Display for HeaderCurlFormatter<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let header = self.header; if header.value_len > 0 { - write!( - f, - "-H \"{}: {}\"", - escape_control_chars(header.name()), - escape_control_chars(header.value()) - ) + write!(f, "-H {}", CurlArg(&[header.name(), b": ", header.value()])) } else { - write!(f, "-H \"{}\"", escape_control_chars(header.name())) + write!(f, "-H {}", CurlArg(&[header.name()])) } } } @@ -282,51 +303,6 @@ impl<'a> Request<'a> { body, } } - - /// Widen the borrowed slices to `'static` for self-referential storage. - /// - /// Field-by-field move (no bitwise reinterpret). Used when the request's - /// `method`/`path`/`headers` borrow thread-local static buffers - /// (`SHARED_REQUEST_HEADERS_BUF`) or a sibling field on the same - /// heap-stable owner. - /// - /// # Safety - /// Caller guarantees every borrowed slice outlives the returned value. - #[inline] - pub unsafe fn detach_lifetime(self) -> Request<'static> { - Request { - // SAFETY: caller contract. - method: unsafe { &*core::ptr::from_ref::<[u8]>(self.method) }, - // SAFETY: caller contract. - path: unsafe { &*core::ptr::from_ref::<[u8]>(self.path) }, - minor_version: self.minor_version, - // SAFETY: caller contract. - headers: unsafe { &*core::ptr::from_ref::<[Header]>(self.headers) }, - bytes_read: self.bytes_read, - } - } -} - -impl fmt::Display for Request<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if enable_ansi_colors_stderr() { - f.write_str(pretty_fmt!("[fetch] ", true))?; - } - writeln!( - f, - "> HTTP/1.1 {} {}", - BStr::new(self.method), - escape_control_chars(self.path) - )?; - for header in self.headers { - if enable_ansi_colors_stderr() { - f.write_str(pretty_fmt!("[fetch] ", true))?; - } - f.write_str("> ")?; - writeln!(f, "{}", header)?; - } - Ok(()) - } } pub struct RequestCurlFormatter<'a> { @@ -356,19 +332,24 @@ impl fmt::Display for RequestCurlFormatter<'_> { write!( f, - pretty_fmt!("curl --http1.1 \"{}\"", true), - escape_control_chars(request.path), + pretty_fmt!("curl --http1.1 {}", true), + CurlArg(&[request.path]), )?; } else { - write!( - f, - "curl --http1.1 \"{}\"", - escape_control_chars(request.path) - )?; + write!(f, "curl --http1.1 {}", CurlArg(&[request.path]))?; } - if request.method != b"GET" { - write!(f, " -X {}", BStr::new(request.method))?; + // Without this, curl expands `[1-3]` and `{a,b}` in the URL. + if strings::index_of_any(request.path, b"[]{}").is_some() { + f.write_str(" --globoff")?; + } + + if request.method.iter().all(u8::is_ascii_alphabetic) { + if request.method != b"GET" { + write!(f, " -X {}", BStr::new(request.method))?; + } + } else { + write!(f, " -X {}", CurlArg(&[request.method]))?; } if self.ignore_insecure { @@ -394,11 +375,15 @@ impl fmt::Display for RequestCurlFormatter<'_> { if !self.body.is_empty() && Self::is_printable_body(content_type) { f.write_str(" --data-raw ")?; - bun_core::js_printer::write_json_string( - self.body, - f, - bun_core::strings::Encoding::Utf8, - )?; + if cfg!(windows) { + bun_core::js_printer::write_json_string( + self.body, + f, + bun_core::strings::Encoding::Utf8, + )?; + } else { + write!(f, "{}", CurlArg(&[self.body]))?; + } } Ok(()) @@ -435,6 +420,8 @@ impl fmt::Display for StatusCodeFormatter { pub enum ParseResponseError { #[strum(serialize = "Malformed_HTTP_Response")] MalformedHttpResponse, + /// The response has more header fields than `src` has slots. + TooManyHeaders, ShortRead, } bun_core::impl_tag_error!(ParseResponseError); @@ -507,6 +494,8 @@ impl<'a> Response<'a> { }; match rc { + // Running out of slots is -1 too, but a malformed field leaves `num_headers` below `src.len()`. + -1 if num_headers == src.len() => Err(ParseResponseError::TooManyHeaders), -1 => { bun_core::debug!("Malformed HTTP response:\n{}", BStr::new(buf)); Err(ParseResponseError::MalformedHttpResponse) @@ -554,10 +543,7 @@ impl<'a> Response<'a> { impl fmt::Display for Response<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if enable_ansi_colors_stderr() { - f.write_str(pretty_fmt!("[fetch] ", true))?; - } - + f.write_str(trace_line_prefix())?; writeln!( f, "< {} {}", @@ -567,10 +553,7 @@ impl fmt::Display for Response<'_> { escape_control_chars(self.status), )?; for header in self.headers.list { - if enable_ansi_colors_stderr() { - f.write_str(pretty_fmt!("[fetch] ", true))?; - } - + f.write_str(trace_line_prefix())?; f.write_str("< ")?; writeln!(f, "{}", header)?; } diff --git a/src/resolver/data_url.rs b/src/resolver/data_url.rs index ae23424741a7..b10ca04192a0 100644 --- a/src/resolver/data_url.rs +++ b/src/resolver/data_url.rs @@ -129,6 +129,155 @@ impl PercentEncoding { } } +/// TAB, LF, FF, CR, SPACE (https://infra.spec.whatwg.org/#ascii-whitespace) +const ASCII_WHITESPACE: &[u8] = b"\t\n\x0C\r "; + +/// Data: URL processor step 11: `;`, optional spaces, case-insensitive `base64`. +fn strip_base64_marker(mime_type: &[u8]) -> Option<&[u8]> { + const MARKER: &[u8] = b"base64"; + let (rest, marker) = mime_type.split_at(mime_type.len().checked_sub(MARKER.len())?); + if !strings::eql_case_insensitive_ascii_check_length(marker, MARKER) { + return None; + } + strings::trim_right(rest, b" ").strip_suffix(b";") +} + +/// The `Content-Type` to expose and the decoded body, from [`DataURL::process_for_fetch`]. +pub struct FetchDataURL { + pub mime_type: Vec, + pub body: Vec, +} + +/// https://fetch.spec.whatwg.org/#http-whitespace +const HTTP_WHITESPACE: &[u8] = b"\t\n\r "; + +/// https://mimesniff.spec.whatwg.org/#http-token-code-point +fn is_http_token(c: u8) -> bool { + c.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&c) +} + +/// https://mimesniff.spec.whatwg.org/#http-quoted-string-token-code-point +fn is_http_quoted_string_token(c: u8) -> bool { + c == b'\t' || (0x20..=0x7E).contains(&c) || c >= 0x80 +} + +fn solely_http_tokens(s: &[u8]) -> bool { + !s.is_empty() && s.iter().all(|&c| is_http_token(c)) +} + +/// https://fetch.spec.whatwg.org/#collect-an-http-quoted-string with extract-value, after the opening `"`. +fn collect_quoted_string_value(input: &[u8], pos: &mut usize) -> Vec { + let mut value = Vec::new(); + while *pos < input.len() { + let c = input[*pos]; + *pos += 1; + match c { + b'"' => return value, + b'\\' => { + if *pos < input.len() { + value.push(input[*pos]); + *pos += 1; + } else { + value.push(b'\\'); + } + } + _ => value.push(c), + } + } + value +} + +/// https://mimesniff.spec.whatwg.org/#parse-a-mime-type then serialize; `None` for an invalid `type/subtype`. +fn serialize_mime_type(input: &[u8]) -> Option> { + let input = strings::trim(input, HTTP_WHITESPACE); + let slash = strings::index_of_char_usize(input, b'/')?; + let type_ = &input[..slash]; + if !solely_http_tokens(type_) { + return None; + } + + let mut pos = slash + 1; + let subtype_start = pos; + while pos < input.len() && input[pos] != b';' { + pos += 1; + } + let subtype = strings::trim_right(&input[subtype_start..pos], HTTP_WHITESPACE); + if !solely_http_tokens(subtype) { + return None; + } + + let mut out = Vec::with_capacity(input.len()); + out.extend(type_.iter().map(u8::to_ascii_lowercase)); + out.push(b'/'); + out.extend(subtype.iter().map(u8::to_ascii_lowercase)); + + let mut seen: Vec> = Vec::new(); + while pos < input.len() { + pos += 1; // consume ';' + while pos < input.len() && HTTP_WHITESPACE.contains(&input[pos]) { + pos += 1; + } + let name_start = pos; + while pos < input.len() && input[pos] != b';' && input[pos] != b'=' { + pos += 1; + } + let name: Vec = input[name_start..pos] + .iter() + .map(u8::to_ascii_lowercase) + .collect(); + + if pos >= input.len() || input[pos] == b';' { + continue; + } + pos += 1; // consume '=' + + let value: Vec = if pos < input.len() && input[pos] == b'"' { + pos += 1; + let v = collect_quoted_string_value(input, &mut pos); + while pos < input.len() && input[pos] != b';' { + pos += 1; + } + v + } else { + let value_start = pos; + while pos < input.len() && input[pos] != b';' { + pos += 1; + } + let v = strings::trim_right(&input[value_start..pos], HTTP_WHITESPACE); + if v.is_empty() { + continue; + } + v.to_vec() + }; + + if !solely_http_tokens(&name) + || !value.iter().all(|&c| is_http_quoted_string_token(c)) + || seen.iter().any(|n| n == &name) + { + continue; + } + + out.push(b';'); + out.extend_from_slice(&name); + out.push(b'='); + if solely_http_tokens(&value) { + out.extend_from_slice(&value); + } else { + out.push(b'"'); + for &c in &value { + if c == b'"' || c == b'\\' { + out.push(b'\\'); + } + out.push(c); + } + out.push(b'"'); + } + seen.push(name); + } + + Some(out) +} + // `mime_type`/`data` are slices into the caller-provided `url` string. // Classified as BORROW_PARAM — struct gets a lifetime parameter. pub struct DataURL<'a> { @@ -146,18 +295,19 @@ impl<'a> DataURL<'a> { Ok(Some(Self::parse_without_check(url)?)) } + /// https://fetch.spec.whatwg.org/#data-url-processor pub fn parse_without_check(url: &'a [u8]) -> Result, ParseDataURLError> { let comma = strings::index_of_char(url, b',').ok_or(ParseDataURLError::InvalidDataURL)? as usize; let mut parsed = DataURL { - mime_type: &url[b"data:".len()..comma], + mime_type: strings::trim(&url[b"data:".len()..comma], ASCII_WHITESPACE), data: &url[comma + 1..url.len()], is_base64: false, }; - if parsed.mime_type.ends_with(b";base64") { - parsed.mime_type = &parsed.mime_type[0..(parsed.mime_type.len() - b";base64".len())]; + if let Some(mime_type) = strip_base64_marker(parsed.mime_type) { + parsed.mime_type = mime_type; parsed.is_base64 = true; } @@ -174,17 +324,32 @@ impl<'a> DataURL<'a> { let percent_decoded: &[u8] = percent_decoded_owned.as_deref().unwrap_or(self.data); if self.is_base64 { - let decoded = bun_base64::decode_alloc(percent_decoded) - .map_err(|_| DecodeDataError::Base64DecodeError)?; - if decoded.len() != bun_base64::decode_len(percent_decoded) { - return Err(DecodeDataError::Base64DecodeError); - } - return Ok(decoded); + return bun_base64::decode_forgiving_alloc(percent_decoded) + .map_err(|_| DecodeDataError::Base64DecodeError); } Ok(percent_decoded.to_vec()) } + /// https://fetch.spec.whatwg.org/#data-url-processor; `input` is the serialized URL without its fragment. + pub fn process_for_fetch(input: &[u8]) -> Option { + let parsed = DataURL::parse(input).ok()??; + let body = parsed.decode_data().ok()?; + + let mut mime_type = parsed.mime_type; + let mime_type_buf; + if mime_type.first() == Some(&b';') { + mime_type_buf = [b"text/plain".as_slice(), mime_type].concat(); + mime_type = &mime_type_buf; + } + + Some(FetchDataURL { + mime_type: serialize_mime_type(mime_type) + .unwrap_or_else(|| b"text/plain;charset=US-ASCII".to_vec()), + body, + }) + } + /// Returns the shorter of either a base64-encoded or percent-escaped data URL pub fn encode_string_as_shortest_data_url(mime_type: &[u8], text: &[u8]) -> Vec { // Calculate base64 version @@ -358,6 +523,39 @@ mod tests { assert!(parsed.is_base64); } + #[test] + fn base64_marker_is_case_insensitive_and_space_tolerant() { + for url in [ + &b"data:text/plain;base64,aGk="[..], + b"data:text/plain;BASE64,aGk=", + b"data:text/plain;Base64,aGk=", + b"data:text/plain; base64,aGk=", + b"data:text/plain;base64 ,aGk=", + b"data: text/plain; bAsE64 ,aGk=", + ] { + let parsed = DataURL::parse(url).unwrap().unwrap(); + assert!(parsed.is_base64, "{}", String::from_utf8_lossy(url)); + assert_eq!(parsed.mime_type, b"text/plain"); + assert_eq!(parsed.decode_data().unwrap(), b"hi"); + } + + let parsed = DataURL::parse(b"data:;base64,aGk=").unwrap().unwrap(); + assert!(parsed.is_base64); + assert_eq!(parsed.mime_type, b""); + + for url in [ + &b"data:text/plain;base64x,aGk="[..], + b"data:text/plain;charset=base64,aGk=", + b"data:text/plain base64,aGk=", + b"data:base64,aGk=", + b"data:text/plain;base 64,aGk=", + ] { + let parsed = DataURL::parse(url).unwrap().unwrap(); + assert!(!parsed.is_base64, "{}", String::from_utf8_lossy(url)); + assert_eq!(parsed.decode_data().unwrap(), b"aGk="); + } + } + #[test] fn shortest_data_url_base64_fallback_when_shorter() { // Every byte needs escaping (3 bytes each) so base64 (4/3 per byte) diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index ae2acb3867ce..c33dcd84f3ee 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -1262,7 +1262,7 @@ fn serve(global_object: &JSGlobalObject, callframe: &CallFrame) -> JsResult {{ // SAFETY: tag was matched; ptr was inserted as `*mut $T` below. let server: &mut $T = unsafe { &mut *entry.ptr.cast::<$T>() }; - server.on_reload_from_zig(&mut config, global_object); + server.on_reload_from_zig(&mut config, global_object)?; // Its handlers are the calling script's now, and so is the server: it goes // with that script's context, not with the one that first listened. server.abort_handle.leave(); @@ -1954,9 +1954,20 @@ pub(crate) mod JSZlib { bun_libdeflate::Encoding::Deflate }; let max_output = ArrayBuffer::MAX_SIZE as usize; - let result = decompressor - .decompress_to_vec_grow(compressed, &mut list, encoding, max_output) - .map_err(|_| bun_alloc::AllocError)?; + let mut rest = compressed; + let result = loop { + let result = decompressor + .decompress_to_vec_grow(rest, &mut list, encoding, max_output) + .map_err(|_| bun_alloc::AllocError)?; + // libdeflate decodes one gzip member per call (RFC 1952 §2.2). + rest = &rest[result.read..]; + if result.status != bun_libdeflate::Status::Success + || !is_gzip + || !rest.starts_with(&zlib::GZIP_MAGIC) + { + break result; + } + }; match result.status { bun_libdeflate::Status::Success if list.len() <= max_output => {} bun_libdeflate::Status::Success | bun_libdeflate::Status::InsufficientSpace => { diff --git a/src/runtime/api/bun/h2/connection.rs b/src/runtime/api/bun/h2/connection.rs index a6ad19360952..30d95704f788 100644 --- a/src/runtime/api/bun/h2/connection.rs +++ b/src/runtime/api/bun/h2/connection.rs @@ -71,6 +71,26 @@ pub(crate) struct Stream { pub recv_body_bytes: u64, /// The embedder had already sent GOAWAY when the peer opened this stream. pub opened_after_goaway: bool, + /// Client only: `:method` of the request this stream answers, known from the first response block on. + pub request_method: Option, +} + +/// Request methods that change how the response body is framed (nghttp2's METH_HEAD and METH_CONNECT flags). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum RequestMethod { + Other, + Head, + Connect, +} + +impl RequestMethod { + pub(crate) fn from_method(value: &[u8]) -> Self { + match value { + b"HEAD" => RequestMethod::Head, + b"CONNECT" => RequestMethod::Connect, + _ => RequestMethod::Other, + } + } } impl Stream { @@ -84,7 +104,20 @@ impl Stream { content_length: None, recv_body_bytes: 0, opened_after_goaway: false, + request_method: None, + } + } + + /// Stream error that answers an inbound DATA frame, if the stream has to refuse it. + fn data_refusal(&self, is_server: bool) -> Option { + if !stream::can_receive_data(self.state) { + return Some(ErrorCode::StreamClosed); } + // RFC 9113 §8.1: a response starts with HEADERS. nghttp2 ends the session here, fetch() resets the stream. + if !is_server && !self.recv_final_headers { + return Some(ErrorCode::ProtocolError); + } + None } /// RFC 9113 §8.1.1 (nghttp2_http_on_data_chunk): more DATA than the declared `content-length`. @@ -101,6 +134,16 @@ pub(crate) enum WriteResult { Sent = 1, } +/// What `Sink::credit_send_window` did with a WINDOW_UPDATE increment. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum SendCredit { + /// The embedder has no send window for this id, so the engine's own window takes the increment. + NotOwned, + Applied, + /// §6.9.1: the increment would push the window past 2^31-1. Nothing was added. + Overflow, +} + /// Outcome of feeding bytes: how many were consumed, and whether the connection is now closing. #[derive(Clone, Copy, Debug)] enum StreamedDataStart { @@ -125,6 +168,8 @@ struct HeaderBlockInFlight { /// The stream id the next frame MUST be a CONTINUATION for (the parent stream id while /// assembling a PUSH_PROMISE block). continuation_stream: u32, + /// CONTINUATION frames received for this block, capped at `MAX_CONTINUATIONS`. + continuations: u32, meta: HeaderBlockMeta, } @@ -151,7 +196,7 @@ struct HeaderBlockMeta { #[derive(Clone, Copy, PartialEq, Eq)] enum BlockDisposition { Deliver, - /// HEADERS arrived on a closed stream: answered with RST_STREAM(STREAM_CLOSED). + /// HEADERS arrived on a closed stream: dropped without a reply (nghttp2's IGN_HEADER_BLOCK). StreamClosed, /// The embedder refused the stream (can_open_stream = false, node's maxSessionMemory): /// answered with RST_STREAM(ENHANCE_YOUR_CALM). @@ -161,16 +206,25 @@ enum BlockDisposition { pub(crate) struct Feed { pub consumed: usize, pub fatal: bool, + /// `Sink::should_stop()` ended the call between frames, before any window was replenished. + pub yielded: bool, } /// nghttp2's NGHTTP2_DEFAULT_MAX_OBQ_FLOOD_ITEM: outbound PING/SETTINGS ACKs that may pile up /// behind a non-reading peer before the session is treated as flooded (NGHTTP2_ERR_FLOODED). const MAX_OUTBOUND_ACK_QUEUE: u32 = 1000; +/// nghttp2's NGHTTP2_DEFAULT_MAX_CONTINUATIONS: CONTINUATION frames allowed per header block. +const MAX_CONTINUATIONS: u32 = 8; + /// nghttp2's NGHTTP2_DEFAULT_STREAM_RESET_BURST / _RATE (node's streamResetBurst / streamResetRate). pub(crate) const DEFAULT_STREAM_RESET_BURST: u32 = 1000; pub(crate) const DEFAULT_STREAM_RESET_RATE: u32 = 33; +/// nghttp2's NGHTTP2_DEFAULT_GLITCH_BURST / _RATE. +const GLITCH_BURST: u32 = 10_000; +const GLITCH_RATE: u32 = 330; + #[derive(Clone, Copy)] enum ResetBy { Peer, @@ -228,19 +282,22 @@ pub(crate) trait Sink { fn on_ping(&self, payload: &[u8], is_ack: bool); /// `code` is the raw u32 from the wire so unknown error codes survive to JS (node parity). fn on_go_away(&self, code: u32, last_stream_id: u32, debug: &[u8]); - /// After a WINDOW_UPDATE has been applied (for resuming sends). - fn on_window_update(&self, stream_id: u32, increment: u32); + /// A WINDOW_UPDATE with a non-zero increment for `stream_id` (0 = the connection). + fn credit_send_window(&self, _stream_id: u32, _increment: u32) -> SendCredit { + SendCredit::NotOwned + } - /// The embedder cannot take further callbacks in this batch (its VM has an exception pending - /// from an earlier one): stop before the next frame; the unconsumed bytes stay queued. + /// The embedder wants control back before the next frame. `receive` returns with `yielded` set. fn should_stop(&self) -> bool { false } // ---- Stream-level (default no-op so simple sinks can ignore them) ---- - /// A new stream was created by an inbound HEADERS (the embedder allocates its JS wrapper). - fn on_stream_open(&self, _stream_id: u32) {} + /// An inbound HEADERS opened a stream. Returns false if the embedder reset it for exceeding SETTINGS_MAX_CONCURRENT_STREAMS. + fn on_stream_open(&self, _stream_id: u32) -> bool { + true + } /// Whether the embedder can afford the state for a new peer-initiated stream (the session /// memory budget, node's maxSessionMemory). `false` refuses the HEADERS with RST_STREAM /// (ENHANCE_YOUR_CALM, node's Http2Session::OnBeginHeadersCallback) before any stream state @@ -254,6 +311,8 @@ pub(crate) trait Sink { } /// A SETTINGS entry with an id outside the standard registry (node's remoteCustomSettings). fn on_remote_custom_setting(&self, _id: u16, _value: u32) {} + /// One SETTINGS_HEADER_TABLE_SIZE entry from the peer, in wire order. + fn on_remote_header_table_size(&self, _size: u32) {} /// One decoded header field. `name`/`value` alias a shared buffer — copy before returning. fn on_header(&self, _stream_id: u32, _name: &[u8], _value: &[u8], _never_index: bool) {} /// The header block for `stream_id` is complete. `end_stream` = the HEADERS carried END_STREAM. @@ -289,6 +348,10 @@ pub(crate) trait Sink { fn is_local_stream(&self, _stream_id: u32) -> bool { false } + /// `:method` of the request the embedder sent on `stream_id`, or `None` once it has forgotten the stream. + fn request_method(&self, _stream_id: u32) -> Option { + None + } /// Highest stream id the embedder has ever registered, in either direction. Monotonic across /// stream eviction: ids at or below it have existed (closed at worst, never idle), which the /// engine cannot tell on its own for locally-initiated streams whose HEADERS it never sent @@ -315,8 +378,6 @@ pub(crate) struct Connection { pub local_settings: Settings, pub remote_settings: Settings, - /// Whether our initial SETTINGS has been ACKed (affects §6.9.2 window grace). - pub local_settings_acked: bool, /// Maximum decoded header fields per block (node's maxHeaderListPairs session option; not a /// SETTINGS parameter). Blocks exceeding it are refused like an oversized header list. pub max_header_list_pairs: u32, @@ -376,6 +437,8 @@ pub(crate) struct Connection { sent_reset_limit: RateLimit, /// A bucket ran dry mid-frame; receive() tears the session down between frames. reset_flood: bool, + /// Frames and header blocks dropped without a reply (nghttp2's glitch_ratelim). + glitch_limit: RateLimit, /// Scratch buffer for the outbound HPACK-encoded header block. enc_buf: Vec, @@ -386,6 +449,8 @@ pub(crate) struct Connection { preface_received: usize, pub last_stream_id: u32, + /// Highest stream id a PUSH_PROMISE reserved (§5.1.1). + last_promised_stream_id: u32, pub going_away: bool, } @@ -395,7 +460,6 @@ impl Connection { is_server, local_settings: local, remote_settings: Settings::default(), - local_settings_acked: false, max_header_list_pairs: 128, frames_received: 0, frames_sent: 0, @@ -408,7 +472,7 @@ impl Connection { max_settings: 32, send_window: SendWindow::new(wire::DEFAULT_WINDOW_SIZE), recv_window: RecvWindow::new(wire::DEFAULT_WINDOW_SIZE), - hpack: hpack::Coder::new(local.header_table_size), + hpack: hpack::Coder::new(), streams: HashMap::new(), header_block_in_flight: None, header_block: Vec::new(), @@ -421,11 +485,13 @@ impl Connection { ), sent_reset_limit: RateLimit::new(DEFAULT_STREAM_RESET_BURST, DEFAULT_STREAM_RESET_RATE), reset_flood: false, + glitch_limit: RateLimit::new(GLITCH_BURST, GLITCH_RATE), enc_buf: Vec::new(), replenish_buf: Vec::new(), evict_buf: Vec::new(), preface_received: 0, last_stream_id: 0, + last_promised_stream_id: 0, going_away: false, } } @@ -531,6 +597,7 @@ impl Connection { return Feed { consumed: bytes.len(), fatal: true, + yielded: false, }; } @@ -552,6 +619,7 @@ impl Connection { return Feed { consumed: avail, fatal: true, + yielded: false, }; } self.preface_received += avail; @@ -560,6 +628,7 @@ impl Connection { return Feed { consumed: offset, fatal: false, + yielded: false, }; } } @@ -577,17 +646,33 @@ impl Connection { self.finish_streamed_data(sink, &inflight); } else { self.data_in_flight = Some(inflight); + if sink.should_stop() { + return Feed { + consumed: offset, + fatal: false, + yielded: true, + }; + } self.replenish_windows(sink); return Feed { consumed: offset, fatal: false, + yielded: false, }; } } loop { + // Check before looking at the input, so a stop the last frame requested still yields when no bytes follow it. + if sink.should_stop() { + return Feed { + consumed: offset, + fatal: false, + yielded: true, + }; + } let remaining = &bytes[offset..]; - if remaining.len() < wire::FRAME_HEADER_SIZE || sink.should_stop() { + if remaining.len() < wire::FRAME_HEADER_SIZE { break; } let hdr = FrameHeader::parse(remaining); @@ -603,6 +688,7 @@ impl Connection { return Feed { consumed: offset, fatal: true, + yielded: false, }; } let total = wire::FRAME_HEADER_SIZE + hdr.length as usize; @@ -623,6 +709,7 @@ impl Connection { return Feed { consumed: offset, fatal: true, + yielded: false, }; } StreamedDataStart::Consumed(n) => { @@ -638,6 +725,7 @@ impl Connection { return Feed { consumed: offset + total, fatal: true, + yielded: false, }; } offset += total; @@ -645,6 +733,7 @@ impl Connection { return Feed { consumed: offset, fatal: true, + yielded: false, }; } } @@ -652,6 +741,14 @@ impl Connection { return Feed { consumed: offset, fatal: true, + yielded: false, + }; + } + if sink.should_stop() { + return Feed { + consumed: offset, + fatal: false, + yielded: true, }; } // Re-open consumed receive windows once per batch (RFC 9113 §6.9; mirrors how the @@ -661,6 +758,7 @@ impl Connection { Feed { consumed: offset, fatal: false, + yielded: false, } } @@ -690,9 +788,7 @@ impl Connection { } self.replenish_buf = buf; // Evict closed streams so the map (and this scan) stay bounded on long-lived connections. - // Late DATA for an evicted id is dropped (is_closed_stream). A late HEADERS for an - // evicted id re-opens a fresh entry (the parity check still applies); that matches how - // trailers-after-close are treated as a new block by the legacy parser as well. + // Late DATA or HEADERS for an evicted id is dropped. let mut evict = std::mem::take(&mut self.evict_buf); evict.clear(); for (id, s) in self.streams.iter() { @@ -755,10 +851,8 @@ impl Connection { Some(FrameType::PushPromise) => self.handle_push_promise(sink, hdr, payload), Some(FrameType::AltSvc) => self.handle_altsvc(sink, hdr, payload), Some(FrameType::Origin) => self.handle_origin(sink, hdr, payload), - // PRIORITY has no scheduling effect here; structurally validated above, otherwise ignored. - Some(FrameType::Priority) => false, - // §4.1: unknown frame types are silently discarded. - _ => false, + // Both are ignored (§4.1 for unknown types). nghttp2 charges each one as a glitch. + Some(FrameType::Priority) | None => self.note_glitch(sink), } } @@ -772,27 +866,26 @@ impl Connection { ); return true; } - // An ACK with no outstanding SETTINGS submission is unsolicited; nghttp2 silently - // ignores it (it never reaches node's HandleSettingsFrame defensive branch). The - // queue can also be empty during the legacy-parser bridge handoff (its - // pending_settings_window_submissions is drained into this queue between batches), - // so the first ACK falls back to local_settings rather than being dropped. - if self.local_settings_acked && self.pending_local_settings_acks.is_empty() { - return false; - } - self.local_settings_acked = true; // §6.5.3: this ACK acknowledges the oldest outstanding SETTINGS, whose values may // differ from the latest submission when several SETTINGS are in flight. - let acked = - self.pending_local_settings_acks - .pop_front() - .unwrap_or(PendingLocalSettings { - settings: self.local_settings, - }); + let Some(acked) = self.pending_local_settings_acks.pop_front() else { + self.send_go_away(sink, ErrorCode::ProtocolError, b"SETTINGS: unexpected ACK"); + return true; + }; + // §6.9.2: the peer shifted its send window for every stream by this delta. + let delta = + acked.settings.initial_window_size as i64 - self.acked_local_initial_window as i64; + if delta != 0 { + for s in self.streams.values_mut() { + s.recv_window.grow(delta); + } + } self.acked_local_initial_window = acked.settings.initial_window_size; // The peer has acknowledged this submission: header-list enforcement may now use the // limit it carried. self.enforced_max_header_list_size = acked.settings.max_header_list_size; + self.hpack + .set_acked_header_table_size(acked.settings.header_table_size); sink.on_local_settings(&acked.settings); return false; } @@ -811,7 +904,6 @@ impl Connection { self.send_go_away(sink, code, b"SETTINGS value out of range"); return true; } - let old_table = self.remote_settings.header_table_size; let old_initial_window = self.remote_settings.initial_window_size; let mut i = 0; while i + 6 <= payload.len() { @@ -837,16 +929,16 @@ impl Connection { return true; } self.remote_settings.apply(sid, value); + // Per entry, because the peer's decoder evicts at each one (RFC 7541 §4.2). + if sid == SettingId::HeaderTableSize { + self.hpack.set_peer_header_table_size(value); + sink.on_remote_header_table_size(value); + } } else { sink.on_remote_custom_setting(id, value); } i += 6; } - // The peer's HEADER_TABLE_SIZE governs OUR encoder; queue a 6.3 size update. - if self.remote_settings.header_table_size != old_table { - self.hpack - .queue_encoder_capacity(self.remote_settings.header_table_size); - } // 6.9.2: a change to SETTINGS_INITIAL_WINDOW_SIZE adjusts every non-closed stream's send // window by the delta (the connection window is not affected). if self.remote_settings.initial_window_size != old_initial_window { @@ -912,6 +1004,27 @@ impl Connection { true } + /// node's Http2Session::OnInvalidFrame, `count++ > max`. Returns true when the session was torn down. + fn count_invalid_frame(&mut self, sink: &impl Sink) -> bool { + let count = self.invalid_frame_count; + self.invalid_frame_count = count.saturating_add(1); + if count <= self.max_invalid_frames { + return false; + } + self.terminated = true; + sink.on_too_many_invalid_frames(); + true + } + + /// nghttp2's session_update_glitch_ratelim. Returns true when the session was torn down. + fn note_glitch(&mut self, sink: &impl Sink) -> bool { + if self.glitch_limit.drain() { + return false; + } + self.send_go_away(sink, ErrorCode::EnhanceYourCalm, b"too many ignored frames"); + true + } + /// Bound queued PING/SETTINGS ACKs behind a non-reading peer — nghttp2's /// obq_flood_counter_ / NGHTTP2_ERR_FLOODED. The counter is only reset by /// note_outbound_drained() when the transport actually drains, so detection @@ -1002,28 +1115,38 @@ impl Connection { sink.on_stream_reset(hdr.stream_id, ErrorCode::ProtocolError.as_u32()); return false; } + let overflow = match sink.credit_send_window(hdr.stream_id, increment) { + SendCredit::Applied => false, + SendCredit::Overflow => true, + SendCredit::NotOwned => { + let window = if hdr.stream_id == 0 { + Some(&mut self.send_window) + } else { + self.streams + .get_mut(&hdr.stream_id) + .map(|s| &mut s.send_window) + }; + window.is_some_and(|w| w.increase(increment).is_err()) + } + }; + if !overflow { + return false; + } if hdr.stream_id == 0 { // 6.9.1: the connection window must not exceed 2^31-1. - if self.send_window.increase(increment).is_err() { - self.send_go_away( - sink, - ErrorCode::FlowControlError, - b"connection flow-control window overflow", - ); - return true; - } - } else if let Some(s) = self.streams.get_mut(&hdr.stream_id) { - // 6.9.1: a per-stream overflow is a stream error, not a connection error. - if s.send_window.increase(increment).is_err() { - self.send_rst_stream(sink, hdr.stream_id, ErrorCode::FlowControlError); - if let Some(s) = self.streams.get_mut(&hdr.stream_id) { - s.state = State::Closed; - } - sink.on_stream_reset(hdr.stream_id, ErrorCode::FlowControlError.as_u32()); - return false; - } + self.send_go_away( + sink, + ErrorCode::FlowControlError, + b"connection flow-control window overflow", + ); + return true; + } + // 6.9.1: a per-stream overflow is a stream error, not a connection error. + self.send_rst_stream(sink, hdr.stream_id, ErrorCode::FlowControlError); + if let Some(s) = self.streams.get_mut(&hdr.stream_id) { + s.state = State::Closed; } - sink.on_window_update(hdr.stream_id, increment); + sink.on_stream_reset(hdr.stream_id, ErrorCode::FlowControlError.as_u32()); false } @@ -1064,15 +1187,12 @@ impl Connection { let end_stream = wire::flags::has(hdr.flags, wire::flags::END_STREAM); // §5.1.1: a server's inbound HEADERS opens (or continues) a client-initiated odd stream. - // Our receive window is sized by OUR advertised SETTINGS_INITIAL_WINDOW_SIZE; our send - // window by the PEER's (§6.9.2). + // The receive window starts at the last INITIAL_WINDOW_SIZE the peer ACKed (§6.9.2). let send_init = self.remote_settings.initial_window_size; - let recv_init = self.local_settings.initial_window_size; + let recv_init = self.acked_local_initial_window; let is_new = !self.streams.contains_key(&hdr.stream_id); // RFC 9113 5.1.1: client-initiated streams use odd ids - a server receiving HEADERS that - // would open an even-id stream is a connection PROTOCOL_ERROR. (Monotonicity is not - // checked here: a client legitimately receives HEADERS on even promised ids that are - // numerically below its own latest odd id.) + // would open an even-id stream is a connection PROTOCOL_ERROR. if is_new && self.is_server && hdr.stream_id.is_multiple_of(2) { self.send_go_away( sink, @@ -1081,13 +1201,27 @@ impl Connection { ); return true; } + // An id with no entry is a new request, the first frame of our own live request, or closed. + let closed = is_new + && if self.is_server { + hdr.stream_id <= self.last_stream_id + } else { + !sink.is_local_stream(hdr.stream_id) + }; + if closed && !self.has_existed(sink, hdr.stream_id) { + self.send_go_away(sink, ErrorCode::ProtocolError, b"HEADERS on idle stream"); + return true; + } + let is_new = is_new && !closed; let refused = is_new && self.is_server && !sink.can_open_stream(); - let mut disposition = if refused { + let mut disposition = if closed { + BlockDisposition::StreamClosed + } else if refused { BlockDisposition::Refused } else { BlockDisposition::Deliver }; - if !refused { + if disposition == BlockDisposition::Deliver { let s = self .streams .entry(hdr.stream_id) @@ -1096,12 +1230,8 @@ impl Connection { s.opened_after_goaway = sink.goaway_sent(); } let cur_state = s.state; - let ev = if end_stream { - stream::Event::RecvHeadersEndStream - } else { - stream::Event::RecvHeaders - }; - match stream::transition(cur_state, ev) { + // finish_header_block applies END_STREAM, so a stream it closes isn't evicted with its block still open. + match stream::transition(cur_state, stream::Event::RecvHeaders) { Ok(next) => { if let Some(s) = self.streams.get_mut(&hdr.stream_id) { s.state = next; @@ -1121,9 +1251,7 @@ impl Connection { // of type STREAM_CLOSED — node surfaces it as NghttpError "Stream was already // closed or invalid" and tears the session down. A stream that closed for any // other reason (e.g. we reset it and the peer's trailers were already in - // flight) keeps the conservative stream-level handling: the block is still - // decoded for HPACK sync (§4.3), then refused with RST_STREAM(STREAM_CLOSED) - // by finish_header_block. + // flight) has its block decoded for HPACK sync (§4.3) and dropped. if cur_state == State::HalfClosedRemote { self.local_connection_error( sink, @@ -1144,8 +1272,9 @@ impl Connection { if hdr.stream_id > self.last_stream_id { self.last_stream_id = hdr.stream_id; } - if !refused { - sink.on_stream_open(hdr.stream_id); + // nghttp2 reports a stream over the limit to node as an invalid frame. + if !refused && !sink.on_stream_open(hdr.stream_id) && self.count_invalid_frame(sink) { + return true; } } @@ -1177,6 +1306,7 @@ impl Connection { if !wire::flags::has(hdr.flags, wire::flags::END_HEADERS) { self.header_block_in_flight = Some(HeaderBlockInFlight { continuation_stream: hdr.stream_id, + continuations: 0, meta, }); return false; @@ -1186,7 +1316,7 @@ impl Connection { /// RFC 9113 §6.10 CONTINUATION: append the fragment; complete the block on END_HEADERS. fn handle_continuation(&mut self, sink: &impl Sink, hdr: &FrameHeader, payload: &[u8]) -> bool { - let Some(inflight) = self.header_block_in_flight.take() else { + let Some(mut inflight) = self.header_block_in_flight.take() else { // §6.10: a CONTINUATION with no header block in progress is a connection PROTOCOL_ERROR. self.send_go_away( sink, @@ -1196,6 +1326,17 @@ impl Connection { return true; }; // dispatch() already enforced that we are assembling this exact stream. + inflight.continuations += 1; + if inflight.continuations > MAX_CONTINUATIONS { + // nghttp2 sends GOAWAY(INTERNAL_ERROR) here. + self.local_connection_error( + sink, + ErrorCode::InternalError, + wire::lib_error::TOO_MANY_CONTINUATIONS, + b"too many CONTINUATION frames", + ); + return true; + } // Cap the reassembled block at the header-list limit (floored so tiny custom settings // don't reject normal blocks): HPACK output is never smaller than its input, so a // compressed block already past max_header_list_size can only decode past it too — @@ -1232,7 +1373,9 @@ impl Connection { } = *meta; // Surface the push reservation before its request headers so the embedder can create the // pushed stream object that the on_header calls populate. - if let Some(parent) = push_parent { + if let Some(parent) = push_parent + && disposition == BlockDisposition::Deliver + { sink.on_push_promise(parent.get(), target); } let block = std::mem::take(&mut self.header_block); @@ -1268,6 +1411,8 @@ impl Connection { let mut origin_form = false; let mut asterisk_form = false; let mut informational = false; + let mut no_body_status = false; + let mut block_method = RequestMethod::Other; let mut content_length: Option = None; while off < block.len() { match self.hpack.decode(&block[off..]) { @@ -1350,9 +1495,17 @@ impl Connection { seen_pseudo |= bit; if rest == b"method" && value_b == b"CONNECT" { saw_connect = true; + // nghttp2's http_request_on_header refuses CONNECT in a PUSH_PROMISE. + malformed |= push_parent.is_some(); } match bit { - pseudo::METHOD => saw_options = value_b == b"OPTIONS", + pseudo::METHOD => { + saw_options = value_b == b"OPTIONS"; + block_method = RequestMethod::from_method(value_b); + } + pseudo::STATUS => { + no_body_status = matches!(value_b, b"204" | b"304"); + } pseudo::SCHEME => { http_scheme = value_b.eq_ignore_ascii_case(b"http") || value_b.eq_ignore_ascii_case(b"https"); @@ -1427,16 +1580,7 @@ impl Connection { sink.on_stream_rejected(target); return false; } - BlockDisposition::StreamClosed => { - // §5.1: HEADERS on a closed/half-closed-remote stream is a stream error of type - // STREAM_CLOSED. The block was decoded above purely for HPACK-table sync. - self.send_rst_stream(sink, target, ErrorCode::StreamClosed); - if let Some(s) = self.streams.get_mut(&target) { - s.state = State::Closed; - } - sink.on_stream_reset(target, ErrorCode::StreamClosed.as_u32()); - return false; - } + BlockDisposition::StreamClosed => return self.note_glitch(sink), BlockDisposition::Deliver => {} } // RFC 9113 §8.3.1 (nghttp2_http_on_request_headers): a request block needs exactly one @@ -1457,11 +1601,29 @@ impl Connection { || (http_scheme && !origin_form && !(asterisk_form && saw_options)) }; } - if push_parent.is_none() && self.is_server && !malformed && !rejected { + // nghttp2_http_on_response_headers: a 1xx or final response block needs :status. + if !self.is_server && !is_request && !is_trailer && !rejected { + malformed |= (seen_pseudo & pseudo::STATUS) == 0; + } + if push_parent.is_none() && !malformed && !rejected { if let Some(s) = self.streams.get_mut(&target) { - s.recv_host = saw_host; - if !saw_connect && s.content_length.is_none() { - s.content_length = content_length; + if self.is_server { + s.recv_host = saw_host; + if !saw_connect && s.content_length.is_none() { + s.content_length = content_length; + } + } else if !informational && s.content_length.is_none() { + if s.request_method.is_none() { + s.request_method = sink.request_method(target); + } + // nghttp2_http_on_response_headers: HEAD, 204 and 304 have no body, and CONNECT's DATA is a tunnel. + s.content_length = match s.request_method { + None => None, + Some(RequestMethod::Head) => Some(0), + Some(_) if no_body_status => Some(0), + Some(RequestMethod::Connect) => None, + Some(RequestMethod::Other) => content_length, + }; } if end_stream && s.content_length @@ -1471,15 +1633,12 @@ impl Connection { } } } + // RFC 9113 §8.1: a 1xx block isn't the final response, so it can't end the stream. + if informational && end_stream { + malformed = true; + } if malformed && !rejected { - // node (Http2Session::OnInvalidFrame): every locally-rejected invalid frame counts - // against maxSessionInvalidFrames; exceeding it tears the session down with - // ERR_HTTP2_TOO_MANY_INVALID_FRAMES (same post-increment comparison as node). - let count = self.invalid_frame_count; - self.invalid_frame_count = count.saturating_add(1); - if count > self.max_invalid_frames { - self.terminated = true; - sink.on_too_many_invalid_frames(); + if self.count_invalid_frame(sink) { return true; } // RFC 9113 §8.2: a malformed header block gets a stream error of type PROTOCOL_ERROR and @@ -1509,12 +1668,17 @@ impl Connection { { s.recv_final_headers = true; } + if push_parent.is_some() + && let Some(s) = self.streams.get_mut(&target) + { + s.request_method = Some(block_method); + } sink.on_headers_complete(target, end_stream, flags); - if end_stream { - let state = self.streams.get(&target).map(|s| s.state as u8); - if let Some(state) = state { - sink.on_stream_end(target, state); + if end_stream && let Some(s) = self.streams.get_mut(&target) { + if let Ok(next) = stream::transition(s.state, stream::Event::RecvEndStream) { + s.state = next; } + sink.on_stream_end(target, s.state as u8); } false } @@ -1557,9 +1721,9 @@ impl Connection { if !self.streams.contains_key(&hdr.stream_id) && sink.is_local_stream(hdr.stream_id) { let send_init = self.remote_settings.initial_window_size; - let recv_init = self.local_settings.initial_window_size; + let recv_init = self.acked_local_initial_window; let mut s = Stream::new(send_init, recv_init); - s.state = State::Open; + s.state = self.local_stream_state(); self.streams.insert(hdr.stream_id, s); } let recv_limit = self @@ -1575,12 +1739,12 @@ impl Connection { discard = true; } Some(st) => { - if !stream::can_receive_data(st.state) { - self.send_rst_stream(sink, hdr.stream_id, ErrorCode::StreamClosed); + if let Some(code) = st.data_refusal(self.is_server) { + self.send_rst_stream(sink, hdr.stream_id, code); if let Some(st2) = self.streams.get_mut(&hdr.stream_id) { st2.state = State::Closed; } - sink.on_stream_reset(hdr.stream_id, ErrorCode::StreamClosed.as_u32()); + sink.on_stream_reset(hdr.stream_id, code.as_u32()); discard = true; } else { st.recv_window.on_data(hdr.length as i64); @@ -1654,6 +1818,15 @@ impl Connection { } } + /// State of a stream the embedder opened, at its first inbound DATA. A server only opens pushed streams, which can't receive DATA (§5.1). + fn local_stream_state(&self) -> State { + if self.is_server { + State::HalfClosedRemote + } else { + State::Open + } + } + fn has_existed(&self, sink: &impl Sink, stream_id: u32) -> bool { stream_id <= self.last_stream_id || stream_id <= sink.highest_started_stream_id() } @@ -1697,16 +1870,12 @@ impl Connection { } // An empty DATA frame that does not end the stream carries no information and is only - // useful for flooding: count it against the session's invalid-frame allowance (node's - // maxSessionInvalidFrames; same post-increment comparison as node). - if payload.is_empty() && !wire::flags::has(hdr.flags, wire::flags::END_STREAM) { - let count = self.invalid_frame_count; - self.invalid_frame_count = count.saturating_add(1); - if count > self.max_invalid_frames { - self.terminated = true; - sink.on_too_many_invalid_frames(); - return true; - } + // useful for flooding. + if payload.is_empty() + && !wire::flags::has(hdr.flags, wire::flags::END_STREAM) + && self.count_invalid_frame(sink) + { + return true; } // Per-stream flow control + state check, decided under a scoped borrow so the self.* calls @@ -1716,13 +1885,13 @@ impl Connection { FlowControlViolation, Deliver(u32), } - // Transition shim: DATA for a stream the embedder opened locally (legacy outbound) — open it + // Transition shim: DATA for a stream the embedder opened locally (legacy outbound) — enter it // here so it isn't mistaken for a closed/idle stream. if !self.streams.contains_key(&hdr.stream_id) && sink.is_local_stream(hdr.stream_id) { let send_init = self.remote_settings.initial_window_size; - let recv_init = self.local_settings.initial_window_size; + let recv_init = self.acked_local_initial_window; let mut s = Stream::new(send_init, recv_init); - s.state = State::Open; + s.state = self.local_stream_state(); self.streams.insert(hdr.stream_id, s); } if self.is_closed_stream(sink, hdr.stream_id) { @@ -1735,11 +1904,12 @@ impl Connection { // §5.1: DATA for a stream nobody opened is a STREAM_CLOSED error. None => DataDecision::Rst(ErrorCode::StreamClosed), Some(s) => { - if !stream::can_receive_data(s.state) { - DataDecision::Rst(ErrorCode::StreamClosed) + if let Some(code) = s.data_refusal(self.is_server) { + DataDecision::Rst(code) } else { s.recv_window.on_data(consumed); - if s.recv_window.is_overflowed_with(recv_limit) { + // §6.9.1: an empty frame takes no window, even one a SETTINGS change left negative. + if consumed > 0 && s.recv_window.is_overflowed_with(recv_limit) { DataDecision::FlowControlViolation } else { s.recv_body_bytes = s.recv_body_bytes.saturating_add((end - off) as u64); @@ -1806,13 +1976,10 @@ impl Connection { false } - /// RFC 9113 §8.1.1: once END_STREAM arrives, a request whose received DATA total contradicts + /// RFC 9113 §8.1.1: once END_STREAM arrives, a message whose received DATA total contradicts /// its declared `content-length` is malformed. Resets the stream with PROTOCOL_ERROR instead /// of signalling end-of-stream and returns true if it did so. fn enforce_content_length(&mut self, sink: &impl Sink, stream_id: u32) -> bool { - if !self.is_server { - return false; - } let mismatch = self.streams.get(&stream_id).is_some_and(|s| { s.content_length .is_some_and(|declared| declared != s.recv_body_bytes) @@ -1847,7 +2014,7 @@ impl Connection { // though this engine never saw its HEADERS go out. if on_idle && sink.is_local_stream(hdr.stream_id) { let send_init = self.remote_settings.initial_window_size; - let recv_init = self.local_settings.initial_window_size; + let recv_init = self.acked_local_initial_window; let s = self .streams .entry(hdr.stream_id) @@ -1933,9 +2100,8 @@ impl Connection { payload[off + 3], ]) & 0x7fff_ffff; off += 4; - // §5.1.1 / §8.4: server-initiated streams use even ids, never 0, and - // cannot be reused. - if promised == 0 || promised & 1 == 1 || self.streams.contains_key(&promised) { + // §5.1.1 / §8.4: server-initiated streams use even ids, each above the last. + if promised & 1 == 1 || promised <= self.last_promised_stream_id { self.send_go_away( sink, ErrorCode::ProtocolError, @@ -1944,14 +2110,18 @@ impl Connection { return true; } - // Reserve the promised (even) stream. - let send_init = self.remote_settings.initial_window_size; - let recv_init = self.local_settings.initial_window_size; - let entry = self - .streams - .entry(promised) - .or_insert_with(|| Stream::new(send_init, recv_init)); - entry.state = State::ReservedRemote; + // Like nghttp2, don't reserve the stream after our GOAWAY. Later frames on its id are dropped as frames on a closed stream. + let discard = sink.goaway_sent(); + if !discard { + let send_init = self.remote_settings.initial_window_size; + let recv_init = self.acked_local_initial_window; + let entry = self + .streams + .entry(promised) + .or_insert_with(|| Stream::new(send_init, recv_init)); + entry.state = State::ReservedRemote; + } + self.last_promised_stream_id = promised; if promised > self.last_stream_id { self.last_stream_id = promised; } @@ -1965,13 +2135,20 @@ impl Connection { flags: 0, end_stream: false, // PUSH_PROMISE carries a request; it never ends the stream is_request: true, - disposition: BlockDisposition::Deliver, + disposition: if discard { + BlockDisposition::StreamClosed + } else { + BlockDisposition::Deliver + }, }; self.finish_or_park_header_block(sink, hdr, meta) } /// RFC 7838 §4 ALTSVC: optional 2-byte origin-length + origin, then the Alt-Svc field value. fn handle_altsvc(&mut self, sink: &impl Sink, hdr: &FrameHeader, payload: &[u8]) -> bool { + if self.is_server { + return self.note_glitch(sink); + } if payload.len() < 2 { return false; // malformed ALTSVC is ignored (§4) } @@ -1981,12 +2158,8 @@ impl Connection { } let origin = &payload[2..2 + origin_len]; let value = &payload[2 + origin_len..]; - // RFC 7838 4 MUST-ignore rules: a server never accepts ALTSVC; on stream 0 the origin - // must be present; on a request stream it must be empty (the stream's own origin applies). - if self.is_server - || (hdr.stream_id == 0 && origin.is_empty()) - || (hdr.stream_id != 0 && !origin.is_empty()) - { + // RFC 7838 §4: the origin must be present on stream 0 and empty on a request stream. + if (hdr.stream_id == 0 && origin.is_empty()) || (hdr.stream_id != 0 && !origin.is_empty()) { return false; } sink.on_altsvc(hdr.stream_id, origin, value); @@ -1998,8 +2171,8 @@ impl Connection { // §2.1: ORIGIN on a non-zero stream is ignored, and like ALTSVC it is server-to-client // only - a server receiving it must ignore it. The whole payload is delivered once; the // embedder iterates the (2-byte length, origin) entries and surfaces a single event. - if hdr.stream_id != 0 || self.is_server { - return false; + if hdr.stream_id != 0 || self.is_server || hdr.flags & 0xf0 != 0 { + return self.note_glitch(sink); } sink.on_origin(payload); false @@ -2010,9 +2183,7 @@ impl Connection { /// Begin a new outbound header block. Emits any pending §6.3 dynamic-table size update first. pub(crate) fn begin_header_block(&mut self) { self.enc_buf.clear(); - let mut tmp = [0u8; hpack::MAX_SIZE_UPDATE_BYTES]; - let n = self.hpack.take_pending_size_update(&mut tmp, 0); - self.enc_buf.extend_from_slice(&tmp[..n]); + self.hpack.take_pending_size_update(&mut self.enc_buf); } /// HPACK-encode one header field into the current block. Returns false on encode failure. @@ -2077,7 +2248,7 @@ impl Connection { self.enc_buf.clear(); let send_init = self.remote_settings.initial_window_size; - let recv_init = self.local_settings.initial_window_size; + let recv_init = self.acked_local_initial_window; let s = self .streams .entry(stream_id) @@ -2203,7 +2374,7 @@ impl Connection { self.enc_buf.clear(); let send_init = self.remote_settings.initial_window_size; - let recv_init = self.local_settings.initial_window_size; + let recv_init = self.acked_local_initial_window; let s = self .streams .entry(promised_id) @@ -2259,9 +2430,9 @@ mod tests { fn on_go_away(&self, c: u32, l: u32, _d: &[u8]) { self.goaway.set(Some((c, l))); } - fn on_window_update(&self, _id: u32, _inc: u32) {} - fn on_stream_open(&self, id: u32) { + fn on_stream_open(&self, id: u32) -> bool { self.opens.borrow_mut().push(id); + true } fn on_header(&self, id: u32, name: &[u8], value: &[u8], _never: bool) { self.headers @@ -2295,7 +2466,7 @@ mod tests { /// Encode a header block with a standalone coder (mirrors a real peer's encoder). fn encode_block(pairs: &[(&[u8], &[u8])]) -> Vec { - let mut coder = hpack::Coder::new(4096); + let mut coder = hpack::Coder::new(); let mut buf = vec![0u8; 4096]; let mut off = 0usize; for (name, value) in pairs { diff --git a/src/runtime/api/bun/h2/hpack.rs b/src/runtime/api/bun/h2/hpack.rs index ac84c8048116..3ea310ed83f5 100644 --- a/src/runtime/api/bun/h2/hpack.rs +++ b/src/runtime/api/bun/h2/hpack.rs @@ -8,46 +8,78 @@ #![allow(dead_code)] -use bun_http::lshpack::{DecodeResult, HpackError, HpackHandle}; +use bun_http::lshpack::{DecodeResult, HeaderBlock, HpackError, HpackHandle}; -/// RFC 7541 §6.3: a Dynamic Table Size Update integer never needs more than 6 bytes for a u32. -pub(crate) const MAX_SIZE_UPDATE_BYTES: usize = 6; +/// Two RFC 7541 §6.3 updates (the minimum, then the final size) of up to 6 bytes each. +pub(crate) const MAX_SIZE_UPDATE_BYTES: usize = 12; + +/// RFC 9113 §6.5.2: both sides start at this SETTINGS_HEADER_TABLE_SIZE. +const DEFAULT_HEADER_TABLE_SIZE: u32 = 4096; + +/// Node's default `maxDeflateDynamicTableSize`, so a peer can't make us retain more header history. +const MAX_ENCODER_TABLE_SIZE: u32 = 4096; pub(crate) struct Coder { hpack: HpackHandle, enc_capacity: u32, - /// A capacity change requested by the peer's SETTINGS_HEADER_TABLE_SIZE, applied + announced at - /// the start of the next encoded header block. `None` = nothing pending. - pending_enc_capacity: Option, + /// The smallest encoder capacity since the last header block, until a block announces it (RFC 7541 §4.2). + unannounced_min: Option, } impl Coder { - pub(crate) fn new(max_capacity: u32) -> Self { + pub(crate) fn new() -> Self { Coder { - hpack: HpackHandle::new(max_capacity), - enc_capacity: max_capacity, - pending_enc_capacity: None, + hpack: HpackHandle::new(DEFAULT_HEADER_TABLE_SIZE), + enc_capacity: DEFAULT_HEADER_TABLE_SIZE, + unannounced_min: None, } } - /// Schedule an encoder capacity change from a received SETTINGS_HEADER_TABLE_SIZE. Applied - /// lazily so the §6.3 size-update opcode is emitted inside the next header block. - pub(crate) fn queue_encoder_capacity(&mut self, capacity: u32) { - if capacity == self.enc_capacity && self.pending_enc_capacity.is_none() { + /// Call for every SETTINGS_HEADER_TABLE_SIZE entry the peer sends, in wire order. + pub(crate) fn set_peer_header_table_size(&mut self, size: u32) { + let capacity = size.min(MAX_ENCODER_TABLE_SIZE); + if capacity == self.enc_capacity && self.unannounced_min.is_none() { return; } - self.pending_enc_capacity = Some(capacity); + self.hpack.set_encoder_max_capacity(capacity); + self.enc_capacity = capacity; + self.unannounced_min = Some( + self.unannounced_min + .map_or(capacity, |min| min.min(capacity)), + ); } - /// If a capacity change is pending, apply it and write the §6.3 size-update opcode into `dst` at - /// `offset`. Returns bytes written (0 if none). `dst[offset..]` needs >= MAX_SIZE_UPDATE_BYTES. - pub(crate) fn take_pending_size_update(&mut self, dst: &mut [u8], offset: usize) -> usize { - let Some(cap) = self.pending_enc_capacity.take() else { - return 0; + /// Call when the peer ACKs our SETTINGS. Until then its encoder still uses the previous size. + pub(crate) fn set_acked_header_table_size(&mut self, size: u32) { + self.hpack.set_decoder_max_capacity(size); + } + + /// Appends the pending §6.3 size updates to `block`, which must be the start of a header block. + pub(crate) fn take_pending_size_update(&mut self, block: &mut Vec) { + let Some(min) = self.unannounced_min.take() else { + return; }; - self.hpack.set_encoder_max_capacity(cap); - self.enc_capacity = cap; - write_table_size_update(dst, offset, cap) + if min < self.enc_capacity { + write_table_size_update(block, min); + } + write_table_size_update(block, self.enc_capacity); + } + + /// Appends one whole header block to `dst`. `Err` appends nothing and leaves the encoder unchanged. + pub(crate) fn encode_block( + &mut self, + block: &HeaderBlock, + dst: &mut Vec, + ) -> Result<(), HpackError> { + let start = dst.len(); + let unannounced_min = self.unannounced_min; + self.take_pending_size_update(dst); + let result = self.hpack.encode_block(block, dst); + if result.is_err() { + dst.truncate(start); + self.unannounced_min = unannounced_min; + } + result } #[inline] @@ -69,25 +101,19 @@ impl Coder { } } -/// RFC 7541 §5.1 + §6.3: encode `value` as a 5-bit-prefix integer with the `001` pattern (0x20) -/// into `dst[offset..]`. Returns bytes written. -fn write_table_size_update(dst: &mut [u8], offset: usize, value: u32) -> usize { - let mut i = offset; +/// RFC 7541 §5.1 + §6.3: append `value` as a 5-bit-prefix integer with the `001` pattern (0x20). +fn write_table_size_update(block: &mut Vec, value: u32) { if value < 31 { - dst[i] = 0x20 | value as u8; - return 1; + block.push(0x20 | value as u8); + return; } - dst[i] = 0x20 | 31; - i += 1; + block.push(0x20 | 31); let mut rest = value - 31; while rest >= 128 { - dst[i] = (rest as u8) | 0x80; - i += 1; + block.push((rest as u8) | 0x80); rest >>= 7; } - dst[i] = rest as u8; - i += 1; - i - offset + block.push(rest as u8); } #[cfg(test)] @@ -96,17 +122,16 @@ mod tests { #[test] fn size_update_small() { - let mut buf = [0u8; 6]; - assert_eq!(write_table_size_update(&mut buf, 0, 30), 1); - assert_eq!(buf[0], 0x20 | 30); + let mut block = Vec::new(); + write_table_size_update(&mut block, 30); + assert_eq!(block, [0x20 | 30]); } #[test] fn size_update_large() { - let mut buf = [0u8; 6]; - // 4096 = 31 + 4065; 4065 = 0b111_1110_0001 -> 0xE1, 0x1F - let n = write_table_size_update(&mut buf, 0, 4096); - assert_eq!(buf[0], 0x3f); // 0x20 | 31 - assert!(n >= 2); + let mut block = Vec::new(); + // 4096 = 31 + 4065; 4065 = 0b11111_1100001 -> 0xE1, 0x1F + write_table_size_update(&mut block, 4096); + assert_eq!(block, [0x3f, 0xe1, 0x1f]); } } diff --git a/src/runtime/api/bun/h2/stream.rs b/src/runtime/api/bun/h2/stream.rs index e44dd9dab1f2..b6e5a58b8c92 100644 --- a/src/runtime/api/bun/h2/stream.rs +++ b/src/runtime/api/bun/h2/stream.rs @@ -104,7 +104,7 @@ pub(crate) fn transition(state: State, ev: Event) -> Result bool { matches!(state, State::Open | State::HalfClosedLocal) diff --git a/src/runtime/api/bun/h2/wire.rs b/src/runtime/api/bun/h2/wire.rs index 3606f16b9caa..08fe1b9d5893 100644 --- a/src/runtime/api/bun/h2/wire.rs +++ b/src/runtime/api/bun/h2/wire.rs @@ -112,6 +112,8 @@ pub(crate) mod lib_error { pub(crate) const BAD_CLIENT_MAGIC: i32 = -903; /// NGHTTP2_ERR_FLOODED — "Flooding was detected in this HTTP/2 session, and it must be closed" pub(crate) const FLOODED: i32 = -904; + /// NGHTTP2_ERR_TOO_MANY_CONTINUATIONS — "Too many CONTINUATION frames following a HEADER frame" + pub(crate) const TOO_MANY_CONTINUATIONS: i32 = -905; } /// RFC 9113 §6.5.2 SETTINGS parameter registry (+ RFC 8441, RFC 9218). diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index 55d12619a107..25389f4bccc8 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -11,6 +11,7 @@ use core::mem::ManuallyDrop; use core::ptr::NonNull; use std::borrow::Cow; +use crate::api::h2::connection::RequestMethod; use crate::api::socket::{TCPSocket, TLSSocket}; use crate::node::{Encoding, StringOrBuffer}; use crate::socket::NativeCallbacks; @@ -19,13 +20,14 @@ use bstr::BStr; use bun_collections::{ByteVecExt, HashMap as BunHashMap, HiveArrayFallback, VecExt}; use bun_core::strings; use bun_http::lshpack; -use bun_jsc::AbortSignal; use bun_jsc::ErrorCode as JscErrorCode; use bun_jsc::StringJsc as _; use bun_jsc::array_buffer::BinaryType; +use bun_jsc::rare_data::H2HeaderScratch; use bun_jsc::virtual_machine::VirtualMachine; use bun_jsc::{ - CallFrame, GlobalRef, JSGlobalObject, JSValue, JsCell, JsClass, JsRef, JsResult, StrongOptional, + CallFrame, GlobalRef, JSGlobalObject, JSValue, JsCell, JsClass, JsRef, JsResult, Strong, + StrongOptional, }; use bun_ptr::RefPtr; @@ -181,7 +183,6 @@ impl Drop for NativeSocket { } unsafe extern "C" { - safe fn Bun__wrapAbortError(global_object: &JSGlobalObject, cause: JSValue) -> JSValue; /// One-call materialization of a decoded header block: returns the /// [rawHeadersArray, headersObject, sensitiveArray|undefined] tuple, or a /// zero JSValue with a JS exception pending. See H2HeadersMaterializer.cpp. @@ -256,9 +257,8 @@ const MAX_FRAME_SIZE_F64: f64 = MAX_FRAME_SIZE as f64; // the JS caller (Http2Stream._write/_writev) completes it asynchronously. Mirrored in // src/js/node/http2.ts (kWriteFlushedWithoutCallback). const WRITE_FLUSHED_WITHOUT_CALLBACK: u32 = 0x10; -// RFC 7541 Section 4.1: Each header entry has 32 bytes of overhead -// for the HPACK dynamic table entry structure -const HPACK_ENTRY_OVERHEAD: usize = 32; +// https://github.com/nodejs/node/blob/v26.3.0/deps/nghttp2/lib/nghttp2_frame.h#L58 +const NGHTTP2_MAX_HEADERSLEN: u32 = 65536; // Maximum number of custom settings (same as Node.js MAX_ADDITIONAL_SETTINGS) const MAX_CUSTOM_SETTINGS: usize = 10; @@ -315,7 +315,6 @@ enum HeadersFrameFlags { END_STREAM = 0x1, END_HEADERS = 0x4, PADDED = 0x8, - PRIORITY = 0x20, } // Open set of wire values → newtype over u32 @@ -327,7 +326,6 @@ impl ErrorCode { const INTERNAL_ERROR: Self = Self(0x2); const FRAME_SIZE_ERROR: Self = Self(0x6); const REFUSED_STREAM: Self = Self(0x7); - const CANCEL: Self = Self(0x8); const COMPRESSION_ERROR: Self = Self(0x9); const ENHANCE_YOUR_CALM: Self = Self(0xb); const MAX_PENDING_SETTINGS_ACK: Self = Self(0xe); @@ -354,13 +352,6 @@ impl UInt31WithReserved { fn init(value: u32, reserved: bool) -> Self { Self((value & 0x7fff_ffff) | if reserved { 0x8000_0000 } else { 0 }) } - /// Note: the wire format (RFC 7540 §6.3) wants the reserved/E bit at bit - /// 31, so the layout is `(reserved << 31) | uint31`, which matches - /// `write` and the on-wire `StreamPriority.stream_identifier`. - #[inline] - fn to_uint32(self) -> u32 { - self.0 - } #[inline] fn write(self, writer: &mut impl WireWriter) -> bool { let mut value: u32 = self.uint31(); @@ -372,29 +363,6 @@ impl UInt31WithReserved { } } -// packed struct(u40): streamIdentifier: u32, weight: u8 -#[repr(C, packed)] -#[derive(Clone, Copy, Default)] -struct StreamPriority { - stream_identifier: u32, - weight: u8, -} -// SAFETY: `#[repr(C, packed)]` with `u32 + u8` fields — no padding, no niches, -// every 5-byte pattern is a valid value. -unsafe impl bytemuck::Zeroable for StreamPriority {} -// SAFETY: see `Zeroable` impl above; additionally `Copy + 'static`. -unsafe impl bytemuck::Pod for StreamPriority {} -const _: () = assert!(core::mem::size_of::() == StreamPriority::BYTE_SIZE); -impl StreamPriority { - pub(crate) const BYTE_SIZE: usize = 5; - #[inline] - fn write(self, writer: &mut impl WireWriter) -> bool { - let mut swap = self; - swap.stream_identifier = swap.stream_identifier.swap_bytes(); - writer.write_all(bytemuck::bytes_of(&swap)).is_ok() - } -} - // packed struct(u72): length: u24, type: u8, flags: u8, streamIdentifier: u32 // `length` is u24 on the wire; widened to u32 here (Rust has no u24). The 3-byte // big-endian encoding is handled explicitly in write()/decode(). @@ -951,14 +919,90 @@ struct SendDataOptions { defer_write_callback: bool, } -struct DispatchGuard<'a>(&'a Cell); +/// Frames count as sent once serialized, so `detach(true)` keeps the parser attached until the socket has taken them. +#[derive(Clone, Copy, Default, PartialEq, Eq)] +enum Draining { + #[default] + No, + /// Attached only to write the buffered output. No streams, no JS context, no inbound frames. + Output, + /// Also sends the FIN the socket asked for in the meantime (`defer_shutdown`). + OutputThenFin, +} + +struct DepthGuard<'a>(&'a Cell); + +impl<'a> DepthGuard<'a> { + fn enter(depth: &'a Cell) -> Self { + depth.set(depth.get() + 1); + DepthGuard(depth) + } +} -impl Drop for DispatchGuard<'_> { +impl Drop for DepthGuard<'_> { fn drop(&mut self) { self.0.set(self.0.get() - 1); } } +struct FlagGuard<'a>(&'a Cell); + +impl<'a> FlagGuard<'a> { + fn set(flag: &'a Cell) -> Self { + flag.set(true); + FlagGuard(flag) + } +} + +impl Drop for FlagGuard<'_> { + fn drop(&mut self) { + self.0.set(false); + } +} + +/// A callback queued while `engine.receive()` runs. `rewrite_read` calls it after it releases the engine borrow. +enum Deferred { + /// `handlers.(ctx, args...)` + Event { + event: JSH2FrameParser::Gc, + args: [DeferredArg; 4], + argc: u8, + }, + WriteCallback(Strong), + /// Runs all of `Sink::on_stream_end`. It changes stream state JS can observe, so it must stay behind earlier callbacks. + StreamEnd { + stream_id: u32, + state: u8, + }, + /// Runs all of `Sink::on_stream_reset`, queued for the same reason. + StreamReset { + stream_id: u32, + code: u32, + }, +} + +enum DeferredArg { + Immediate(JSValue), + Cell(Strong), + /// The stream's JS context, looked up at delivery like an inline dispatch does. + StreamCtx(u32), +} + +/// The first argument of an event: a value, or the stream whose JS context to pass. +#[derive(Clone, Copy)] +enum Target { + Value(JSValue), + Stream(u32), +} + +struct ClearOnDrop<'a>(&'a Cell); + +impl Drop for ClearOnDrop<'_> { + fn drop(&mut self) { + self.0.set(false); + } +} + /// Follows the byte stream `write()` emits over a JS-backed transport and reports when it /// sits at a point where another frame may legally begin: between frames, and not inside a /// header block (HEADERS / PUSH_PROMISE without END_HEADERS up to the CONTINUATION that carries @@ -1039,7 +1083,7 @@ impl Drop for Keepalive<'_> { /// (option getters, header-value `toString`) cannot free the stream out from under the borrow. struct GuardedStream<'a> { stream: &'a mut Stream, - _dispatch: DispatchGuard<'a>, + _dispatch: DepthGuard<'a>, } impl core::ops::Deref for GuardedStream<'_> { @@ -1100,8 +1144,8 @@ pub(crate) struct H2FrameParser { // local Window limits the download of data // current window size for the connection window_size: Cell, - // used window size for the connection - used_window_size: Cell, + // connection window the peer was granted: 65535 plus every setLocalWindowSize() increment + advertised_window_size: Cell, // remote Window limits the upload of data // remote window size for the connection @@ -1115,14 +1159,6 @@ pub(crate) struct H2FrameParser { /// Receive-window growth requested by setLocalWindowSize() while a dispatch held the engine /// borrow; applied by rewrite_read() on its next pass. pending_recv_window_growth: Cell, - /// Bridge: outbound DATA bytes the legacy encoder wrote since the engine last ran, applied to - /// the engine's connection-level send window in rewrite_read (the engine cell may be borrowed - /// when the DATA goes out). Without this the engine's window only ever grows and a compliant - /// peer's cumulative WINDOW_UPDATEs would eventually trip the §6.9.1 overflow error. - pending_send_window_consumed: Cell, - /// Same bridge per stream: (stream id, bytes) pairs drained into the engine's per-stream send - /// windows in rewrite_read. - pending_stream_send_consumed: JsCell>, /// Local-settings snapshot of each SETTINGS frame the legacy encoder sent, in send order, /// drained into the engine's per-SETTINGS ack queue in rewrite_read (§6.5.3: ACKs apply in /// order). @@ -1163,12 +1199,15 @@ pub(crate) struct H2FrameParser { // we buffer requests until we get the first settings ACK write_buffer: JsCell>, write_buffer_offset: Cell, + /// True while flush_stream_queue() is on the stack. + flushing_stream_queue: Cell, // TODO: this will be removed when I re-add header and data priorization outbound_queue_size: Cell, streams: JsCell>, - hpack: JsCell>, + /// Encodes outbound header blocks. The engine's own `Coder` decodes inbound ones. + hpack: JsCell>, has_nonnative_backpressure: Cell, /// True while flush() has bytes out in an onWrite dispatch to a JS-backed socket. @@ -1176,6 +1215,7 @@ pub(crate) struct H2FrameParser { /// A native write returned a terminal result (socket closed, shut down, or the kernel /// rejected the send). Latched once; the deferred tick closes the transport. transport_write_fatal: Cell, + draining: Cell, /// An outbound header block the HPACK encoder could not emit. Latched once; the deferred /// tick reports it, because it is detected inside a user submit call. pending_header_compression_error: Cell, @@ -1203,6 +1243,13 @@ pub(crate) struct H2FrameParser { /// Unconsumed inbound tail (the engine holds no reassembly buffer — design B): bytes after the /// last complete frame are kept here and prepended to the next read(). rewrite_tail: JsCell>, + /// Read cursor into `rewrite_tail` while a batch has its remainder parked there, otherwise 0. + rewrite_tail_pos: Cell, + /// `engine.receive()` is on the stack, so events go to `deferred`. + receiving: Cell, + deferred: JsCell>, + /// `rewrite_read` calls on the stack. + batch_depth: Cell, /// Promised stream id whose PUSH_PROMISE header block is being delivered by the engine (its /// on_headers_complete dispatches onStreamPush instead of onStreamHeaders). rewrite_pending_push: Cell, @@ -1260,20 +1307,6 @@ impl H2FrameParser { /// Hold a ref on `self` for the guard's lifetime (across re-entrant calls). #[inline] - fn abort_stream_for_signal(&self, stream_id: u32, reason: JSValue) { - bun_output::scoped_log!(H2FrameParser, "abortListener"); - reason.ensure_still_alive(); - let Some(stream) = self.streams.get().get(&stream_id).copied() else { - return; - }; - // SAFETY: stream is a *mut Stream from self.streams (heap::alloc); valid while the map entry exists - let stream = unsafe { &mut *stream }; - if stream.state != StreamState::CLOSED { - let wrapped = Bun__wrapAbortError(&self.global_this, reason); - self.abort_stream(stream, wrapped); - } - } - pub(crate) fn ref_guard(&self) -> RefPtr { // SAFETY: `self` is the live heap allocation. unsafe { RefPtr::init_ref(self.as_ctx_ptr()) } @@ -1349,7 +1382,6 @@ pub(crate) enum FlushState { #[repr(u8)] #[derive(Clone, Copy, PartialEq, Eq)] enum StreamState { - IDLE = 1, OPEN = 2, HALF_CLOSED_LOCAL = 5, HALF_CLOSED_REMOTE = 6, @@ -1361,19 +1393,16 @@ pub(crate) struct Stream { state: StreamState, js_context: StrongOptional, // jsc.Strong.Optional wait_for_trailers: bool, - end_after_headers: bool, padding_strategy: PaddingStrategy, rst_code: u32, - weight: u16, + // `:method` of the request sent on this stream. The engine never sees outbound HEADERS, so it asks for it. + request_method: Option, // current window size for the stream window_size: u64, - // used window size for the stream - used_window_size: u64, // remote window size for the stream remote_window_size: u64, // remote used window size for the stream remote_used_window_size: u64, - signal: Option>, // The JS readable for this stream is paused (setStreamReading(id, false)): the engine defers // replenishing the stream's receive window until reading resumes, backpressuring the peer. reading_paused: bool, @@ -1382,30 +1411,6 @@ pub(crate) struct Stream { data_frame_queue: PendingQueue, } -pub(crate) struct SignalRef { - abort_handle: bun_jsc::AbortHandle, - // TODO: We should not need this ref counting here, since Parser owns Stream - parser: RefPtr, - stream_id: u32, -} - -bun_jsc::impl_abort_handle_owner!( - SignalRef, - abort_handle, - keep_alive = |this| -> RefPtr { - // SAFETY: trait contract — `this` is live. - unsafe { (*this).parser.ref_guard() } - }, - |this, cause| { - if let bun_jsc::AbortCause::Signal(reason) = cause { - // SAFETY: trait contract — `this` is live; aborting the stream frees it. - let (parser, stream_id) = unsafe { ((*this).parser.as_ptr(), (*this).stream_id) }; - // SAFETY: `keep_alive` holds the parser. - unsafe { (*parser).abort_stream_for_signal(stream_id, reason) }; - } - } -); - #[derive(Default)] struct PendingQueue { data: Vec, @@ -1516,21 +1521,21 @@ impl Stream { let mut writer = client.to_writer(); if frame_len == 0 { - // flush a zero payload frame let Some(frame) = self.data_frame_queue.dequeue() else { return FlushState::NoAction; }; + let end_stream = frame.end_stream && !self.wait_for_trailers; + owned_frame = Some(frame); + // Same rule as send_data: an empty DATA frame only goes out to carry END_STREAM. + if !end_stream { + break 'brk true; + } let data_header = FrameHeader { type_: FrameType::HTTP_FRAME_DATA as u8, - flags: if frame.end_stream && !self.wait_for_trailers { - DataFrameFlags::END_STREAM as u8 - } else { - 0 - }, + flags: DataFrameFlags::END_STREAM as u8, stream_identifier: self.id, length: 0, }; - owned_frame = Some(frame); break 'brk data_header.write(&mut writer, &client.frames_sent_legacy); } else { let max_size = frame_remaining @@ -1599,7 +1604,6 @@ impl Stream { client .remote_used_window_size .set(client.remote_used_window_size.get() + payload_size as u64); - client.note_engine_send_consumed(self.id, payload_size as u64); let mut flags: u8 = 0; // we ignore end_stream for now because we know we have more data to send if padding != 0 { @@ -1648,7 +1652,6 @@ impl Stream { client .remote_used_window_size .set(client.remote_used_window_size.get() + payload_size as u64); - client.note_engine_send_consumed(self.id, payload_size as u64); let mut flags: u8 = if frame.end_stream && !self.wait_for_trailers { DataFrameFlags::END_STREAM as u8 } else { @@ -1866,17 +1869,12 @@ impl Stream { state: StreamState::OPEN, js_context: StrongOptional::empty(), wait_for_trailers: false, - end_after_headers: false, padding_strategy, rst_code: 0, - // RFC 7540 §5.3.5 / nghttp2 NGHTTP2_DEFAULT_WEIGHT: streams default to weight 16, - // which is what stream.state.weight reports when no priority was signaled. - weight: 16, + request_method: None, window_size: initial_window_size as u64, - used_window_size: 0, remote_window_size: remote_window_size as u64, remote_used_window_size: 0, - signal: None, reading_paused: false, data_frame_queue: PendingQueue::default(), } @@ -1891,14 +1889,14 @@ impl Stream { pub(crate) fn can_receive_data(&self) -> bool { matches!( self.state, - StreamState::IDLE | StreamState::OPEN | StreamState::HALF_CLOSED_LOCAL + StreamState::OPEN | StreamState::HALF_CLOSED_LOCAL ) } pub(crate) fn can_send_data(&self) -> bool { matches!( self.state, - StreamState::IDLE | StreamState::OPEN | StreamState::HALF_CLOSED_REMOTE + StreamState::OPEN | StreamState::HALF_CLOSED_REMOTE ) } @@ -1916,20 +1914,6 @@ impl Stream { .unwrap_or_else(|| JSValue::js_number(self.id as f64)) } - pub(crate) fn attach_signal(&mut self, parser: &H2FrameParser, signal: &mut AbortSignal) { - let signal_ref = bun_core::heap::into_raw(Box::new(SignalRef { - abort_handle: bun_jsc::AbortHandle::for_owner::(), - parser: parser.ref_guard(), - stream_id: self.id, - })); - // SAFETY: `signal_ref` is a live heap allocation; both calls keep its provenance. - let signal_ref = unsafe { - bun_jsc::AbortHandle::follow_owner(signal_ref, signal.ref_()); - bun_core::heap::take(signal_ref) - }; - self.signal = Some(signal_ref); - } - pub(crate) fn detach_context(&mut self) { self.js_context.deinit(); } @@ -1988,69 +1972,80 @@ impl Stream { } self.detach_context(); self.clean_queue::(client); - if let Some(signal) = self.signal.take() { - drop(signal); + } +} + +/// The per-VM header scratch, held for one outbound header call and put back on drop. +struct HeaderScratch<'a> { + global: &'a JSGlobalObject, + scratch: H2HeaderScratch, +} + +impl<'a> HeaderScratch<'a> { + fn take(global: &'a JSGlobalObject) -> Self { + Self { + global, + scratch: global + .bun_vm() + .as_mut() + .rare_data() + .take_h2_header_scratch(), } } } +impl Drop for HeaderScratch<'_> { + fn drop(&mut self) { + self.global + .bun_vm() + .as_mut() + .rare_data() + .put_back_h2_header_scratch(core::mem::take(&mut self.scratch)); + } +} + +impl core::ops::Deref for HeaderScratch<'_> { + type Target = H2HeaderScratch; + fn deref(&self) -> &H2HeaderScratch { + &self.scratch + } +} + +impl core::ops::DerefMut for HeaderScratch<'_> { + fn deref_mut(&mut self) -> &mut H2HeaderScratch { + &mut self.scratch + } +} + // ────────────────────────────────────────────────────────────────────────── // H2FrameParser impl — core methods // ────────────────────────────────────────────────────────────────────────── impl H2FrameParser { - /// Encodes a single header into the ArrayList, growing if needed. - /// Returns the number of bytes written, or error on failure. - /// - /// Capacity estimation: name.len + value.len + HPACK_ENTRY_OVERHEAD - fn encode_header_into_list( - &self, - encoded_headers: &mut Vec, - name: &[u8], - value: &[u8], - never_index: bool, - ) -> crate::Result { - let old_len = encoded_headers.len(); - let required = old_len + name.len() + value.len() + HPACK_ENTRY_OVERHEAD; - // Note: materializing `&mut [u8]` over uninitialized capacity is UB and - // hpack.encode() needs `&mut [u8]` (not `&mut [MaybeUninit]`), so zero-extend to - // `required` first. On both Ok and Err we truncate so `len` never exposes scratch - // bytes. - encoded_headers.resize(required, 0); - match self.encode( - encoded_headers.as_mut_slice(), - old_len, - name, - value, - never_index, - ) { - Ok(bytes_written) => { - encoded_headers.truncate(old_len + bytes_written); - Ok(bytes_written) - } - Err(e) => { - encoded_headers.truncate(old_len); - Err(e) - } - } - } - - pub(crate) fn encode( - &self, - dst_buffer: &mut [u8], - dst_offset: usize, - name: &[u8], - value: &[u8], - never_index: bool, - ) -> crate::Result { - self.hpack.with_mut(|hpack| { - if let Some(hpack) = hpack.as_mut() { - // lets make sure the name is lowercase - return hpack - .encode(name, value, never_index, dst_buffer, dst_offset) - .map_err(crate::Error::from); - } - Err(crate::Error::UnableToEncode) + /// nghttp2 counts the priority bytes of every HEADERS frame: https://github.com/nodejs/node/blob/v26.3.0/deps/nghttp2/lib/nghttp2_session.c#L2095-L2101 + fn is_over_send_limit(&self, block: &lshpack::HeaderBlock) -> bool { + const PRIORITY_SPEC_LEN: usize = 5; + block.deflate_bound() + PRIORITY_SPEC_LEN > self.max_send_header_block_length.get() as usize + } + + /// Encodes all of `scratch.block` into `scratch.encoded`, or nothing on error. + fn encode_header_list(&self, scratch: &mut H2HeaderScratch) -> crate::Result<()> { + scratch.encoded.clear(); + // 256 extra bytes for a PADDED frame's pad-length byte and padding. + scratch + .encoded + .try_reserve( + crate::api::h2::hpack::MAX_SIZE_UPDATE_BYTES + scratch.block.encode_bound() + 256, + ) + .map_err(|_| bun_alloc::AllocError)?; + bun_output::scoped_log!( + H2FrameParser, + "encode header block: {} fields", + scratch.block.len() + ); + self.hpack.with_mut(|hpack| match hpack.as_mut() { + Some(hpack) => Ok(hpack.encode_block(&scratch.block, &mut scratch.encoded)?), + None => Err(crate::Error::UnableToEncode), }) } @@ -2136,42 +2131,6 @@ impl H2FrameParser { true } - pub(crate) fn abort_stream(&self, stream: &mut Stream, abort_reason: JSValue) { - bun_output::scoped_log!( - H2FrameParser, - "HTTP_FRAME_RST_STREAM id: {} code: CANCEL", - stream.id - ); - - abort_reason.ensure_still_alive(); - let mut buffer = [0u8; FrameHeader::BYTE_SIZE + 4]; - let mut writer_stream = FixedBufferStream::new(&mut buffer); - - let frame = FrameHeader { - type_: FrameType::HTTP_FRAME_RST_STREAM as u8, - flags: 0, - stream_identifier: stream.id, - length: 4, - }; - let _ = frame.write(&mut writer_stream, &self.frames_sent_legacy); - let mut value: u32 = ErrorCode::CANCEL.0; - stream.rst_code = value; - value = value.swap_bytes(); - let _ = writer_stream.write_all(&value.to_ne_bytes()); - let old_state = stream.state; - stream.state = StreamState::CLOSED; - let identifier = stream.get_identifier(); - identifier.ensure_still_alive(); - stream.free_resources::(self); - self.dispatch_with_2_extra( - JSH2FrameParser::Gc::onAborted, - identifier, - abort_reason, - JSValue::js_number(old_state as u8 as f64), - ); - let _ = self.write(&buffer); - } - pub(crate) fn end_stream(&self, stream: &mut Stream, rst_code: ErrorCode) { bun_output::scoped_log!( H2FrameParser, @@ -2201,11 +2160,13 @@ impl H2FrameParser { let identifier = stream.get_identifier(); identifier.ensure_still_alive(); stream.free_resources::(self); + // The handler can submit a queued request, and its HEADERS must follow this frame. + let _ = self.write(&buffer); if rst_code == ErrorCode::NO_ERROR { self.dispatch_with_extra( JSH2FrameParser::Gc::onStreamEnd, identifier, - JSValue::js_number(stream.state as u8 as f64), + JSValue::js_number(StreamState::CLOSED as u8 as f64), ); } else { self.dispatch_with_extra( @@ -2214,8 +2175,6 @@ impl H2FrameParser { JSValue::js_number(rst_code.0 as f64), ); } - - let _ = self.write(&buffer); } pub(crate) fn send_go_away( @@ -2400,9 +2359,8 @@ impl H2FrameParser { /// Armed across every JS dispatch wrapper AND every section that holds a `&mut Stream` /// while user JS can run (property getters, iteration, string coercion), so /// rewrite_read's deferred stream free (pending_engine_stream_closes) only runs at depth 0. - fn enter_dispatch(&self) -> DispatchGuard<'_> { - self.dispatch_depth.set(self.dispatch_depth.get() + 1); - DispatchGuard(&self.dispatch_depth) + fn enter_dispatch(&self) -> DepthGuard<'_> { + DepthGuard::enter(&self.dispatch_depth) } /// Reborrows a host fn's `*mut Stream` with the dispatch guard armed for the borrow's whole @@ -2419,21 +2377,87 @@ impl H2FrameParser { } } - pub(crate) fn dispatch(&self, event: JSH2FrameParser::Gc, value: JSValue) { - value.ensure_still_alive(); + /// Calls `handlers.(ctx, args...)` now. + fn deliver(&self, event: JSH2FrameParser::Gc, args: &[JSValue]) { let Some(this_value) = self.strong_this.get().try_get() else { return; }; let Some(ctx_value) = JSH2FrameParser::Gc::context.get(this_value) else { return; }; + let mut argv = [ctx_value; 5]; + argv[1..=args.len()].copy_from_slice(args); let _dispatch = self.enter_dispatch(); - let _ = self.handlers.get().call_event_handler( - event, - this_value, - ctx_value, - &[ctx_value, value], - ); + let handlers = self.handlers.get(); + let _ = handlers.call_event_handler(event, this_value, ctx_value, &argv[..=args.len()]); + } + + /// Delivers an event, or queues it while `receive()` is on the stack so its JS never runs under the engine borrow. + fn dispatch_event(&self, event: JSH2FrameParser::Gc, target: Target, extra: &[JSValue]) { + for value in extra { + value.ensure_still_alive(); + } + if !self.receiving.get() { + let mut args = [JSValue::UNDEFINED; 4]; + args[0] = match target { + Target::Value(value) => value, + Target::Stream(stream_id) => self.rewrite_stream_ctx(stream_id), + }; + args[0].ensure_still_alive(); + args[1..=extra.len()].copy_from_slice(extra); + return self.deliver(event, &args[..=extra.len()]); + } + // An empty value failed to materialize. `should_skip_dispatch` would drop the event, and a `Strong` can't hold it. + if extra.contains(&JSValue::ZERO) + || matches!(target, Target::Value(v) if v == JSValue::ZERO) + { + return; + } + let global = self.global(); + let root = |value: JSValue| { + if value.is_cell() { + DeferredArg::Cell(Strong::create(value, &global)) + } else { + DeferredArg::Immediate(value) + } + }; + let mut args = [const { DeferredArg::Immediate(JSValue::UNDEFINED) }; 4]; + args[0] = match target { + Target::Value(value) => root(value), + Target::Stream(stream_id) => DeferredArg::StreamCtx(stream_id), + }; + for (slot, value) in args[1..].iter_mut().zip(extra) { + *slot = root(*value); + } + let argc = 1 + extra.len() as u8; + self.deferred + .with_mut(|q| q.push_back(Deferred::Event { event, args, argc })); + } + + /// Runs the callbacks the last `receive()` queued. A callback can re-enter `rewrite_read`, which drains the same queue. + fn run_deferred(&self) { + while let Some(entry) = self.deferred.with_mut(|q| q.pop_front()) { + match entry { + Deferred::Event { event, args, argc } => { + let mut values = [JSValue::UNDEFINED; 4]; + for (value, arg) in values.iter_mut().zip(&args[..argc as usize]) { + *value = match arg { + DeferredArg::Immediate(v) => *v, + DeferredArg::Cell(strong) => strong.get(), + DeferredArg::StreamCtx(id) => self.rewrite_stream_ctx(*id), + }; + } + self.deliver(event, &values[..argc as usize]); + } + Deferred::WriteCallback(callback) => self.dispatch_write_callback(callback.get()), + Deferred::StreamEnd { stream_id, state } => self.stream_end_now(stream_id, state), + Deferred::StreamReset { stream_id, code } => self.stream_reset_now(stream_id, code), + } + } + } + + pub(crate) fn dispatch(&self, event: JSH2FrameParser::Gc, value: JSValue) { + self.dispatch_event(event, Target::Value(value), &[]); } /// `onWrite(context, bytes, transportClosed)`. The flag is true once the native socket closed. @@ -2458,6 +2482,14 @@ impl H2FrameParser { } pub(crate) fn dispatch_write_callback(&self, callback: JSValue) { + if self.receiving.get() { + if callback.is_callable() { + let callback = Strong::create(callback, &self.global()); + self.deferred + .with_mut(|q| q.push_back(Deferred::WriteCallback(callback))); + } + return; + } let _dispatch = self.enter_dispatch(); let _ = self.handlers.get().call_write_callback(callback, &[]); } @@ -2468,21 +2500,7 @@ impl H2FrameParser { value: JSValue, extra: JSValue, ) { - let Some(this_value) = self.strong_this.get().try_get() else { - return; - }; - let Some(ctx_value) = JSH2FrameParser::Gc::context.get(this_value) else { - return; - }; - value.ensure_still_alive(); - extra.ensure_still_alive(); - let _dispatch = self.enter_dispatch(); - let _ = self.handlers.get().call_event_handler( - event, - this_value, - ctx_value, - &[ctx_value, value, extra], - ); + self.dispatch_event(event, Target::Value(value), &[extra]); } pub(crate) fn dispatch_with_2_extra( @@ -2492,7 +2510,7 @@ impl H2FrameParser { extra: JSValue, extra2: JSValue, ) { - self.dispatch_values(event, [value, extra, extra2]); + self.dispatch_event(event, Target::Value(value), &[extra, extra2]); } pub(crate) fn dispatch_with_3_extra( @@ -2503,24 +2521,7 @@ impl H2FrameParser { extra2: JSValue, extra3: JSValue, ) { - self.dispatch_values(event, [value, extra, extra2, extra3]); - } - - fn dispatch_values(&self, event: JSH2FrameParser::Gc, values: [JSValue; N]) { - let Some(this_value) = self.strong_this.get().try_get() else { - return; - }; - let Some(ctx_value) = JSH2FrameParser::Gc::context.get(this_value) else { - return; - }; - let mut args = [ctx_value; 5]; - args[1..=N].copy_from_slice(&values); - for value in values { - value.ensure_still_alive(); - } - let _dispatch = self.enter_dispatch(); - let handlers = self.handlers.get(); - let _ = handlers.call_event_handler(event, this_value, ctx_value, &args[..=N]); + self.dispatch_event(event, Target::Value(value), &[extra, extra2, extra3]); } /// A header block the HPACK encoder cannot emit fails the whole session in nghttp2, so node @@ -2744,6 +2745,11 @@ impl H2FrameParser { /// be sure that we dont have any backpressure/data queued on writerBuffer before calling this fn flush_stream_queue(&self) -> usize { + // A write callback that calls flush() would nest one level per queued stream. The loop below picks up its frames. + if self.flushing_stream_queue.replace(true) { + return 0; + } + let _flushing = ClearOnDrop(&self.flushing_stream_queue); bun_output::scoped_log!( H2FrameParser, "flushStreamQueue {}", @@ -2760,6 +2766,10 @@ impl H2FrameParser { // SAFETY: stream is a *mut Stream from self.streams (heap::alloc); valid while the // map entry exists. Separate heap allocation from `self`, so no aliasing. let stream = unsafe { &mut *stream }; + // The cork hides a refused write from flush_queue() until a write callback flushes it. + if self.has_backpressure() { + return written; + } // reach backpressure let result = stream.flush_queue(self, &mut written); match result { @@ -2926,6 +2936,11 @@ impl H2FrameParser { self.write_buffer.get().len_u32() > 0 || self.has_nonnative_backpressure.get() } + /// Checks this stream's queue only. Another stream's queue can wait on its own window forever. + fn must_queue_data(&self, stream: &Stream) -> bool { + self.has_backpressure() || !stream.data_frame_queue.is_empty() + } + /// Whether a write to this session's transport synchronously runs user JS: a JS-backed /// socket's onWrite is the user's Duplex, and a socket upgraded from a JS Duplex /// (`tls.connect({ socket })`) writes its records through that Duplex. @@ -3116,18 +3131,20 @@ impl H2FrameParser { pub(crate) fn on_auto_flush(&self) -> bool { let _keepalive = self.keepalive(); if self.transport_write_fatal.get() { + // The close cancels open streams, so deliver the rest of this frame (its END_STREAM) first. + if !self.deferred.get().is_empty() { + return true; + } // Returning `false` makes DeferredTaskQueue::run remove the entry // itself, so only the registration's flag and ref are released here - // - never a re-entrant map mutation from inside run(). The flag is - // cleared before the close so the teardown paths the close re-enters - // (detach -> unregister_auto_flush) see an unregistered flusher and - // early-return instead of removing a map entry run() still owns. + // - never a re-entrant map mutation from inside run(). self.auto_flusher.get().registered.set(false); self.deref(); // An empty write buffer here means a later write in the same flush() // cycle already drained the bytes the failing send left behind (racy // one-off errnos, e.g. macOS EPROTOTYPE) - the transport recovered. if self.has_backpressure() { + // `registered` is already clear, so the detach() this re-enters leaves run()'s map entry alone. self.close_transport_after_fatal_write(); } else { self.transport_write_fatal.set(false); @@ -3473,6 +3490,29 @@ impl H2FrameParser { bun_core::String::clone_latin1(payload).into_js(&global) } + /// Adds a stream to `streams` without dispatching onStreamStart. + fn insert_stream(&self, stream_identifier: u32, state: StreamState) -> *mut Stream { + let local_window_size = if self.outstanding_settings.get() > 0 { + DEFAULT_WINDOW_SIZE as u32 + } else { + self.local_settings.get().initial_window_size + }; + let mut stream = Stream::init( + stream_identifier, + local_window_size, + self.remote_settings + .get() + .map(|s| s.initial_window_size) + .unwrap_or(DEFAULT_WINDOW_SIZE as u32), + self.padding_strategy.get(), + ); + stream.state = state; + let stream = bun_core::heap::into_raw(Box::new(stream)); + self.streams + .with_mut(|s| s.insert(stream_identifier, stream)); + stream + } + /// Returned *Stream is heap-allocated and stable for the lifetime of this H2FrameParser. fn handle_received_stream_id(&self, stream_identifier: u32) -> Option<*mut Stream> { // connection stream @@ -3495,28 +3535,13 @@ impl H2FrameParser { self.last_peer_stream_id.set(stream_identifier); } - // new stream open - let local_window_size = if self.outstanding_settings.get() > 0 { - DEFAULT_WINDOW_SIZE as u32 + // The peer has no half to close on a push, so our END_STREAM closes it (RFC 9113 5.1). + let state = if self.is_server.get() && stream_identifier.is_multiple_of(2) { + StreamState::HALF_CLOSED_REMOTE } else { - self.local_settings.get().initial_window_size + StreamState::OPEN }; - let mut new_stream = Stream::init( - stream_identifier, - local_window_size, - self.remote_settings - .get() - .map(|s| s.initial_window_size) - .unwrap_or(DEFAULT_WINDOW_SIZE as u32), - self.padding_strategy.get(), - ); - // The peer has no half to close on a push, so our END_STREAM closes it (RFC 9113 5.1). - if self.is_server.get() && stream_identifier.is_multiple_of(2) { - new_stream.state = StreamState::HALF_CLOSED_REMOTE; - } - let stream = bun_core::heap::into_raw(Box::new(new_stream)); - self.streams - .with_mut(|s| s.insert(stream_identifier, stream)); + let stream = self.insert_stream(stream_identifier, state); let Some(this_value) = self.strong_this.get().try_get() else { return Some(stream); @@ -3567,6 +3592,11 @@ impl H2FrameParser { }); self.enter_stream_dispatch(stream) .set_context(returned, &global); + } else if returned.is_number() { + // streamStart refused the stream and returned the RST_STREAM code to answer it with. + let mut refused = self.enter_stream_dispatch(stream); + self.end_stream(&mut refused, ErrorCode(returned.to_u32())); + return None; } Some(stream) } @@ -3636,26 +3666,6 @@ impl H2FrameParser { JSValue::UNDEFINED } - /// Record outbound DATA the legacy encoder wrote so the engine's send windows track reality. - /// Buffered in cells and applied in rewrite_read: inbound WINDOW_UPDATE handling always goes - /// through rewrite_read first, so the windows are in sync before any overflow check runs. - pub(crate) fn note_engine_send_consumed(&self, stream_id: u32, n: u64) { - if n == 0 { - return; - } - self.pending_send_window_consumed - .set(self.pending_send_window_consumed.get() + n); - self.pending_stream_send_consumed.with_mut(|v| { - if let Some(last) = v.last_mut() - && last.0 == stream_id - { - last.1 += n; - } else { - v.push((stream_id, n)); - } - }); - } - /// Mirror the engine's frame counters into plain Cells so getFrameCounters() never /// contends with the engine borrow (destroy can run inside a dispatch). fn sync_engine_frame_counters(&self) { @@ -3667,21 +3677,8 @@ impl H2FrameParser { } } - /// Feed inbound bytes through the rewrite engine, buffering the unconsumed tail (design B). - fn rewrite_read(&self, bytes: &[u8]) { - bun_output::scoped_log!(H2FrameParser, "rewriteRead {}", bytes.len()); - // Re-entrancy guard: receive() dispatches into JS between frames, and user code can feed - // bytes back into the same parser from inside such a dispatch (e.g. a custom Duplex whose - // write path synchronously pushes back into the socket). The engine cell stays mutably - // borrowed across the outer receive(), so queue the bytes for the outer call to drain - // rather than panicking on a second borrow. - if self.engine.try_borrow_mut().is_err() { - self.rewrite_tail.with_mut(|t| t.extend_from_slice(bytes)); - return; - } - self.ensure_engine(); - // Keep the engine's local settings in sync with the JS-configured cell (settings() may be - // called after the engine was lazily created). Plain Copy structs — no allocation. + /// Copy everything the legacy side changed since the last receive() into the engine. + fn sync_engine(&self, batch_start: bool) { if let Some(engine) = self.engine.borrow_mut().as_mut() { engine.local_settings = self.rewrite_local_settings(); engine.max_header_list_pairs = self.max_header_list_pairs.get(); @@ -3695,7 +3692,9 @@ impl H2FrameParser { // Outbound-ACK-flood counter: only reset when the transport actually // drained (nghttp2 decrements per-send). Resetting per receive() lets // a peer that never reads keep it under the limit forever. - if self.write_buffer.get().slice()[self.write_buffer_offset.get()..].is_empty() { + if batch_start + && self.write_buffer.get().slice()[self.write_buffer_offset.get()..].is_empty() + { engine.note_outbound_drained(); } // Apply any receive-window growth setLocalWindowSize() accumulated while a dispatch @@ -3704,29 +3703,6 @@ impl H2FrameParser { if pending > 0 { engine.recv_window.grow(pending); } - // Apply outbound DATA the legacy encoder wrote since the last batch, so the engine's - // send windows reflect what is actually in flight (§6.9.1 overflow stays peer-error - // only). - let sent = self.pending_send_window_consumed.replace(0); - if sent > 0 { - engine.send_window.consume(sent as i64); - } - self.pending_stream_send_consumed.with_mut(|v| { - // A client-initiated stream has no engine entry until its first inbound - // frame; dropping its consume here would leave the engine's send window - // permanently wider than the peer's view. Keep unmatched entries queued — - // but only while the legacy stream is still alive: a pushed stream the - // client never sends frames on would otherwise park its entry forever - // (and get scanned on every read). - v.retain(|&(id, n)| { - if let Some(s) = engine.streams.get_mut(&id) { - s.send_window.consume(n as i64); - false - } else { - self.streams.get().contains_key(&id) - } - }); - }); // Register SETTINGS submissions the legacy encoder sent since the last batch, so the // engine attributes each inbound ACK to the right submission (§6.5.3). self.pending_settings_window_submissions.with_mut(|v| { @@ -3737,7 +3713,7 @@ impl H2FrameParser { // Streams whose legacy lifecycle finished since the last batch: evict the engine // entry and free the legacy slot. free_resources already ran for these (it is the // only producer of this queue); duplicate ids are fine — remove() yields None. - if self.dispatch_depth.get() == 0 { + if batch_start && self.dispatch_depth.get() == 0 { self.pending_engine_stream_closes.with_mut(|v| { for id in v.drain(..) { engine.close_stream(id); @@ -3756,68 +3732,89 @@ impl H2FrameParser { }); } } - if self.rewrite_tail.get().is_empty() { - let feed = { - let mut guard = self.engine.borrow_mut(); - guard.as_mut().unwrap().receive(self, bytes) - }; - if feed.fatal { - // GOAWAY is on the wire and on_error tore the session down; feeding the - // remainder would only re-parse frames for a dead connection. - self.rewrite_tail.with_mut(|t| t.clear()); - let _ = self.flush(); - return; - } - let consumed = feed.consumed; - if consumed < bytes.len() { - self.rewrite_tail.with_mut(|t| { - // A reentrant read() during dispatch may have queued bytes already; this - // batch's unconsumed remainder precedes them on the wire. - let _ = t.splice(0..0, bytes[consumed..].iter().copied()); - }); - } - } else { - let mut combined = self.rewrite_tail.with_mut(std::mem::take); - combined.extend_from_slice(bytes); - let feed = { - let mut guard = self.engine.borrow_mut(); - guard.as_mut().unwrap().receive(self, &combined) - }; - if feed.fatal { - self.rewrite_tail.with_mut(|t| t.clear()); - let _ = self.flush(); - return; - } - let consumed = feed.consumed; - self.rewrite_tail.with_mut(|t| { - if consumed < combined.len() { - let _ = t.splice(0..0, combined[consumed..].iter().copied()); - } - }); + } + + /// Feed inbound bytes through the rewrite engine, buffering the unconsumed tail (design B). + fn rewrite_read(&self, bytes: &[u8]) { + bun_output::scoped_log!(H2FrameParser, "rewriteRead {}", bytes.len()); + if self.draining.get() != Draining::No { + return; } - // Drain bytes queued by reentrant reads during the dispatches above. Stop when the engine - // makes no progress (an incomplete frame waiting for more input stays in the tail). + // A JS transport can push bytes back while receive() is on the stack. The running batch picks them up. + if self.receiving.get() || self.engine.try_borrow_mut().is_err() { + self.rewrite_tail.with_mut(|t| t.extend_from_slice(bytes)); + return; + } + self.ensure_engine(); + // `rewrite_tail` is either an incomplete frame or the bytes an outer batch parked before running callbacks. + let mut owned: Vec = self.rewrite_tail.take(); + let mut pos = self.rewrite_tail_pos.replace(0).min(owned.len()); + // Parse the read in place when nothing is parked. It's only copied if a frame yields with bytes left behind it. + let mut direct = owned.is_empty(); + if !direct { + owned.extend_from_slice(bytes); + } + let mut first_pass = true; + let batch = DepthGuard::enter(&self.batch_depth); loop { - if self.rewrite_tail.get().is_empty() { - break; - } - let pending = self.rewrite_tail.with_mut(std::mem::take); + // A callback may have called settings() or setLocalWindowSize(), or written DATA. + self.sync_engine(first_pass); + first_pass = false; let feed = { + let input: &[u8] = if direct { &bytes[pos..] } else { &owned[pos..] }; + // The engine still calls some JS directly (streamStart, a JS transport's onWrite). Don't end it in a checkpoint. + let vm = self.global_this.bun_vm(); + let _no_checkpoint = vm.enter_event_loop_scope_without_checkpoint(); + let _receiving = FlagGuard::set(&self.receiving); let mut guard = self.engine.borrow_mut(); - guard.as_mut().unwrap().receive(self, &pending) + guard.as_mut().unwrap().receive(self, input) }; + pos += feed.consumed; + // Bytes a JS transport pushed back during receive() follow the current input. + let pushed = self.rewrite_tail.take(); + if !pushed.is_empty() { + if direct { + owned.extend_from_slice(&bytes[pos..]); + direct = false; + pos = 0; + } + owned.extend_from_slice(&pushed); + } if feed.fatal { - self.rewrite_tail.with_mut(|t| t.clear()); - let _ = self.flush(); - return; + // GOAWAY is on the wire and on_error tears the session down, so the rest is never parsed. + self.run_deferred(); + break; } - let consumed = feed.consumed; - self.rewrite_tail - .with_mut(|t| drop(t.splice(0..0, pending[consumed..].iter().copied()))); - if consumed == 0 { + if !feed.yielded && !pushed.is_empty() { + // The pushed bytes may complete a frame. + continue; + } + let stopped = self.left_exception.get() || self.draining.get() != Draining::No; + if !feed.yielded || stopped { + // The engine parsed every complete frame or has to stop. Keep the rest for the next read. + if direct { + owned.extend_from_slice(&bytes[pos..]); + } else { + owned.drain(..pos); + } + self.rewrite_tail.set(owned); + self.run_deferred(); break; } + // Park the rest first. A callback that spins the event loop reads this socket again and continues from here. + if direct { + owned.extend_from_slice(&bytes[pos..]); + direct = false; + pos = 0; + } + self.rewrite_tail.set(owned); + self.rewrite_tail_pos.set(pos); + self.run_deferred(); + // Call receive() again even if nothing is left. Only a call that doesn't yield replenishes the windows. + owned = self.rewrite_tail.take(); + pos = self.rewrite_tail_pos.replace(0).min(owned.len()); } + drop(batch); // Uncork: flush the engine's queued control/response frames to the socket. let _ = self.flush(); } @@ -3825,12 +3822,12 @@ impl H2FrameParser { /// The from-scratch engine calls back into H2FrameParser (the embedder) through this. impl crate::api::h2::connection::Sink for H2FrameParser { - /// A frame callback left an exception pending (a value it could not build): no later frame - /// in this batch is dispatched over it; `read()` throws it and `on_native_read` returns it to - /// the socket dispatch. (A termination between frames is what `run_callback`'s gate covers.) + /// Returns true if the last frame queued callbacks, left an exception for `read()` / `on_native_read`, or detached the parser. #[inline] fn should_stop(&self) -> bool { self.left_exception.get() + || self.draining.get() != Draining::No + || !self.deferred.get().is_empty() } fn on_frame_counters(&self, received: u64, sent: u64) { @@ -3931,6 +3928,14 @@ impl crate::api::h2::connection::Sink for H2FrameParser { }); } + fn on_remote_header_table_size(&self, size: u32) { + self.hpack.with_mut(|hpack| { + if let Some(hpack) = hpack.as_mut() { + hpack.set_peer_header_table_size(size); + } + }); + } + fn on_remote_settings(&self, settings: &crate::api::h2::settings::Settings) { // Bridge: the legacy outbound (frame sizing, window init) reads the remote_settings Cell. let fp = FullSettingsPayload { @@ -3942,21 +3947,22 @@ impl crate::api::h2::connection::Sink for H2FrameParser { max_header_list_size: settings.max_header_list_size, enable_connect_protocol: settings.enable_connect_protocol, }; + let old_initial_window = self + .remote_settings + .get() + .map(|s| s.initial_window_size) + .unwrap_or(DEFAULT_WINDOW_SIZE as u32); self.remote_settings.set(Some(fp)); - // §6.9.2 (mirrors the legacy inbound): when the peer's INITIAL_WINDOW_SIZE grows, raise the - // send window of streams opened before its SETTINGS arrived (a client's first request is - // typically sent before the server's SETTINGS lands), then resume queued sends. - let mut window_grew = false; - for (_, item) in self.streams.get().iter() { - // SAFETY: item is &*mut Stream from streams.iter(); the boxed Stream outlives the iteration - let stream = unsafe { &mut **item }; - if (settings.initial_window_size as u64) > stream.remote_window_size { - stream.remote_window_size = settings.initial_window_size as u64; - window_grew = true; - } - } - // Resume queued sends only when a window actually grew; there is nothing to flush otherwise. - if window_grew { + // §6.9.2: shift every stream's send window. It may drop below the bytes already sent. + let delta = settings.initial_window_size as i64 - old_initial_window as i64; + if delta != 0 { + for (_, item) in self.streams.get().iter() { + // SAFETY: item is &*mut Stream from streams.iter(); the boxed Stream outlives the iteration + let stream = unsafe { &mut **item }; + stream.remote_window_size = stream.remote_window_size.saturating_add_signed(delta); + } + } + if delta > 0 { let _ = self.flush(); } let g = self.global(); @@ -4030,22 +4036,39 @@ impl crate::api::h2::connection::Sink for H2FrameParser { ); } - fn on_window_update(&self, stream_id: u32, increment: u32) { + fn credit_send_window( + &self, + stream_id: u32, + increment: u32, + ) -> crate::api::h2::connection::SendCredit { + use crate::api::h2::connection::SendCredit; bun_output::scoped_log!( H2FrameParser, "engine WU received stream={} inc={}", stream_id, increment ); - // Bridge: the legacy outbound reads its own window cells to decide how much DATA to send. + // `granted` drops below `used` when the peer lowers INITIAL_WINDOW_SIZE that far (§6.9.2). + let overflows = + |granted: u64, used: u64| granted + increment as u64 > used + MAX_WINDOW_SIZE as u64; if stream_id == 0 { - self.remote_window_size - .set(self.remote_window_size.get() + increment as u64); + let granted = self.remote_window_size.get(); + if overflows(granted, self.remote_used_window_size.get()) { + return SendCredit::Overflow; + } + self.remote_window_size.set(granted + increment as u64); } else if let Some(stream) = self.streams.get().get(&stream_id).copied() { // SAFETY: stream is *mut Stream from self.streams; valid while the map entry exists - unsafe { (*stream).remote_window_size += increment as u64 }; + let stream = unsafe { &mut *stream }; + if overflows(stream.remote_window_size, stream.remote_used_window_size) { + return SendCredit::Overflow; + } + stream.remote_window_size += increment as u64; + } else { + return SendCredit::NotOwned; } let _ = self.flush(); + SendCredit::Applied } fn on_altsvc(&self, stream_id: u32, origin: &[u8], value: &[u8]) { @@ -4078,6 +4101,12 @@ impl crate::api::h2::connection::Sink for H2FrameParser { self.streams.get().contains_key(&stream_id) } + fn request_method(&self, stream_id: u32) -> Option { + let stream = self.streams.get().get(&stream_id).copied()?; + // SAFETY: stream is *mut Stream from self.streams; valid while the map entry exists + unsafe { (*stream).request_method } + } + fn goaway_sent(&self) -> bool { self.goaway_sent.get() } @@ -4097,6 +4126,9 @@ impl crate::api::h2::connection::Sink for H2FrameParser { } fn on_push_promise(&self, _parent_id: u32, promised_id: u32) { + // Host fns and session teardown look streams up in `streams`. A client never sends on a promised stream (§5.1). + self.insert_stream(promised_id, StreamState::HALF_CLOSED_LOCAL); + self.last_peer_stream_id.set(promised_id); // The promised request headers follow via on_header/on_headers_complete for promised_id; // remember it so that completion dispatches onStreamPush instead of onStreamHeaders. self.rewrite_pending_push.set(promised_id); @@ -4151,12 +4183,12 @@ impl crate::api::h2::connection::Sink for H2FrameParser { self.dispatch(JSH2FrameParser::Gc::onOrigin, origin_value); } - fn on_stream_open(&self, stream_id: u32) { + fn on_stream_open(&self, stream_id: u32) -> bool { // Bridge: create the legacy stream entry (the legacy outbound host fns — respond/sendData/ // rstStream/getStreamState — look streams up there) AND dispatch onStreamStart, which the // legacy helper already does. The JS streamStart handler then calls setStreamContext, // populating both `sctx` and the legacy stream context. - let _ = self.handle_received_stream_id(stream_id); + self.handle_received_stream_id(stream_id).is_some() } fn on_header(&self, _stream_id: u32, name: &[u8], value: &[u8], never_index: bool) { @@ -4176,12 +4208,7 @@ impl crate::api::h2::connection::Sink for H2FrameParser { }); } - fn on_headers_complete(&self, stream_id: u32, end_stream: bool, flags: u8) { - // Bridge: the JS endAfterHeaders getter reads the legacy stream's end_after_headers flag. - if let Some(stream) = self.streams.get().get(&stream_id).copied() { - // SAFETY: stream is *mut Stream from self.streams; valid while the map entry exists - unsafe { (*stream).end_after_headers = end_stream }; - } + fn on_headers_complete(&self, stream_id: u32, _end_stream: bool, flags: u8) { // Materialize the accumulated block in a single native pass: the raw array, the // node-shaped headers object, and the sensitive list (a zero-field block yields an // empty array + object, matching the legacy decoder's upfront array). @@ -4215,26 +4242,60 @@ impl crate::api::h2::connection::Sink for H2FrameParser { JSValue::js_number(flags as f64), ); } else { - let stream_ctx = self.rewrite_stream_ctx(stream_id); - self.dispatch_with_2_extra( + self.dispatch_event( JSH2FrameParser::Gc::onStreamHeaders, - stream_ctx, - tuple, - JSValue::js_number(flags as f64), + Target::Stream(stream_id), + &[tuple, JSValue::js_number(flags as f64)], ); } } fn on_data(&self, stream_id: u32, data: &[u8]) { let g = self.global(); - let stream_ctx = self.rewrite_stream_ctx(stream_id); let Some(chunk) = self.or_stop(self.handlers.get().binary_type.to_js(data, &g)) else { return; }; - self.dispatch_with_extra(JSH2FrameParser::Gc::onStreamData, stream_ctx, chunk); + self.dispatch_event( + JSH2FrameParser::Gc::onStreamData, + Target::Stream(stream_id), + &[chunk], + ); } fn on_stream_end(&self, stream_id: u32, state: u8) { + if self.receiving.get() { + self.deferred + .with_mut(|q| q.push_back(Deferred::StreamEnd { stream_id, state })); + return; + } + self.stream_end_now(stream_id, state); + } + + fn on_stream_rejected(&self, stream_id: u32) { + self.rejected_streams.set(self.rejected_streams.get() + 1); + if self.max_rejected_streams.get() <= self.rejected_streams.get() { + self.send_go_away( + stream_id, + ErrorCode::ENHANCE_YOUR_CALM, + b"ENHANCE_YOUR_CALM", + self.last_stream_id.get(), + true, + ); + } + } + + fn on_stream_reset(&self, stream_id: u32, code: u32) { + if self.receiving.get() { + self.deferred + .with_mut(|q| q.push_back(Deferred::StreamReset { stream_id, code })); + return; + } + self.stream_reset_now(stream_id, code); + } +} + +impl H2FrameParser { + fn stream_end_now(&self, stream_id: u32, state: u8) { // The engine only sees the inbound half while outbound flows through the legacy path, so it // can't know the local side already sent END_STREAM. Combine with the legacy stream's local // state: remote-closed (6) on a stream whose local half is closed (5/7) is fully CLOSED (7), @@ -4284,22 +4345,7 @@ impl crate::api::h2::connection::Sink for H2FrameParser { } } - fn on_stream_rejected(&self, stream_id: u32) { - // maxSessionRejectedStreams: counts only locally-initiated rejections (oversized or - // malformed header blocks) - peer-sent RST_STREAM frames must not consume the budget. - self.rejected_streams.set(self.rejected_streams.get() + 1); - if self.max_rejected_streams.get() <= self.rejected_streams.get() { - self.send_go_away( - stream_id, - ErrorCode::ENHANCE_YOUR_CALM, - b"ENHANCE_YOUR_CALM", - self.last_stream_id.get(), - true, - ); - } - } - - fn on_stream_reset(&self, stream_id: u32, code: u32) { + fn stream_reset_now(&self, stream_id: u32, code: u32) { // A mid-block rejection (e.g. max_header_list_size) leaves partially-accumulated header // arrays behind; drop them so they can't leak into the next stream's dispatch. The same // applies to the pending PUSH_PROMISE marker - a rejected push block must not make the @@ -4309,13 +4355,10 @@ impl crate::api::h2::connection::Sink for H2FrameParser { if self.rewrite_pending_push.get() == stream_id && stream_id != 0 { self.rewrite_pending_push.set(0); } - // Bridge: mark the legacy stream closed with the rst code (capturing the prior state for - // the aborted dispatch below). - let mut old_state: u8 = StreamState::OPEN as u8; + // Bridge: mark the legacy stream closed with the rst code. if let Some(stream) = self.streams.get().get(&stream_id).copied() { // SAFETY: stream is *mut Stream from self.streams; valid while the map entry exists unsafe { - old_state = (*stream).state as u8; (*stream).state = StreamState::CLOSED; (*stream).rst_code = code; } @@ -4324,11 +4367,10 @@ impl crate::api::h2::connection::Sink for H2FrameParser { if code == crate::api::h2::wire::ErrorCode::Cancel.as_u32() { // A peer CANCEL is an abort, not an error (node emits 'aborted' and closes with // rstCode 8 without an 'error' event). - self.dispatch_with_2_extra( + self.dispatch_with_extra( JSH2FrameParser::Gc::onAborted, stream_ctx, JSValue::UNDEFINED, - JSValue::js_number(old_state as f64), ); } else { self.dispatch_with_extra( @@ -4417,7 +4459,8 @@ impl H2FrameParser { if let Some(v) = options.get(global_object, $key)? { if v.is_number() { let value = v.as_number(); - if value < ($min as f64) || value > $max { + // `contains` also rejects NaN, which compares false against both bounds. + if !(($min as f64)..=$max).contains(&value) { return global_object .err_http2_invalid_setting_value_range_error($err) .throw(); @@ -4460,7 +4503,7 @@ impl H2FrameParser { if let Some(v) = options.get(global_object, "initialWindowSize")? { if v.is_number() { let value = v.as_number(); - if value < 0.0 || value > MAX_WINDOW_SIZE_F64 { + if !(0.0..=MAX_WINDOW_SIZE_F64).contains(&value) { return global_object .err_http2_invalid_setting_value_range_error( "Expected initialWindowSize to be a number between 0 and 2^32-1", @@ -4582,7 +4625,7 @@ impl H2FrameParser { // Validate setting value is in range [0, 2^32-1] if setting_value.is_number() { let value = setting_value.as_number(); - if value < 0.0 || value > MAX_HEADER_TABLE_SIZE_F64 { + if !(0.0..=MAX_HEADER_TABLE_SIZE_F64).contains(&value) { return global_object .err_http2_invalid_setting_value_range_error( "Invalid custom setting value", @@ -4682,26 +4725,13 @@ impl H2FrameParser { .throw_invalid_arguments(format_args!("Expected windowSize to be a number"))); } let window_size_value: u32 = window_size.to_u32(); - if this.used_window_size.get() > window_size_value as u64 { - return Err(global_object.throw_invalid_arguments(format_args!( - "Expected windowSize to be greater than usedWindowSize" - ))); - } - let old_window_size = this.window_size.get(); this.window_size.set(window_size_value as u64); - if this.local_settings.get().initial_window_size < window_size_value { - let mut s = this.local_settings.get(); - s.initial_window_size = window_size_value; - this.local_settings.set(s); - } - if window_size_value as u64 > old_window_size { - let increment: u32 = (window_size_value as u64 - old_window_size) as u32; - this.send_window_update(0, UInt31WithReserved::init(increment, false)); - // Keep the rewrite engine's receive window in sync: we just advertised a larger - // window, so the engine must accept that much DATA without tripping its overflow - // check. try_borrow: setLocalWindowSize can be called from JS inside a dispatch - // (rewrite_read holds the engine borrow there); deferring the sync to the pending - // delta keeps that path panic-free. + // Only the connection window moves. Stream windows follow SETTINGS_INITIAL_WINDOW_SIZE. + let advertised = this.advertised_window_size.get(); + if window_size_value as u64 > advertised { + let increment: u32 = (window_size_value as u64 - advertised) as u32; + this.advertised_window_size.set(window_size_value as u64); + // try_borrow: JS can call this inside a dispatch, where rewrite_read holds the borrow. match this.engine.try_borrow_mut() { Ok(mut guard) => match guard.as_mut() { Some(engine) => engine.recv_window.grow(increment as i64), @@ -4719,14 +4749,8 @@ impl H2FrameParser { .set(this.pending_recv_window_growth.get() + increment as i64); } } - } - for (_, item) in this.streams.get().iter() { - // SAFETY: item is &*mut Stream from streams.iter(); the boxed Stream outlives the iteration - let stream = unsafe { &mut **item }; - if stream.used_window_size > window_size_value as u64 { - continue; - } - stream.window_size = window_size_value as u64; + // Write last. A JS transport can answer with DATA from inside this write. + this.send_window_update(0, UInt31WithReserved::init(increment, false)); } Ok(JSValue::UNDEFINED) } @@ -4769,7 +4793,7 @@ impl H2FrameParser { result.put( global_object, b"effectiveRecvDataLength", - JSValue::js_number((this.window_size.get() - this.used_window_size.get()) as f64), + JSValue::js_number(this.window_size.get() as f64), ); result.put( global_object, @@ -4784,7 +4808,6 @@ impl H2FrameParser { let settings = this.remote_settings.get().unwrap_or_default(); let remote_iws = settings.initial_window_size; - let local_iws = this.local_settings.get().initial_window_size; let local_hts = this.local_settings.get().header_table_size; result.put( global_object, @@ -4794,7 +4817,7 @@ impl H2FrameParser { result.put( global_object, b"localWindowSize", - JSValue::js_number(local_iws as f64), + JSValue::js_number(this.advertised_window_size.get() as f64), ); result.put( global_object, @@ -5053,34 +5076,6 @@ impl H2FrameParser { Ok(JSValue::UNDEFINED) } - #[bun_jsc::host_fn(method)] - pub(crate) fn get_end_after_headers( - this: &Self, - global_object: &JSGlobalObject, - callframe: &CallFrame, - ) -> JsResult { - let [stream_arg] = callframe.arguments_as_array::<1>(); - if callframe.arguments_count() < 1 { - return Err(global_object.throw(format_args!("Expected stream argument"))); - } - - if !stream_arg.is_number() { - return Err(global_object.throw(format_args!("Invalid stream id"))); - } - - let stream_id = stream_arg.to_u32(); - if stream_id == 0 { - return Err(global_object.throw(format_args!("Invalid stream id"))); - } - - let Some(stream) = this.streams.get().get(&stream_id).copied() else { - return Err(global_object.throw(format_args!("Invalid stream id"))); - }; - - // SAFETY: stream is *mut Stream from self.streams; valid while the map entry exists - Ok(JSValue::from(unsafe { (*stream).end_after_headers })) - } - #[bun_jsc::host_fn(method)] pub(crate) fn get_stream_state( this: &Self, @@ -5102,7 +5097,8 @@ impl H2FrameParser { } let Some(stream) = this.streams.get().get(&stream_id).copied() else { - return Err(global_object.throw(format_args!("Invalid stream id"))); + // The JS stream outlives its entry while it has unread data. + return Ok(JSValue::UNDEFINED); }; // SAFETY: stream is a *mut Stream from self.streams (heap::alloc); valid while the map entry exists let stream = unsafe { &mut *stream }; @@ -5134,11 +5130,8 @@ impl H2FrameParser { b"sumDependencyWeight", JSValue::js_number(0.0), ); - state.put( - global_object, - b"weight", - JSValue::js_number(stream.weight as f64), - ); + // NGHTTP2_DEFAULT_WEIGHT. Like node, no stream ever gets another weight. + state.put(global_object, b"weight", JSValue::js_number(16.0)); Ok(state) } @@ -5169,28 +5162,8 @@ impl H2FrameParser { } let error_code = error_arg.to_u32(); - // maxSessionRejectedStreams: a REFUSED_STREAM reset from the JS layer (the - // max-concurrent-streams refusal in streamStart) is the same rejection class the engine - // counts; budget it identically so a flood of refused streams still tears the session - // down. Server-side only: a client's GOAWAY sweep resets its own unprocessed streams - // with REFUSED_STREAM and must not consume the budget. - if error_code == ErrorCode::REFUSED_STREAM.0 && this.is_server.get() { - this.rejected_streams.set(this.rejected_streams.get() + 1); - if this.max_rejected_streams.get() <= this.rejected_streams.get() { - this.send_go_away( - stream_id, - ErrorCode::ENHANCE_YOUR_CALM, - b"ENHANCE_YOUR_CALM", - this.last_stream_id.get(), - true, - ); - return Ok(JSValue::UNDEFINED); - } - } - let Some(stream) = this.streams.get().get(&stream_id).copied() else { - // Streams the legacy bookkeeping never registered (e.g. peer-initiated pushed streams - // surfaced by the rewrite engine) get the RST_STREAM written directly. The frame is + // No entry (the slot was already released): the RST_STREAM is written directly. It is // built here rather than through the engine so this stays callable from inside an // engine dispatch (the engine cell may already be borrowed). // @@ -5252,6 +5225,22 @@ impl H2FrameParser { )) } + /// DATA bytes the send windows allow right now, capped at one frame. + fn sendable_data_size(&self, stream: &Stream) -> usize { + MAX_PAYLOAD_SIZE_WITHOUT_FRAME + .min( + (self + .remote_window_size + .get() + .saturating_sub(self.remote_used_window_size.get())) as usize, + ) + .min( + (stream + .remote_window_size + .saturating_sub(stream.remote_used_window_size)) as usize, + ) + } + /// Returns `(settled_state, callback_deferred)`: the state the close tail settled on (5 = /// HALF_CLOSED_LOCAL, 7 = CLOSED, 0 = none) and whether `callback` was left to the caller. fn send_data( @@ -5266,6 +5255,8 @@ impl H2FrameParser { suppress_half_closed_local_dispatch, defer_write_callback, } = options; + // JS under a transport write below updates this `Stream` through the map's pointer. + let stream: &mut Stream = core::hint::black_box(stream); bun_output::scoped_log!( H2FrameParser, "HTTP_FRAME_DATA {} sendData({}, {}, {})", @@ -5285,21 +5276,17 @@ impl H2FrameParser { let can_close = close && !stream.wait_for_trailers; if payload.is_empty() { - // empty payload we still need to send a frame - let data_header = FrameHeader { - type_: FrameType::HTTP_FRAME_DATA as u8, - flags: if can_close { - DataFrameFlags::END_STREAM as u8 - } else { - 0 - }, - stream_identifier: stream_id, - length: 0, - }; - if self.has_backpressure() || self.outbound_queue_size.get() > 0 { + if self.must_queue_data(stream) { enqueued = true; stream.queue_frame(self, b"", callback, close); - } else { + } else if can_close { + // Peers count an empty DATA frame without END_STREAM as a flood, so never send one. + let data_header = FrameHeader { + type_: FrameType::HTTP_FRAME_DATA as u8, + flags: DataFrameFlags::END_STREAM as u8, + stream_identifier: stream_id, + length: 0, + }; let mut writer = self.to_writer(); let _ = data_header.write(&mut writer, &self.frames_sent_legacy); } @@ -5308,20 +5295,16 @@ impl H2FrameParser { while offset < payload.len() { // max frame size will always be at least 16384 (but we need to respect the flow control) - let mut max_size = MAX_PAYLOAD_SIZE_WITHOUT_FRAME - .min( - (self - .remote_window_size - .get() - .saturating_sub(self.remote_used_window_size.get())) - as usize, - ) - .min( - (stream - .remote_window_size - .saturating_sub(stream.remote_used_window_size)) - as usize, - ); + let mut max_size = self.sendable_data_size(stream); + if max_size == 0 { + // A synchronous JS transport handles the peer's answer inside this write. + self.flush_batch_buffer(); + if !stream.can_send_data() { + self.dispatch_write_callback(callback); + return (0, false); + } + max_size = self.sendable_data_size(stream); + } let mut is_flow_control_limited = false; if max_size == 0 { is_flow_control_limited = true; @@ -5334,10 +5317,7 @@ impl H2FrameParser { offset += size; let end_stream = offset >= payload.len() && can_close; - if self.has_backpressure() - || self.outbound_queue_size.get() > 0 - || is_flow_control_limited - { + if self.must_queue_data(stream) || is_flow_control_limited { // Preserve wire order: anything already batched goes out before the // queued remainder is flushed later by the drain path. self.flush_batch_buffer(); @@ -5373,7 +5353,6 @@ impl H2FrameParser { stream.remote_used_window_size += payload_size as u64; self.remote_used_window_size .set(self.remote_used_window_size.get() + payload_size as u64); - self.note_engine_send_consumed(stream_id, payload_size as u64); let mut flags: u8 = if end_stream { DataFrameFlags::END_STREAM as u8 } else { @@ -5525,9 +5504,16 @@ impl H2FrameParser { }; // SAFETY: stream is a *mut Stream from self.streams (heap::alloc); valid while the map entry exists let stream = unsafe { &mut *stream }; + // JS learns about a native reset on the next tick and can still end the stream until then. + if stream.state != StreamState::CLOSED { + this.send_no_trailers(stream); + } + Ok(JSValue::UNDEFINED) + } + fn send_no_trailers(&self, stream: &mut Stream) { stream.wait_for_trailers = false; - let _ = this.send_data( + let _ = self.send_data( stream, b"", JSValue::UNDEFINED, @@ -5537,7 +5523,6 @@ impl H2FrameParser { defer_write_callback: false, }, ); - Ok(JSValue::UNDEFINED) } /// node's strictSingleValueFields option (default true): when disabled, duplicate @@ -5577,13 +5562,12 @@ impl H2FrameParser { // SAFETY: stream is a *mut Stream from self.streams (heap::alloc); valid while the map entry exists unsafe { (*stream).reading_paused = !reading }; if reading { - // Resumed: send the deferred WINDOW_UPDATE now. try_borrow: a resume issued from - // inside a dispatch (the engine borrow is held by rewrite_read) is covered by the - // batch-end replenish instead. - if let Ok(mut guard) = this.engine.try_borrow_mut() { - if let Some(engine) = guard.as_mut() { - engine.replenish_stream(this, stream_id); - } + // Skip this inside a batch, which replenishes at its end. Per frame it would hide a peer that overruns the window. + if this.batch_depth.get() == 0 + && let Ok(mut guard) = this.engine.try_borrow_mut() + && let Some(engine) = guard.as_mut() + { + engine.replenish_stream(this, stream_id); } let _ = this.flush(); } @@ -5693,6 +5677,9 @@ impl H2FrameParser { // The header/sensitive-object getters and value coercions below can run user JS // while `stream` is borrowed. let mut stream = this.enter_stream_dispatch(stream_ptr); + if stream.state == StreamState::CLOSED { + return Ok(JSValue::UNDEFINED); + } let Some(headers_obj) = headers_arg.get_object() else { return Err(global_object.throw(format_args!("Expected headers to be an object"))); @@ -5704,10 +5691,7 @@ impl H2FrameParser { ); } - let mut encoded_headers: Vec = Vec::new(); - if encoded_headers.try_reserve(16384).is_err() { - return Err(global_object.throw(format_args!("Failed to allocate header buffer"))); - } + let mut scratch = HeaderScratch::take(global_object); // max header name length for lshpack let mut name_buffer = [0u8; 4096]; @@ -5723,9 +5707,8 @@ impl H2FrameParser { let mut single_value_headers = [false; SINGLE_VALUE_HEADERS_LEN]; - // Encode trailer headers using HPACK while let Some((header_name, js_value)) = iter.next()? { - if header_name.length() == 0 { + if header_name.length() == 0 || js_value.is_undefined() { continue; } @@ -5743,13 +5726,6 @@ impl H2FrameParser { return Err(global_object.throw_value(exception)); } - if js_value.is_undefined_or_null() { - let exception = global_object.to_type_error( - bun_jsc::ErrorCode::HTTP2_INVALID_HEADER_VALUE, - format_args!("Invalid value for header \"{}\"", BStr::new(name)), - ); - return Err(global_object.throw_value(exception)); - } let validated_name = match Self::to_valid_header_name(name, &mut name_buffer[..]) { Ok(n) => n, Err(_) => { @@ -5764,11 +5740,7 @@ impl H2FrameParser { } }; - // closure for encode error handling - let mut handle_encode = |this: &Self, - value: &[u8], - never_index: bool| - -> JsResult> { + let mut collect = |value: &[u8], never_index: bool| -> JsResult<()> { if !is_valid_header_value(value) { let exception = global_object.to_type_error( bun_jsc::ErrorCode::HTTP2_INVALID_HEADER_VALUE, @@ -5776,46 +5748,12 @@ impl H2FrameParser { ); return Err(global_object.throw_value(exception)); } - bun_output::scoped_log!( - H2FrameParser, - "encode header {} {}", - BStr::new(validated_name), - BStr::new(value) - ); - match this.encode_header_into_list( - &mut encoded_headers, - validated_name, - value, - never_index, - ) { - Ok(_) => Ok(None), - Err(crate::Error::Alloc(bun_alloc::AllocError)) => { - Err(global_object.throw(format_args!("Failed to allocate header buffer"))) - } - Err(_) => { - // nghttp2 checks maxSendHeaderBlockLength pre-deflation and fires - // on_frame_not_send_callback(NGHTTP2_ERR_FRAME_SIZE_ERROR); Node surfaces - // 'frameError' + ERR_HTTP2_STREAM_ERROR (test-http2-exceeds-server-trailer-size.js). - let identifier = stream.get_identifier(); - identifier.ensure_still_alive(); - this.dispatch_with_2_extra( - JSH2FrameParser::Gc::onFrameError, - identifier, - JSValue::js_number(FrameType::HTTP_FRAME_HEADERS as u8 as f64), - JSValue::js_number(ErrorCode::FRAME_SIZE_ERROR.0 as f64), - ); - let triggering_id = stream.id; - this.end_stream(&mut stream, ErrorCode::FRAME_SIZE_ERROR); - this.send_go_away( - triggering_id, - ErrorCode::NO_ERROR, - b"", - this.last_stream_id.get(), - true, - ); - Ok(Some(JSValue::UNDEFINED)) - } - } + scratch + .block + .push(validated_name, value, never_index) + .map_err(|_| { + global_object.throw(format_args!("Failed to allocate header buffer")) + }) }; if js_value.js_type().is_array() { @@ -5836,17 +5774,6 @@ impl H2FrameParser { } while let Some(item) = value_iter.next()? { - if item.is_empty_or_undefined_or_null() { - let exception = global_object.to_type_error( - bun_jsc::ErrorCode::HTTP2_INVALID_HEADER_VALUE, - format_args!( - "Invalid value for header \"{}\"", - BStr::new(validated_name) - ), - ); - return Err(global_object.throw_value(exception)); - } - let value_view = item.to_js_string_view(global_object)?; // All-digit names can't be passed to get_truthy (integer-index-like names @@ -5861,11 +5788,7 @@ impl H2FrameParser { }; let value_bytes = header_value_bytes(&value_view); - let value = value_bytes.as_ref(); - - if let Some(ret) = handle_encode(this, value, never_index)? { - return Ok(ret); - } + collect(value_bytes.as_ref(), never_index)?; } } else { if let Some(idx) = this.single_value_index_checked(validated_name) { @@ -5895,20 +5818,41 @@ impl H2FrameParser { }; let value_bytes = header_value_bytes(&value_view); - let value = value_bytes.as_ref(); - bun_output::scoped_log!( - H2FrameParser, - "encode header {} {}", - BStr::new(name), - BStr::new(value) - ); + collect(value_bytes.as_ref(), never_index)?; + } + } - if let Some(ret) = handle_encode(this, value, never_index)? { - return Ok(ret); - } + // node submits an empty END_STREAM DATA frame when the trailers have no fields. + if scratch.block.is_empty() { + this.send_no_trailers(&mut stream); + return Ok(JSValue::UNDEFINED); + } + + let encoded = if this.is_over_send_limit(&scratch.block) { + Err(crate::Error::UnableToEncode) + } else { + this.encode_header_list(&mut scratch) + }; + match encoded { + Ok(()) => {} + Err(crate::Error::Alloc(bun_alloc::AllocError)) => { + return Err(global_object.throw(format_args!("Failed to allocate header buffer"))); + } + Err(_) => { + // node: 'frameError' + ERR_HTTP2_STREAM_ERROR (test-http2-exceeds-server-trailer-size.js). + let identifier = stream.get_identifier(); + identifier.ensure_still_alive(); + this.dispatch_with_2_extra( + JSH2FrameParser::Gc::onFrameError, + identifier, + JSValue::js_number(FrameType::HTTP_FRAME_HEADERS as u8 as f64), + JSValue::js_number(ErrorCode::FRAME_SIZE_ERROR.0 as f64), + ); + this.end_stream(&mut stream, ErrorCode::FRAME_SIZE_ERROR); + return Ok(JSValue::UNDEFINED); } } - let encoded_data = encoded_headers.as_slice(); + let encoded_data = scratch.encoded.as_slice(); let encoded_size = encoded_data.len(); // RFC 7540 Section 8.1: Trailers are sent as a HEADERS frame with END_STREAM flag @@ -6189,7 +6133,7 @@ impl H2FrameParser { }; let mut name_buffer = [0u8; 4096]; - let mut encoded_headers: Vec = Vec::new(); + let mut scratch = HeaderScratch::take(global_object); let mut single_value_headers = [false; SINGLE_VALUE_HEADERS_LEN]; // A PUSH_PROMISE carries a REQUEST, so request pseudo-headers are valid even on the server. @@ -6205,7 +6149,7 @@ impl H2FrameParser { }, )?; while let Some((header_name, js_value)) = iter.next()? { - if header_name.length() == 0 { + if header_name.length() == 0 || js_value.is_undefined() { continue; } let name_slice = header_name.to_utf8(); @@ -6241,9 +6185,6 @@ impl H2FrameParser { } else if ignore_pseudo_headers == 0 { continue; } - if js_value.is_empty_or_undefined_or_null() { - continue; - } // All-digit names can't be passed to get_truthy (integer-index-like names trip // a debug assert in getIfPropertyExistsImpl) and can never be sensitive. let never_index = if Self::is_index_like_name(validated_name) { @@ -6254,7 +6195,7 @@ impl H2FrameParser { None => sensitive_arg.get_truthy(global_object, name)?.is_some(), } }; - let mut encode_value = |item: JSValue| -> JsResult> { + let mut collect_value = |item: JSValue| -> JsResult<()> { let value_view = item.to_js_string_view(global_object)?; let value_bytes = header_value_bytes(&value_view); let value = value_bytes.as_ref(); @@ -6269,27 +6210,12 @@ impl H2FrameParser { ) .throw()); } - bun_output::scoped_log!( - H2FrameParser, - "encode header {} {}", - BStr::new(validated_name), - BStr::new(value) - ); - if this - .encode_header_into_list( - &mut encoded_headers, - validated_name, - value, - never_index, - ) - .is_err() - { - // Same as the request/respond encode failures: nghttp2 fails the whole - // session, and node never surfaces this through the pushStream callback. - this.schedule_header_compression_session_error(); - return Ok(Some(JSValue::js_number(-1.0))); - } - Ok(None) + scratch + .block + .push(validated_name, value, never_index) + .map_err(|_| { + global_object.throw(format_args!("Failed to allocate header buffer")) + }) }; if js_value.js_type().is_array() { let mut value_iter = js_value.array_iterator(global_object)?; @@ -6308,20 +6234,7 @@ impl H2FrameParser { single_value_headers[idx] = true; } while let Some(item) = value_iter.next()? { - if item.is_empty_or_undefined_or_null() { - return Err(global_object - .err( - JscErrorCode::HTTP2_INVALID_HEADER_VALUE, - format_args!( - "Invalid value for header \"{}\"", - BStr::new(validated_name) - ), - ) - .throw()); - } - if let Some(ret) = encode_value(item)? { - return Ok(ret); - } + collect_value(item)?; } } else { if let Some(idx) = this.single_value_index_checked(validated_name) { @@ -6338,13 +6251,24 @@ impl H2FrameParser { } single_value_headers[idx] = true; } - if let Some(ret) = encode_value(js_value)? { - return Ok(ret); - } + collect_value(js_value)?; } } } + match this.encode_header_list(&mut scratch) { + Ok(()) => {} + Err(crate::Error::Alloc(bun_alloc::AllocError)) => { + return Err(global_object.throw(format_args!("Failed to allocate header buffer"))); + } + Err(_) => { + // node never surfaces this through the pushStream callback. + this.schedule_header_compression_session_error(); + return Ok(JSValue::js_number(-1.0)); + } + } + let encoded_headers = scratch.encoded.as_slice(); + let max_frame = this.remote_settings .get() @@ -6365,7 +6289,7 @@ impl H2FrameParser { let promised_be = (promised_id & 0x7fff_ffff).swap_bytes(); let _ = ws.write_all(&promised_be.to_ne_bytes()); let _ = this.write(&hdr_buf); - let _ = this.write(&encoded_headers); + let _ = this.write(encoded_headers); } else { // §6.6/§6.10: an oversized block is split - the PUSH_PROMISE (without END_HEADERS) // carries the promised id + the first fragment, then CONTINUATION frames on the @@ -6452,6 +6376,11 @@ impl H2FrameParser { if context_arg.is_empty_or_undefined_or_null() { // Release: a pushed stream torn down before its PUSH_PROMISE left has no reset // dispatch coming, so the JS layer drops the context root explicitly. + if let Some(stream) = this.streams.get().get(&stream_id).copied() { + let mut stream = this.enter_stream_dispatch(stream); + stream.state = StreamState::CLOSED; + stream.free_resources::(this); + } this.sctx.with_mut(|m| { m.remove(&stream_id); }); @@ -6513,45 +6442,6 @@ impl H2FrameParser { Ok(JSValue::UNDEFINED) } - #[bun_jsc::host_fn(method)] - pub(crate) fn emit_abort_to_all_streams( - this: &Self, - _global_object: &JSGlobalObject, - _callframe: &CallFrame, - ) -> JsResult { - // R-2: StreamResumableIterator stores a `ParentRef`; `streams` is `JsCell`-backed, - // so the loop body can keep using `this` (`&Self`) directly. - let mut it = StreamResumableIterator::init(this); - while let Some(stream_ptr) = it.next() { - // SAFETY: stream_ptr is a *mut Stream stored in self.streams (heap::alloc); valid for - // the lifetime of the entry. Separate heap allocation from `this`, so no aliasing. - let stream = unsafe { &mut *stream_ptr }; - // this is the oposite logic of emitErrorToallStreams, in this case we wanna to cancel this streams - if this.is_server.get() { - if stream.id % 2 == 0 { - continue; - } - } else if stream.id % 2 != 0 { - continue; - } - if stream.state != StreamState::CLOSED { - let old_state = stream.state; - stream.state = StreamState::CLOSED; - stream.rst_code = ErrorCode::CANCEL.0; - let identifier = stream.get_identifier(); - identifier.ensure_still_alive(); - stream.free_resources::(this); - this.dispatch_with_2_extra( - JSH2FrameParser::Gc::onAborted, - identifier, - JSValue::UNDEFINED, - JSValue::js_number(old_state as u8 as f64), - ); - } - } - Ok(JSValue::UNDEFINED) - } - #[bun_jsc::host_fn(method)] pub(crate) fn emit_error_to_all_streams( this: &Self, @@ -6612,7 +6502,8 @@ impl H2FrameParser { headers_arg, sensitive_arg, options_arg, - ] = callframe.arguments_as_array::<5>(); + informational_arg, + ] = callframe.arguments_as_array::<6>(); if callframe.arguments_count() < 4 { return Err(global_object.throw(format_args!( "Expected stream_id, stream_ctx, headers and sensitiveHeaders arguments" @@ -6628,10 +6519,7 @@ impl H2FrameParser { global_object.throw(format_args!("Expected sensitiveHeaders to be an object")) ); } - let mut encoded_headers: Vec = Vec::new(); - if encoded_headers.try_reserve(16384).is_err() { - return Err(global_object.throw(format_args!("Failed to allocate header buffer"))); - } + let mut scratch = HeaderScratch::take(global_object); // max header name length for lshpack let mut name_buffer = [0u8; 4096]; let stream_id: u32 = @@ -6657,7 +6545,7 @@ impl H2FrameParser { loop { let Some(name_js) = pairs.next()? else { break }; let Some(value_js) = pairs.next()? else { break }; - if name_js.is_empty_or_undefined_or_null() || value_js.is_undefined_or_null() { + if name_js.is_empty_or_undefined_or_null() || value_js.is_undefined() { continue; } @@ -6707,8 +6595,19 @@ impl H2FrameParser { continue; } + // An array value is one field per element, like the object form. + let mut elements = if value_js.js_type().is_array() { + Some(value_js.array_iterator(global_object)?) + } else { + None + }; + let field_count = elements.as_ref().map_or(1, |iter| iter.len); + if field_count == 0 { + continue; + } + if let Some(idx) = this.single_value_index_checked(validated_name) { - if single_value_headers[idx] { + if field_count > 1 || single_value_headers[idx] { let exception = global_object.to_type_error( bun_jsc::ErrorCode::HTTP2_HEADER_SINGLE_VALUE, format_args!( @@ -6721,8 +6620,6 @@ impl H2FrameParser { single_value_headers[idx] = true; } - let value_view = value_js.to_js_string_view(global_object)?; - let never_index = if Self::is_index_like_name(validated_name) { false } else { @@ -6732,48 +6629,36 @@ impl H2FrameParser { } }; - let value_bytes = header_value_bytes(&value_view); - let value = value_bytes.as_ref(); - if !is_valid_header_value(value) { - return Err(global_object - .err( - JscErrorCode::HTTP2_INVALID_HEADER_VALUE, - format_args!( - "Invalid value for header \"{}\"", - BStr::new(validated_name) - ), - ) - .throw()); - } - bun_output::scoped_log!( - H2FrameParser, - "encode header {} {}", - BStr::new(validated_name), - BStr::new(value) - ); - - if let Err(err) = this.encode_header_into_list( - &mut encoded_headers, - validated_name, - value, - never_index, - ) { - if matches!(err, crate::Error::Alloc(_)) { + let mut collect_value = |item: JSValue| -> JsResult<()> { + let value_view = item.to_js_string_view(global_object)?; + let value_bytes = header_value_bytes(&value_view); + let value = value_bytes.as_ref(); + if !is_valid_header_value(value) { return Err(global_object - .throw(format_args!("Failed to allocate header buffer"))); + .err( + JscErrorCode::HTTP2_INVALID_HEADER_VALUE, + format_args!( + "Invalid value for header \"{}\"", + BStr::new(validated_name) + ), + ) + .throw()); } - let Some(stream) = this.handle_received_stream_id(stream_id) else { - return Ok(JSValue::js_number(-1.0)); - }; - // SAFETY: stream is a *mut Stream from self.streams (heap::alloc); valid while the map entry exists - let stream = unsafe { &mut *stream }; - if !stream_ctx_arg.is_empty_or_undefined_or_null() - && stream_ctx_arg.is_object() - { - stream.set_context(stream_ctx_arg, global_object); + scratch + .block + .push(validated_name, value, never_index) + .map_err(|_| { + global_object + .throw(format_args!("Failed to allocate header buffer")) + }) + }; + match elements.as_mut() { + Some(iter) => { + while let Some(item) = iter.next()? { + collect_value(item)?; + } } - this.schedule_header_compression_session_error(); - return Ok(JSValue::js_number(stream_id as f64)); + None => collect_value(value_js)?, } } } @@ -6797,7 +6682,7 @@ impl H2FrameParser { )?; while let Some((header_name, js_value)) = iter.next()? { - if header_name.length() == 0 { + if header_name.length() == 0 || js_value.is_undefined() { continue; } @@ -6852,14 +6737,6 @@ impl H2FrameParser { continue; } - if js_value.is_undefined_or_null() { - let exception = global_object.to_type_error( - bun_jsc::ErrorCode::HTTP2_INVALID_HEADER_VALUE, - format_args!("Invalid value for header \"{}\"", BStr::new(name)), - ); - return Err(global_object.throw_value(exception)); - } - if js_value.js_type().is_array() { bun_output::scoped_log!(H2FrameParser, "array header {}", BStr::new(name)); let mut value_iter = js_value.array_iterator(global_object)?; @@ -6879,18 +6756,6 @@ impl H2FrameParser { } while let Some(item) = value_iter.next()? { - if item.is_empty_or_undefined_or_null() { - return Err(global_object - .err( - JscErrorCode::HTTP2_INVALID_HEADER_VALUE, - format_args!( - "Invalid value for header \"{}\"", - BStr::new(validated_name) - ), - ) - .throw()); - } - let value_view = item.to_js_string_view(global_object)?; let never_index = if Self::is_index_like_name(validated_name) { @@ -6915,38 +6780,16 @@ impl H2FrameParser { ) .throw()); } - bun_output::scoped_log!( - H2FrameParser, - "encode header {} {}", - BStr::new(validated_name), - BStr::new(value) - ); - - if let Err(err) = this.encode_header_into_list( - &mut encoded_headers, - validated_name, - value, - never_index, - ) { - if matches!(err, crate::Error::Alloc(_)) { - return Err(global_object - .throw(format_args!("Failed to allocate header buffer"))); - } - let Some(stream) = this.handle_received_stream_id(stream_id) else { - return Ok(JSValue::js_number(-1.0)); - }; - // SAFETY: stream is a *mut Stream from self.streams (heap::alloc); valid while the map entry exists - let stream = unsafe { &mut *stream }; - if !stream_ctx_arg.is_empty_or_undefined_or_null() - && stream_ctx_arg.is_object() - { - stream.set_context(stream_ctx_arg, global_object); - } - this.schedule_header_compression_session_error(); - return Ok(JSValue::UNDEFINED); + if scratch + .block + .push(validated_name, value, never_index) + .is_err() + { + return Err(global_object + .throw(format_args!("Failed to allocate header buffer"))); } } - } else if !js_value.is_empty_or_undefined_or_null() { + } else { bun_output::scoped_log!(H2FrameParser, "single header {}", BStr::new(name)); if let Some(idx) = this.single_value_index_checked(validated_name) { if single_value_headers[idx] { @@ -6985,54 +6828,32 @@ impl H2FrameParser { ) .throw()); } - bun_output::scoped_log!( - H2FrameParser, - "encode header {} {}", - BStr::new(validated_name), - BStr::new(value) - ); - - if let Err(err) = this.encode_header_into_list( - &mut encoded_headers, - validated_name, - value, - never_index, - ) { - if matches!(err, crate::Error::Alloc(_)) { - return Err(global_object - .throw(format_args!("Failed to allocate header buffer"))); - } - let Some(stream) = this.handle_received_stream_id(stream_id) else { - return Ok(JSValue::js_number(-1.0)); - }; - // SAFETY: stream is a *mut Stream from self.streams (heap::alloc); valid while the map entry exists - let stream = unsafe { &mut *stream }; - if !stream_ctx_arg.is_empty_or_undefined_or_null() - && stream_ctx_arg.is_object() - { - stream.set_context(stream_ctx_arg, global_object); - } - this.schedule_header_compression_session_error(); - return Ok(JSValue::js_number(stream_id as f64)); + if scratch + .block + .push(validated_name, value, never_index) + .is_err() + { + return Err( + global_object.throw(format_args!("Failed to allocate header buffer")) + ); } } } } - let encoded_size = encoded_headers.len(); let Some(stream_ptr) = this.handle_received_stream_id(stream_id) else { return Ok(JSValue::js_number(-1.0)); }; // The `options` getters below can run user JS while `stream` is borrowed. let mut stream = this.enter_stream_dispatch(stream_ptr); + stream.request_method = scratch + .block + .get(b":method") + .map(RequestMethod::from_method); if !stream_ctx_arg.is_empty_or_undefined_or_null() && stream_ctx_arg.is_object() { stream.set_context(stream_ctx_arg, global_object); } let mut flags: u8 = HeadersFrameFlags::END_HEADERS as u8; - let mut exclusive: bool = false; - let mut has_priority: bool = false; - let mut weight: i32 = 0; - let mut parent: i32 = 0; let mut silent: bool = false; let mut wait_for_trailers: bool = false; let mut end_stream: bool = false; @@ -7062,7 +6883,6 @@ impl H2FrameParser { if let Some(trailes_js) = options.get(global_object, "waitForTrailers")? { if trailes_js.is_boolean() { wait_for_trailers = trailes_js.as_boolean(); - stream.wait_for_trailers = wait_for_trailers; } } @@ -7082,10 +6902,7 @@ impl H2FrameParser { if end_stream_js.is_boolean() { if end_stream_js.as_boolean() { end_stream = true; - // will end the stream after trailers - if !wait_for_trailers || this.is_server.get() { - flags |= HeadersFrameFlags::END_STREAM as u8; - } + flags |= HeadersFrameFlags::END_STREAM as u8; } } else { return Err(global_object.throw_invalid_argument_type_value( @@ -7095,106 +6912,10 @@ impl H2FrameParser { )); } } - - if let Some(exclusive_js) = options.get(global_object, "exclusive")? { - if exclusive_js.is_boolean() { - if exclusive_js.as_boolean() { - exclusive = true; - has_priority = true; - } - } else { - return Err(global_object.throw_invalid_argument_type_value( - b"options.exclusive", - b"boolean", - exclusive_js, - )); - } - } - - if let Some(parent_js) = options.get(global_object, "parent")? { - if parent_js.is_number() || parent_js.is_int32() { - has_priority = true; - parent = parent_js.to_int32(); - if parent <= 0 || parent as u32 > MAX_STREAM_ID { - stream.state = StreamState::CLOSED; - stream.rst_code = ErrorCode::INTERNAL_ERROR.0; - this.dispatch_with_extra( - JSH2FrameParser::Gc::onStreamError, - stream.get_identifier(), - JSValue::js_number(stream.rst_code as f64), - ); - return Ok(JSValue::js_number(stream.id as f64)); - } - } else { - return Err(global_object.throw_invalid_argument_type_value( - b"options.parent", - b"number", - parent_js, - )); - } - } - - if let Some(weight_js) = options.get(global_object, "weight")? { - if weight_js.is_number() || weight_js.is_int32() { - has_priority = true; - weight = weight_js.to_int32(); - if weight < 1 || weight > u8::MAX as i32 { - stream.state = StreamState::CLOSED; - stream.rst_code = ErrorCode::INTERNAL_ERROR.0; - this.dispatch_with_extra( - JSH2FrameParser::Gc::onStreamError, - stream.get_identifier(), - JSValue::js_number(stream.rst_code as f64), - ); - return Ok(JSValue::js_number(stream_id as f64)); - } - stream.weight = u16::try_from(weight).expect("int cast"); - } else { - return Err(global_object.throw_invalid_argument_type_value( - b"options.weight", - b"number", - weight_js, - )); - } - - if weight < 1 || weight > u8::MAX as i32 { - stream.state = StreamState::CLOSED; - stream.rst_code = ErrorCode::INTERNAL_ERROR.0; - this.dispatch_with_extra( - JSH2FrameParser::Gc::onStreamError, - stream.get_identifier(), - JSValue::js_number(stream.rst_code as f64), - ); - return Ok(JSValue::js_number(stream_id as f64)); - } - - stream.weight = u16::try_from(weight).expect("int cast"); - } - - if let Some(signal_arg) = options.get(global_object, "signal")? { - if let Some(signal_ptr) = AbortSignal::from_js(signal_arg) { - // SAFETY: `from_js` returns a live *mut AbortSignal owned by JSC; rooted via `signal_arg` on the stack. - let signal_ = unsafe { &mut *signal_ptr }; - if signal_.aborted() { - stream.state = StreamState::IDLE; - let wrapped = - Bun__wrapAbortError(global_object, signal_.js_reason(global_object)); - this.abort_stream(&mut stream, wrapped); - return Ok(JSValue::js_number(stream_id as f64)); - } - stream.attach_signal(this, signal_); - } else { - return Err(global_object.throw_invalid_argument_type_value( - b"options.signal", - b"AbortSignal", - signal_arg, - )); - } - } } - // too much memory being use - if this.is_over_session_memory_limit() { + // A server only gets here for a stream that's already open, and can_open_stream() covers new ones. + if !this.is_server.get() && this.is_over_session_memory_limit() { stream.state = StreamState::CLOSED; stream.rst_code = ErrorCode::ENHANCE_YOUR_CALM.0; this.rejected_streams.set(this.rejected_streams.get() + 1); @@ -7219,47 +6940,48 @@ impl H2FrameParser { } return Ok(JSValue::js_number(stream_id as f64)); } - let mut length: usize = encoded_size; - if has_priority { - length += 5; - flags |= HeadersFrameFlags::PRIORITY as u8; - } - - bun_output::scoped_log!(H2FrameParser, "request encoded_size {}", encoded_size); - - // Check if headers block exceeds maxSendHeaderBlockLength - if this.max_send_header_block_length.get() != 0 - && encoded_size > this.max_send_header_block_length.get() as usize - { - stream.state = StreamState::CLOSED; - stream.rst_code = ErrorCode::REFUSED_STREAM.0; - + if this.is_over_send_limit(&scratch.block) { + let identifier = stream.get_identifier(); + identifier.ensure_still_alive(); this.dispatch_with_2_extra( JSH2FrameParser::Gc::onFrameError, - stream.get_identifier(), + identifier, JSValue::js_number(FrameType::HTTP_FRAME_HEADERS as u8 as f64), JSValue::js_number(ErrorCode::FRAME_SIZE_ERROR.0 as f64), ); + if !this.is_server.get() { + // The peer never saw this request, so it gets no RST_STREAM. + stream.state = StreamState::CLOSED; + stream.rst_code = ErrorCode::REFUSED_STREAM.0; + this.dispatch_with_extra( + JSH2FrameParser::Gc::onStreamError, + identifier, + JSValue::js_number(stream.rst_code as f64), + ); + } else if !informational_arg.to_boolean() { + this.end_stream(&mut stream, ErrorCode::FRAME_SIZE_ERROR); + } + return Ok(JSValue::js_number(stream_id as f64)); + } - this.dispatch_with_extra( - JSH2FrameParser::Gc::onStreamError, - stream.get_identifier(), - JSValue::js_number(stream.rst_code as f64), - ); + // The encode updates the HPACK table, so nothing may return or throw until the block is written. + if let Err(err) = this.encode_header_list(&mut scratch) { + if matches!(err, crate::Error::Alloc(_)) { + return Err(global_object.throw(format_args!("Failed to allocate header buffer"))); + } + this.schedule_header_compression_session_error(); return Ok(JSValue::js_number(stream_id as f64)); } + let encoded_headers = &mut scratch.encoded; + let encoded_size = encoded_headers.len(); + bun_output::scoped_log!(H2FrameParser, "request encoded_size {}", encoded_size); let actual_max_frame_size = this .remote_settings .get() .unwrap_or_else(|| this.local_settings.get()) .max_frame_size as usize; - let priority_overhead: usize = if has_priority { - StreamPriority::BYTE_SIZE - } else { - 0 - }; - let available_payload = actual_max_frame_size - priority_overhead; + let available_payload = actual_max_frame_size; // Reserve one byte for the pad-length field so `encoded_size + // padding_overhead` never exceeds `available_payload`; otherwise the // CONTINUATION branch below would slice past the end of the encoded @@ -7282,7 +7004,7 @@ impl H2FrameParser { // Check if we need CONTINUATION frames if encoded_size <= headers_frame_max_payload { // Single HEADERS frame - fits in one frame - let payload_size = encoded_size + priority_overhead + padding_overhead; + let payload_size = encoded_size + padding_overhead; bun_output::scoped_log!( H2FrameParser, "padding: {} size: {} max_size: {} payload_size: {}", @@ -7294,17 +7016,6 @@ impl H2FrameParser { if padding != 0 { flags |= HeadersFrameFlags::PADDED as u8; - // Grow before any frame byte is written: failing after the header went out - // would abandon the frame mid-serialization (the JS-transport tracker would - // hold the stream mid-frame and the wire would owe a payload). - if encoded_headers - .try_reserve(encoded_size + padding_overhead - encoded_headers.len()) - .is_err() - { - return Err( - global_object.throw(format_args!("Failed to allocate padding buffer")) - ); - } } let frame = FrameHeader { @@ -7315,22 +7026,11 @@ impl H2FrameParser { }; let _ = frame.write(&mut writer, &this.frames_sent_legacy); - // Write priority data if present - if has_priority { - let stream_identifier = - UInt31WithReserved::init(u32::try_from(parent).expect("int cast"), exclusive); - let priority_data = StreamPriority { - stream_identifier: stream_identifier.to_uint32(), - weight: u8::try_from(weight).expect("int cast"), - }; - let _ = priority_data.write(&mut writer); - } - // Handle padding if padding != 0 { // Zero-fill the padding region (RFC 7540 §6.2: padding octets MUST be zero) and // ensure the slice we hand to writer covers only initialized bytes. Cannot - // allocate: the capacity was reserved above, before the frame header went out. + // allocate: encode_header_list reserved the room before the encode. encoded_headers.resize(encoded_size + padding_overhead, 0); let buffer = encoded_headers.as_mut_slice(); // memmove: shift right by 1 to make room for the pad-length byte @@ -7338,7 +7038,7 @@ impl H2FrameParser { buffer[0] = padding; let _ = writer.write_all(buffer); } else { - let _ = writer.write_all(&encoded_headers); + let _ = writer.write_all(encoded_headers); } } else { bun_output::scoped_log!( @@ -7348,32 +7048,17 @@ impl H2FrameParser { actual_max_frame_size ); - let first_chunk_size = actual_max_frame_size - priority_overhead; + let first_chunk_size = actual_max_frame_size; let headers_flags = flags & !(HeadersFrameFlags::END_HEADERS as u8); let headers_frame = FrameHeader { type_: FrameType::HTTP_FRAME_HEADERS as u8, - flags: headers_flags - | (if has_priority { - HeadersFrameFlags::PRIORITY as u8 - } else { - 0 - }), + flags: headers_flags, stream_identifier: stream.id, - length: u32::try_from(first_chunk_size + priority_overhead).expect("int cast"), + length: u32::try_from(first_chunk_size).expect("int cast"), }; let _ = headers_frame.write(&mut writer, &this.frames_sent_legacy); - if has_priority { - let stream_identifier = - UInt31WithReserved::init(u32::try_from(parent).expect("int cast"), exclusive); - let priority_data = StreamPriority { - stream_identifier: stream_identifier.to_uint32(), - weight: u8::try_from(weight).expect("int cast"), - }; - let _ = priority_data.write(&mut writer); - } - // Write first chunk of header block fragment let _ = writer.write_all(&encoded_headers[0..first_chunk_size]); @@ -7401,14 +7086,6 @@ impl H2FrameParser { } if end_stream { - stream.end_after_headers = true; - - if wait_for_trailers { - stream.state = StreamState::HALF_CLOSED_LOCAL; - this.dispatch(JSH2FrameParser::Gc::onWantTrailers, stream.get_identifier()); - return Ok(JSValue::js_number(stream_id as f64)); - } - // A HEADERS frame carrying END_STREAM half-closes our side; when // the peer already half-closed (a server responding after the // request body finished) the stream is now fully closed. Mirror @@ -7438,7 +7115,6 @@ impl H2FrameParser { // TODO: should we make use of this in the future? We validate it. } - let _ = length; Ok(JSValue::js_number(stream_id as f64)) } @@ -7498,6 +7174,54 @@ impl H2FrameParser { break; } } + if self.draining.get() != Draining::No { + self.finish_draining(); + } + } + + /// Releases the socket once it has taken the output a draining parser kept. + #[cold] + fn finish_draining(&self) { + if self.has_unsent_output() && self.socket_takes_output() { + return; + } + if self.draining.replace(Draining::No) == Draining::OutputThenFin { + match self.native_socket.get() { + BunSocket::Tcp(socket) => socket.get().socket.get().shutdown(), + BunSocket::Tls(socket) => socket.get().socket.get().shutdown(), + BunSocket::TcpWriteonly(_) | BunSocket::TlsWriteonly(_) | BunSocket::None => {} + } + } + self.write_buffer.with_mut(|wb| wb.clear_and_free()); + self.write_buffer_offset.set(0); + self.detach_native_socket(); + } + + fn has_unsent_output(&self) -> bool { + !self.write_buffer.get().slice()[self.write_buffer_offset.get()..].is_empty() + } + + /// Returns true if a later writable event reaches this parser without running JS and the write side is still open. + fn socket_takes_output(&self) -> bool { + !self.transport_write_runs_js() + && match self.native_socket.get() { + BunSocket::Tcp(socket) => socket.get().write_side_open(), + BunSocket::Tls(socket) => socket.get().write_side_open(), + BunSocket::TcpWriteonly(_) | BunSocket::TlsWriteonly(_) | BunSocket::None => false, + } + } + + fn owes_output(&self) -> bool { + self.has_unsent_output() && !self.transport_write_fatal.get() && self.socket_takes_output() + } + + /// Returns true if a draining parser holds back the socket's FIN. `finish_draining` sends it after the output. + pub(crate) fn defer_shutdown(&self) -> bool { + if self.draining.get() == Draining::No || !self.owes_output() { + return false; + } + self.draining.set(Draining::OutputThenFin); + true } pub(crate) fn on_native_close(&self) { @@ -7507,6 +7231,11 @@ impl H2FrameParser { let _keepalive = self.keepalive(); self.native_socket.note_socket_closed(); self.detach_native_socket(); + // Free the kept output now instead of when the wrapper is collected. + if self.draining.replace(Draining::No) != Draining::No { + self.write_buffer.with_mut(|wb| wb.clear_and_free()); + self.write_buffer_offset.set(0); + } } #[bun_jsc::host_fn(method)] @@ -7590,14 +7319,12 @@ impl H2FrameParser { ), remote_settings: Cell::new(None), window_size: Cell::new(DEFAULT_WINDOW_SIZE), - used_window_size: Cell::new(0), + advertised_window_size: Cell::new(DEFAULT_WINDOW_SIZE), remote_window_size: Cell::new(DEFAULT_WINDOW_SIZE), remote_used_window_size: Cell::new(0), max_header_list_pairs: Cell::new(128), max_settings: Cell::new(32), pending_recv_window_growth: Cell::new(0), - pending_send_window_consumed: Cell::new(0), - pending_stream_send_consumed: JsCell::new(Vec::new()), pending_engine_stream_closes: JsCell::new(Vec::new()), dispatch_depth: Cell::new(0), pending_settings_window_submissions: JsCell::new(Vec::new()), @@ -7614,7 +7341,7 @@ impl H2FrameParser { queued_data_size: Cell::new(0), max_outstanding_pings: Cell::new(10), out_standing_pings: Cell::new(0), - max_send_header_block_length: Cell::new(0), + max_send_header_block_length: Cell::new(NGHTTP2_MAX_HEADERSLEN), strict_single_value_fields: Cell::new(true), last_stream_id: Cell::new(0), last_peer_stream_id: Cell::new(0), @@ -7622,12 +7349,14 @@ impl H2FrameParser { left_exception: Cell::new(false), write_buffer: JsCell::new(Vec::::default()), write_buffer_offset: Cell::new(0), + flushing_stream_queue: Cell::new(false), outbound_queue_size: Cell::new(0), streams: JsCell::new(BunHashMap::default()), hpack: JsCell::new(None), has_nonnative_backpressure: Cell::new(false), js_socket_flushing: Cell::new(false), transport_write_fatal: Cell::new(false), + draining: Cell::new(Draining::No), pending_header_compression_error: Cell::new(false), frames_sent_legacy: Cell::new(0), engine_frames_received: Cell::new(0), @@ -7637,6 +7366,10 @@ impl H2FrameParser { padding_strategy: Cell::new(PaddingStrategy::None), engine: core::cell::RefCell::new(None), rewrite_tail: JsCell::new(Vec::new()), + rewrite_tail_pos: Cell::new(0), + receiving: Cell::new(false), + deferred: JsCell::new(std::collections::VecDeque::new()), + batch_depth: Cell::new(0), rewrite_pending_push: Cell::new(0), sctx: JsCell::new(BunHashMap::default()), hdr_block: JsCell::new(Vec::new()), @@ -7820,13 +7553,9 @@ impl H2FrameParser { unsafe { bun_jsc::AbortHandle::arm_owner(this, context) }; } - // Note: `HPACK::init` returns a C-allocated wrapper that must be - // torn down via `lshpack_wrapper_deinit` (runs `lshpack_{enc,dec}_cleanup` - // before freeing). Wrapping it in `heap::take` and letting `Box` drop - // would `mi_free` the struct but leak the encoder/decoder internals. - this_ref.hpack.set(Some(lshpack::HpackHandle::new( - this_ref.local_settings.get().header_table_size, - ))); + this_ref + .hpack + .set(Some(crate::api::h2::hpack::Coder::new())); if is_server { let _ = this_ref.set_settings(this_ref.local_settings.get()); } else { @@ -7837,19 +7566,20 @@ impl H2FrameParser { Ok(scopeguard::ScopeGuard::into_inner(guard)) } + /// `detach(keepOutput)` #[bun_jsc::host_fn(method)] pub(crate) fn detach_from_js( this: &Self, _global_object: &JSGlobalObject, - _callframe: &CallFrame, + callframe: &CallFrame, ) -> JsResult { - this.release_from_js(); + this.release_from_js(callframe.argument(0).to_boolean()); Ok(JSValue::UNDEFINED) } /// The session is over: free its streams, let go of the socket, and stop keeping the wrapper /// (and through it the handlers and their context object) alive. - fn release_from_js(&self) { + fn release_from_js(&self, keep_output: bool) { let this = self; this.abort_handle.leave(); // R-2: StreamResumableIterator stores a `ParentRef`; `streams` is `JsCell`-backed, @@ -7860,7 +7590,7 @@ impl H2FrameParser { // cleared. `stream` points into a disjoint Box. unsafe { (*stream).free_resources::(this) }; } - this.detach(); + this.detach(keep_output); if let Some(this_value) = this.strong_this.get().try_get() { // `global_this` is `GlobalRef` (JSC_BORROW) — Deref gives `&JSGlobalObject`. JSH2FrameParser::Gc::context.clear(this_value, &this.global_this); @@ -7870,18 +7600,22 @@ impl H2FrameParser { /// be careful when calling detach be sure that the socket is closed and the parser not accesible anymore /// this function can be called multiple times, it will erase stream info - pub(crate) fn detach(&self) { + pub(crate) fn detach(&self, keep_output: bool) { self.uncork(); self.unregister_auto_flush(); - self.detach_native_socket(); - - // Free the allocation, not just the length: detach() is reachable from JS without a - // following `deinit`, so the capacity must be released here. - self.write_buffer.with_mut(|wb| wb.clear_and_free()); + let draining = keep_output && self.owes_output(); + if !draining { + self.draining.set(Draining::No); + self.detach_native_socket(); + // JS can call detach() without a following drop, so free the capacity here too. + self.write_buffer.with_mut(|wb| wb.clear_and_free()); + self.write_buffer_offset.set(0); + } else if self.draining.get() == Draining::No { + self.draining.set(Draining::Output); + } self.tx_tracker.set(TxFrameTracker::default()); // Drop every per-stream JS context root; the parser is detaching. self.sctx.with_mut(|m| m.clear()); - self.write_buffer_offset.set(0); // `HpackHandle::drop` → `lshpack_wrapper_deinit` (cleanup + free). self.hpack.set(None); @@ -7917,14 +7651,14 @@ impl H2FrameParser { bun_jsc::impl_abort_handle_owner!(H2FrameParser, abort_handle, |this, _cause| { // SAFETY: trait contract — `this` is live (armed ⇒ not dropped). The wrapper this makes // collectible is not finalized under this call. - unsafe { (*this).release_from_js() } + unsafe { (*this).release_from_js(false) } }); impl Drop for H2FrameParser { fn drop(&mut self) { bun_output::scoped_log!(H2FrameParser, "deinit"); - self.detach(); + self.detach(false); // Note: take the map out first so `self` is free for // `free_resources(self)` while we walk the entries. let streams = self.streams.replace(BunHashMap::default()); @@ -7945,19 +7679,7 @@ impl H2FrameParser { bun_output::scoped_log!(H2FrameParser, "finalize"); self.strong_this.set(JsRef::empty()); if VirtualMachine::get().is_shutting_down() { - // Free the streams first: `free_resources` releases the refs their signals hold. - // The map is emptied so a later `Drop` won't double-free. - let streams = self.streams.replace(BunHashMap::default()); - for (_, item) in streams.iter() { - let stream = *item; - // SAFETY: map has been emptied; each entry is freed exactly once. - unsafe { - (*stream).free_resources::(self); - drop(bun_core::heap::take(stream)); - } - } - drop(streams); - // Then the refs of frames/tasks that will never run again, so the + // Release the refs of frames/tasks that will never run again, so the // wrapper's deref can actually reach zero and run `Drop`. self.release_refs_stranded_by_exit(); } diff --git a/src/runtime/api/h2.classes.ts b/src/runtime/api/h2.classes.ts index d612b4ea0ca3..11c16afcf439 100644 --- a/src/runtime/api/h2.classes.ts +++ b/src/runtime/api/h2.classes.ts @@ -55,7 +55,7 @@ export default [ }, detach: { fn: "detachFromJS", - length: 0, + length: 1, }, rstStream: { fn: "rstStream", @@ -85,10 +85,6 @@ export default [ fn: "setStreamContext", length: 2, }, - getEndAfterHeaders: { - fn: "getEndAfterHeaders", - length: 1, - }, getStreamState: { fn: "getStreamState", length: 1, @@ -109,10 +105,6 @@ export default [ fn: "emitErrorToAllStreams", length: 1, }, - emitAbortToAllStreams: { - fn: "emitAbortToAllStreams", - length: 0, - }, getNextStream: { fn: "getNextStream", length: 0, diff --git a/src/runtime/api/html_rewriter.rs b/src/runtime/api/html_rewriter.rs index a079623242bd..d84ba62929b8 100644 --- a/src/runtime/api/html_rewriter.rs +++ b/src/runtime/api/html_rewriter.rs @@ -1115,6 +1115,7 @@ impl RewriterPipe { } result_ref.set_url(original.url().clone()); + result_ref.set_body_decoded(original.body_decoded()); // ── wire input ────────────────────────────────────────────────────── let value = original.get_body_value(); diff --git a/src/runtime/bake/dev_server/ensure_route.rs b/src/runtime/bake/dev_server/ensure_route.rs index fff11a4f5bda..484b02092001 100644 --- a/src/runtime/bake/dev_server/ensure_route.rs +++ b/src/runtime/bake/dev_server/ensure_route.rs @@ -80,6 +80,7 @@ impl RequestEnsureRouteBundledCtx { } fn on_plugin_error(&mut self) -> JsResult<()> { + self.resp.write_status(b"500 Internal Server Error"); self.resp.end(b"Plugin Error", false); Ok(()) } diff --git a/src/runtime/dns_jsc/cares_jsc.rs b/src/runtime/dns_jsc/cares_jsc.rs index b2c195118c16..c920ec9f97f6 100644 --- a/src/runtime/dns_jsc/cares_jsc.rs +++ b/src/runtime/dns_jsc/cares_jsc.rs @@ -845,6 +845,15 @@ pub(crate) fn error_to_js_with_syscall_and_hostname( Ok(instance) } +/// Builds the `getaddrinfo ` error for a non-zero getaddrinfo(3) return value. +pub(crate) fn getaddrinfo_error(gai: c_int, hostname: &[u8]) -> Option { + if gai == 0 { + return None; + } + c_ares::Error::init_eai(gai) + .map(|error| system_error_with_syscall_and_hostname(error, b"getaddrinfo", hostname)) +} + /// Thrown before uSockets' synchronous `getaddrinfo` can block on a name that can never resolve. pub(crate) fn not_a_hostname_error(global_this: &JSGlobalObject, hostname: &[u8]) -> JSValue { system_error_with_syscall_and_hostname(c_ares::Error::ENOTFOUND, b"getaddrinfo", hostname) diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 920f9c462944..2eb46daa7d91 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -96,6 +96,8 @@ pub(crate) struct RuntimeState { /// has not been proven safe; keep the prior behavior of leaking any /// still-occupied slot while still freeing the pool allocation itself. pub(crate) body_value_pool: Box>, + /// `Bun.serve`'s `RequestContext` pools. + pub(crate) request_pools: crate::server::RequestPools, /// The resolver's PackageManager wake-handler context (module queue + VM /// handle); the resolver holds a raw pointer to it. Freed with the state. pub(crate) wake_ctx: Option>, @@ -196,6 +198,17 @@ pub(crate) fn global_dns_data() -> &'static core::cell::OnceCell &'static crate::server::RequestPools { + let state = runtime_state(); + debug_assert!(!state.is_null(), "request_pools before init_runtime_state"); + // SAFETY: `state` is the live per-thread `RuntimeState` box; the field + // address is stable for the VM's lifetime and only read (interior + // mutability via `OnceCell`). + unsafe { &(*state).request_pools } +} + /// [`RuntimeState::lint`] of this thread's VM. #[inline] pub(crate) fn lint_vm() -> &'static core::cell::OnceCell> { @@ -376,6 +389,7 @@ unsafe fn init_runtime_state( ) } }, + request_pools: crate::server::RequestPools::default(), wake_ctx: None, cron_jobs: Vec::new(), lint: core::cell::OnceCell::new(), diff --git a/src/runtime/node/node_fs.rs b/src/runtime/node/node_fs.rs index 14d696aae435..bbfe17719490 100644 --- a/src/runtime/node/node_fs.rs +++ b/src/runtime/node/node_fs.rs @@ -7285,7 +7285,11 @@ impl NodeFS { // For certain files, the size might be 0 but the file might still have contents. // https://github.com/oven-sh/bun/issues/1220 let max_size: u64 = args.max_size.map(|v| v as u64).unwrap_or(BLOB_SIZE_MAX); - let has_max_size = args.max_size.is_some(); + // `st_size` says nothing about a pipe, a device or a procfs file. Read those to EOF or to the end of the caller's slice. + let size_unknown = !sys::S::ISREG(stat_.st_mode as u32) || stat_.st_size == 0; + let has_max_size = max_size != BLOB_SIZE_MAX && !size_unknown; + let limit_size_for_javascript = + args.limit_size_for_javascript || (args.max_size.is_some() && size_unknown); let size: u64 = (stat_.st_size as i64) .min(max_size as i64) // Only used in DOMFormData @@ -7345,7 +7349,7 @@ impl NodeFS { let amt = Syscall::read(fd, &mut buf[total..upper])?; total += amt; - if args.limit_size_for_javascript { + if limit_size_for_javascript { if let Some(err) = Self::should_throw_out_of_memory_early_for_javascript( args.encoding, total, diff --git a/src/runtime/node/node_http_binding.rs b/src/runtime/node/node_http_binding.rs index 6986d8c1230c..b4417771b0fc 100644 --- a/src/runtime/node/node_http_binding.rs +++ b/src/runtime/node/node_http_binding.rs @@ -1,48 +1,13 @@ -//! `node:http` native binding — `getBunServerAllClosedPromise` / -//! `getBunServerOpenCount`, and the `ws` package's native server +//! `node:http` native binding — `getBunServerOpenCount`, and the `ws` package's native server //! transport: `upgradeNodeHTTPResponseToWebSocket` / `sendWebSocketFrame`. use bun_jsc::bindgen::{JsLocal, JsScope}; -use bun_jsc::{CallFrame, JSGlobalObject, JSValue, JsResult}; +use bun_jsc::{JSValue, JsResult}; use bun_uws_sys::Opcode; use bun_uws_sys::response::PerMessageDeflateParams; use crate::generated_bindgen::node_http_binding::{BunServer, DeflateParams, UpgradeResponse}; -use crate::server::{DebugHTTPSServer, DebugHTTPServer, HTTPSServer, HTTPServer, ServerWebSocket}; - -pub(crate) fn get_bun_server_all_closed_promise( - global: &JSGlobalObject, - frame: &CallFrame, -) -> JsResult { - let arguments = frame.arguments(); - if arguments.is_empty() { - return Err(global.throw_not_enough_arguments( - "getBunServerAllClosePromise", - 1, - arguments.len(), - )); - } - - let value = arguments[0]; - - // Try each heterogeneous server type in turn. - macro_rules! try_server { - ($ty:ty) => { - if let Some(server) = value.as_::<$ty>() { - // SAFETY: `JSValue::as_` returns a non-null pointer to the live - // JS-owned server instance; we hold the JS thread for the duration - // of this call so the GC cannot collect it under us. - return Ok(unsafe { &mut *server }.get_all_closed_promise(global)); - } - }; - } - try_server!(HTTPServer); - try_server!(HTTPSServer); - try_server!(DebugHTTPServer); - try_server!(DebugHTTPSServer); - - Err(global.throw_invalid_argument_type_value("server", "bun.Server", value)) -} +use crate::server::ServerWebSocket; /// What keeps the server open besides its listener: the connections (counted from accept), WebSockets and requests. pub(crate) fn get_bun_server_open_count(server: BunServer<'_>) -> f64 { diff --git a/src/runtime/node/node_quic_binding.rs b/src/runtime/node/node_quic_binding.rs index 7bef6fefd398..0abe7bcd4a75 100644 --- a/src/runtime/node/node_quic_binding.rs +++ b/src/runtime/node/node_quic_binding.rs @@ -9,6 +9,7 @@ use super::quic::{callbacks, endpoint, session, stream}; /// prefix (Node: `QUIC_JS_CALLBACKS` in node/src/quic/bindingdata.h). const QUIC_CALLBACK_NAMES: &[&str] = &[ "EndpointClose", + "SessionApplication", "SessionClose", "SessionEarlyDataRejected", "SessionGoaway", @@ -23,12 +24,14 @@ const QUIC_CALLBACK_NAMES: &[&str] = &[ "SessionPathValidation", "SessionTicket", "SessionVersionNegotiation", + "StreamAvailable", "StreamBlocked", "StreamClose", "StreamCreated", "StreamDrain", "StreamHeaders", "StreamReset", + "StreamStopSending", "StreamTrailers", ]; @@ -270,6 +273,7 @@ pub(crate) fn create_node_quic_binding(global: &JSGlobalObject) -> JsResult wants_headers, "WANTS_RESET" => wants_reset, "WANTS_TRAILERS" => wants_trailers, + "WANTS_STOP_SENDING" => wants_stop_sending, "RECEIVED_EARLY_DATA" => received_early_data, "WRITE_DESIRED_SIZE" => write_desired_size, "HIGH_WATER_MARK" => high_water_mark, diff --git a/src/runtime/node/quic/endpoint.rs b/src/runtime/node/quic/endpoint.rs index c4b571d3d57d..5fa8c75d9584 100644 --- a/src/runtime/node/quic/endpoint.rs +++ b/src/runtime/node/quic/endpoint.rs @@ -15,10 +15,11 @@ use bun_lsquic_sys as lsquic; use bun_uws as uws; use crate::jsc_hooks::timer_all_mut as timer_all; +use crate::socket::udp_socket::get_us_error; use crate::timer::{EventLoopTimer, EventLoopTimerState, EventLoopTimerTag}; use super::callbacks; -use super::ffi::lsquic_callback; +use super::ffi::{ctx_ref, lsquic_callback}; use super::now_ns; use super::session::{self, QuicSession, SOCKADDR_IN_LEN, SOCKADDR_IN6_LEN, StoredAddr}; use super::stream; @@ -69,13 +70,8 @@ const QUIC_TRANSPORT_CONNECTION_REFUSED: core::ffi::c_uint = 0x2; const QUIC_VERSION_1: u32 = 0x0000_0001; /// QUIC v2 wire version (RFC 9369 §3). const QUIC_VERSION_2: u32 = 0x6b33_43cf; -const INITIAL_TYPE_V1: u8 = 0b00; // RFC 9000 §17.2 -const INITIAL_TYPE_V2: u8 = 0b01; // RFC 9369 §3.2 /// Longest connection ID QUIC v1/v2 allow (RFC 9000 §17.2). const MAX_CID_LEN: usize = 20; -/// Long-header form bit (byte0 bit 7; RFC 8999 §5.1). -const LONG_HEADER_FORM_BIT: u8 = 0x80; -const LONG_HEADER_TYPE_MASK: u8 = 0x3; /// Byte offset of the DCID length in a long-header packet: form/type byte + /// 4-byte version (RFC 8999 §5.1). const LONG_HEADER_DCID_LEN_OFFSET: usize = 5; @@ -88,7 +84,7 @@ const IPV4_ADDR_LEN: usize = 4; const IPV6_ADDR_LEN: usize = 16; /// QUIC CRYPTO_ERROR base (RFC 9001 §4.8) + TLS handshake_failure(40). const CRYPTO_ERROR_HANDSHAKE_FAILURE: u64 = 0x0100 + 40; -const PROVISIONAL_TIMEOUT_NS: u64 = 10_000_000_000; +const VERNEG_PROBE_TIMEOUT_NS: u64 = 10_000_000_000; const IDX_STATS_CREATED_AT: usize = 0; const IDX_STATS_DESTROYED_AT: usize = 1; @@ -112,6 +108,9 @@ const PREFERRED_ADDRESS_USE: u64 = 1; /// seconds unit `transportParams.maxIdleTimeout` uses. const DEFAULT_MAX_IDLE_TIMEOUT_SECS: u64 = 10; pub(super) const MS_PER_SEC: u64 = 1_000; +/// lsquic.h `es_cc_algo` values. +const CC_ALGO_CUBIC: c_uint = 1; +const CC_ALGO_BBR: c_uint = 2; /// Copy a sockaddr sized by its family (sockaddr_in = 16, sockaddr_in6 = 28) /// so an AF_INET address never over-reads past its allocation. @@ -140,35 +139,15 @@ pub(super) fn stored_addr_from_sockaddr(ptr: *const c_void) -> StoredAddr { StoredAddr::from_raw(ptr.cast(), len) } -fn conn_peer_addr(conn: *mut lsquic::lsquic_conn) -> Option { - let mut local: *const c_void = null(); - let mut peer: *const c_void = null(); - // SAFETY: `conn` is live for the duration of the callback that passed it. - if unsafe { - lsquic::lsquic_conn_get_sockaddr( - conn, - core::ptr::from_mut(&mut local), - core::ptr::from_mut(&mut peer), - ) - } == 0 - && !peer.is_null() - { - Some(stored_addr_from_sockaddr(peer)) - } else { - None - } -} - -struct ProvisionalSession { - dcid: Vec, - peer: StoredAddr, - created_ns: u64, - session: *mut QuicSession, -} - struct BindConfig { host: Vec, port: u16, + /// `LIBUS_*` flags for `ipv6Only` and `reusePort`. + flags: c_int, + /// 0 keeps the OS default for these three, as in Node. + receive_buffer_size: i32, + send_buffer_size: i32, + ttl: i32, } impl Default for BindConfig { @@ -176,7 +155,32 @@ impl Default for BindConfig { BindConfig { host: b"127.0.0.1\0".to_vec(), port: 0, + flags: uws::LIBUS_LISTEN_DEFAULT, + receive_buffer_size: 0, + send_buffer_size: 0, + ttl: 0, + } + } +} + +impl BindConfig { + /// Returns the errno of the first option the OS rejects. Node reports that as a bind failure too. + fn apply_socket_options(&self, socket: &mut uws::udp::Socket) -> Result<(), c_int> { + let check = |rc: c_int| match get_us_error::(rc, bun_sys::Tag::setsockopt) { + Some(err) => Err(c_int::from(err.errno)), + None => Ok(()), + }; + let mut applied: c_int = 0; + if self.receive_buffer_size > 0 { + check(socket.buffer_size(true, self.receive_buffer_size, &mut applied))?; + } + if self.send_buffer_size > 0 { + check(socket.buffer_size(false, self.send_buffer_size, &mut applied))?; + } + if self.ttl > 0 { + check(socket.set_unicast_ttl(self.ttl))?; } + Ok(()) } } @@ -234,12 +238,8 @@ pub(crate) struct QuicEndpoint { pub(super) server_local_tp: JsCell, pub(super) client_local_tp: JsCell, pending_new_sessions: JsCell>, - /// Server sessions announced at Initial receipt (Node's event order). - provisional: JsCell>, pending_verneg: JsCell>, - dead_provisional_peers: JsCell>, block_list: Cell>, - early_keylog: JsCell)>>, block_list_js: JsCell>, block_list_allow: Cell, pub(crate) event_loop_timer: JsCell, @@ -256,7 +256,7 @@ pub(crate) struct QuicEndpoint { /// a native callback (on_data, the timer) is on the stack and owns the /// flush when it unwinds -- JS it dispatches (onSessionNew and friends) must /// not re-enter lsquic mid-callback, which is why `processing` alone is not - /// enough: on_data announces to JS *before* it feeds packets. + /// enough: `on_new_mini_conn` creates a session while on_data feeds packets. send_scope_depth: Cell, global: Cell<*const JSGlobalObject>, @@ -470,8 +470,7 @@ extern "C" fn on_data( if closed_any { this.drive_engines_once(); } - // This callback dispatches JS (provisional announce) before it feeds - // packets; hold the send scope so that JS cannot flush lsquic underneath us. + // `on_new_mini_conn` runs inside `lsquic_engine_packet_in`. The send scope keeps it from re-entering lsquic. this.send_scope_depth.set(this.send_scope_depth.get() + 1); bun_boringssl_sys::ERR_clear_error(); for i in 0..packets { @@ -606,14 +605,7 @@ extern "C" fn on_data( { continue; } - // Node announces server sessions at Initial receipt — before the - // handshake — so `onsession` precedes the client's `opened`. - if let Err(err) = - this.maybe_announce_provisional(global, payload, core::ptr::from_ref(peer).cast()) - { - crate::dispatch::fold(Err(err)); - } - let engines = match owner_engine { + let engines = match owner_engine.or_else(|| this.client_engine_owner(payload)) { // Already matched above: the other engine would only miss it. Some(e) => [e, null_mut()], None => [this.server_engine.get(), this.client_engine.get()], @@ -645,68 +637,6 @@ extern "C" fn on_data( } lsquic_callback! { - fn on_mini_conn_failed(this: &QuicEndpoint, peer_sa: *const c_void, error_code: u64) { - /// CRYPTO_ERROR base (RFC 9001 §4.8) + TLS no_application_protocol(120). - const CRYPTO_ERROR_NO_APPLICATION_PROTOCOL: u64 = 0x0100 + 120; - if peer_sa.is_null() { - return; - } - let peer = stored_addr_from_sockaddr(peer_sa); - this.discard_early_keylog(&peer); - let peer_decoded = peer.decode(); - let failed = this.provisional.with_mut(|v| { - let idx = v.iter().position(|p| p.peer.decode() == peer_decoded); - idx.map(|i| v.remove(i).session) - }); - /// Bit 63 marks "the peer sent its own CONNECTION_CLOSE" (QUIC codes - /// fit in 62 bits); the low bits carry the peer's code. See the - /// connection-close-pns lsquic patch. - const PEER_CLOSE_BIT: u64 = 1 << 63; - if let Some(session) = failed { - if let Some(session) = this.live_session(session) { - if error_code & PEER_CLOSE_BIT != 0 { - // Not a failure: the client closed during the handshake - // (connect() then immediate close()). Report the peer's - // own code so `closed` settles the way node's does. - session.push_event(session::SessionEvent::PeerClose { - app_error: false, - code: error_code & !PEER_CLOSE_BIT, - reason: Vec::new(), - }); - session.push_event(session::SessionEvent::Closed); - session.schedule_process(); - return; - } - if error_code == 0 { - // The peer went away without a frame (destroyed client, - // dropped packets): node's server surfaces the idle death - // of a handshaking session as a clean close, not an error. - session.push_event(session::SessionEvent::PeerClose { - app_error: false, - code: 0, - reason: Vec::new(), - }); - session.push_event(session::SessionEvent::Closed); - session.schedule_process(); - return; - } - let code = error_code; - let reason: &[u8] = if code == CRYPTO_ERROR_NO_APPLICATION_PROTOCOL { - b"no application protocol" - } else { - b"handshake failed" - }; - session.push_event(session::SessionEvent::PeerClose { - app_error: false, - code, - reason: reason.to_vec(), - }); - session.push_event(session::SessionEvent::Closed); - session.schedule_process(); - } - } - } - fn packets_out( this: &QuicEndpoint, specs: *const lsquic::lsquic_out_spec, @@ -791,16 +721,18 @@ lsquic_callback! { ); if rv < 1 { // SAFETY: `errno_ptr()` is this thread's errno slot. - unsafe { + let stop = unsafe { let e = bun_core::ffi::errno_ptr(); - // EMSGSIZE has to survive: it is how lsquic learns to drop - // an oversized packet and feed DPLPMTUD (ci_packet_too_large). - // Anything else it cannot act on becomes backpressure. - if *e != libc::EAGAIN && *e != libc::EWOULDBLOCK && *e != libc::EMSGSIZE { + if rv == 0 || *e == libc::ENOBUFS { *e = libc::EAGAIN; } + // EMSGSIZE has to survive: lsquic drops the oversized packet and feeds DPLPMTUD (ci_packet_too_large). + *e == libc::EAGAIN || *e == libc::EMSGSIZE + }; + if stop { + break; } - break; + // The kernel refused this destination. A short return pauses every session, so count it as sent and let loss recovery handle it. } this.add_stat(IDX_STATS_PACKETS_SENT, 1); this.add_stat(IDX_STATS_BYTES_SENT, total as u64); @@ -935,6 +867,8 @@ fn apply_transport_params( // Node's default max_idle_timeout is 10 seconds // (node/src/quic/transportparams.h DEFAULT_MAX_IDLE_TIMEOUT); lsquic's is 30. s.idle_timeout(10); + // Node's default `cc` is cubic. lsquic's is adaptive, which picks BBRv1 if the first RTT sample is over 1.5 ms. + s.cc_algo(CC_ALGO_CUBIC); if !options.is_object() { local_tp.max_idle_timeout = match s.get_idle_timeout_ms() { 0 => s.get_idle_timeout().saturating_mul(1000), @@ -1066,16 +1000,11 @@ fn apply_transport_params( }; if let Some(cc) = options.get(global, "cc")?.filter(|v| v.is_string()) { let name = bun_core::String::from_js(cc, global)?.to_owned_slice(); - // lsquic.h es_cc_algo: 0=default(→3 Adaptive), 1=Cubic, 2=BBRv1, - // 3=Adaptive. lsquic ships no Reno (NGTCP2_CC_ALGO_RENO in node's - // backend), so map 'reno' to Cubic, the closest loss-based option, - // rather than silently falling through to Adaptive which may pick BBR. - let algo = match name.as_slice() { - b"cubic" | b"reno" => 1, - b"bbr" => 2, - _ => 0, - }; - s.cc_algo(algo); + // lsquic has no Reno. Cubic is its closest loss-based controller. + s.cc_algo(match name.as_slice() { + b"bbr" => CC_ALGO_BBR, + _ => CC_ALGO_CUBIC, + }); } Ok(()) } @@ -1101,19 +1030,6 @@ impl QuicEndpoint { frame: &CallFrame, this_value: JSValue, ) -> JsResult<*mut Self> { - static INIT: std::sync::Once = std::sync::Once::new(); - INIT.call_once(|| { - // SAFETY: pure library init. - unsafe { - lsquic::lsquic_global_init( - lsquic::LSQUIC_GLOBAL_CLIENT | lsquic::LSQUIC_GLOBAL_SERVER, - ) - }; - if bun_core::getenv_z(bun_core::zstr!("BUN_DEBUG_lsquic")).is_some() { - // SAFETY: static C string. - unsafe { lsquic::us_nq_enable_logging(c"debug".as_ptr()) }; - } - }); lsquic::debug_assert_layout(); let this = QuicEndpoint { @@ -1151,11 +1067,8 @@ impl QuicEndpoint { server_local_tp: JsCell::new(lsquic::NqTransportParams::default()), client_local_tp: JsCell::new(lsquic::NqTransportParams::default()), pending_new_sessions: JsCell::new(Vec::new()), - provisional: JsCell::new(Vec::new()), pending_verneg: JsCell::new(Vec::new()), - dead_provisional_peers: JsCell::new(Vec::new()), block_list: Cell::new(None), - early_keylog: JsCell::new(Vec::new()), block_list_js: JsCell::new(None), block_list_allow: Cell::new(false), event_loop_timer: JsCell::new(EventLoopTimer::init_paused( @@ -1201,7 +1114,8 @@ impl QuicEndpoint { get_client_ssl_ctx, lookup_cert, packets_out, - on_mini_conn_failed, + on_new_mini_conn: session::on_new_mini_conn, + on_mini_conn_failed: session::on_mini_conn_failed, }); // SAFETY: `raw` was just created and is uniquely owned by the wrapper. unsafe { @@ -1238,14 +1152,39 @@ impl QuicEndpoint { host_nul.push(0); // SAFETY: `raw` is uniquely owned here. unsafe { - (**raw).bind_config.set(BindConfig { - host: host_nul, - port, + (**raw).bind_config.with_mut(|cfg| { + cfg.host = host_nul; + cfg.port = port; }) }; } } } + let mut flags = uws::LIBUS_LISTEN_DEFAULT; + if options.get_boolean_loose(global, "ipv6Only")? == Some(true) { + flags |= uws::LIBUS_SOCKET_IPV6_ONLY; + } + if options.get_boolean_loose(global, "reusePort")? == Some(true) { + // The bind fails where SO_REUSEPORT doesn't exist, like UV_UDP_REUSEPORT. + flags |= + uws::LIBUS_LISTEN_REUSE_PORT | uws::LIBUS_LISTEN_DISALLOW_REUSE_PORT_FAILURE; + } + let read_i32 = |name| -> JsResult { + Ok(read_u64_option(global, options, name)? + .map_or(0, |v| v.min(i32::MAX as u64) as i32)) + }; + let receive_buffer_size = read_i32("udpReceiveBufferSize")?; + let send_buffer_size = read_i32("udpSendBufferSize")?; + let ttl = read_i32("udpTTL")?; + // SAFETY: as above. + unsafe { + (**raw).bind_config.with_mut(|cfg| { + cfg.flags = flags; + cfg.receive_buffer_size = receive_buffer_size; + cfg.send_buffer_size = send_buffer_size; + cfg.ttl = ttl; + }) + }; if let Some(bl_js) = options.get(global, "blockList")?.filter(|v| v.is_object()) { if let Some(bl) = crate::generated_classes::js_BlockList::from_js(bl_js) { // SAFETY: `raw` is uniquely owned here; the Strong keeps @@ -1335,6 +1274,36 @@ impl QuicEndpoint { // SAFETY: see doc comment. self.sessions.get().contains(&p).then(|| unsafe { &*p }) } + /// Counts the server sessions from one IP address. Like Node, it ignores the port. + fn server_sessions_from_host(&self, family: u16, ip: &[u8]) -> usize { + self.sessions + .get() + .iter() + // SAFETY: a registered session is live on the JS thread, see `live_session`. + .map(|&session| unsafe { &*session }) + .filter(|session| { + session.is_server() + && session + .remote_addr + .get() + .decode() + .is_some_and(|(f, _, addr)| f == family && addr == ip) + }) + .count() + } + /// Returns true if `busy` or a connection limit refuses a new server session from `peer`. + fn refuses_new_session(&self, peer: Option<&StoredAddr>) -> bool { + let (busy, max_total, max_per_host) = + self.with_state(|s| (s.busy, s.max_connections_total, s.max_connections_per_host)); + busy != 0 + || (max_total > 0 && self.sessions.get().len() >= max_total as usize) + || (max_per_host > 0 + && peer + .and_then(StoredAddr::decode) + .is_none_or(|(family, _, ip)| { + self.server_sessions_from_host(family, ip) >= max_per_host as usize + })) + } fn write_stat(&self, idx: usize, value: u64) { if !self.stats.is_null() && idx < ENDPOINT_STATS_FIELDS.len() { // SAFETY: `stats` is a live `[u64; N]` view. @@ -1376,24 +1345,27 @@ impl QuicEndpoint { on_recv_error, cfg.host.as_ptr().cast(), cfg.port, - 0, + cfg.flags, Some(&mut err), core::ptr::from_ref(self).cast_mut().cast::(), ); - if !socket.is_null() { + let bound = if socket.is_null() { + Err(err) + } else { + self.socket.set(Some(socket)); // Linked only while we hold a socket, so an idle endpoint costs // the loop nothing. self.link_loop_driver(); - } - if socket.is_null() { + cfg.apply_socket_options(uws::udp::Socket::opaque_mut(socket)) + }; + if let Err(errno) = bound { self.this_value .with_mut(|r| r.set_strong(this_value, global)); self.finish_close(); self.pending_endpoint_close.set(false); - self.deliver_endpoint_close(global, CLOSECONTEXT_BIND_FAILURE, err); + self.deliver_endpoint_close(global, CLOSECONTEXT_BIND_FAILURE, errno); return Ok(false); } - self.socket.set(Some(socket)); let sock = uws::udp::Socket::opaque_mut(socket); let port = sock.bound_port(); let mut ip = [0u8; IPV6_ADDR_LEN]; @@ -1492,9 +1464,7 @@ impl QuicEndpoint { let _keep_alive = bun_jsc::Strong::create(self.this_value.get().get(), global); loop { - // Arrival order: both push sites append, and a burst of Initials in - // one recvmmsg batch must announce in the order the sessions loop - // below then walks them. + // In the order the ClientHellos were parsed. let Some(session) = self.pending_new_sessions.with_mut(|v| { if v.is_empty() { None @@ -1527,7 +1497,7 @@ impl QuicEndpoint { session.process_events(global); session.maybe_finish_deferred_close(); } - self.sweep_provisional(); + self.sweep_verneg_probes(); self.rearm_timer(); self.update_keepalive(); if self.closing.get() @@ -1609,12 +1579,9 @@ impl QuicEndpoint { } } let mut ms = earliest_us.map(|us| (us.max(0) as u64).div_ceil(1000).max(1)); - // Both of these are settled only by `sweep_provisional`, and neither - // keeps an engine ticking on its own -- a probe has no engine at all, - // and an idle engine stops advising a tick -- so without a poll the - // sweep never runs and an announced session never settles. + // A probe has no engine to advise a tick, so only this poll runs its sweep. const SWEEP_POLL_MS: u64 = 250; - if !self.pending_verneg.get().is_empty() || !self.provisional.get().is_empty() { + if !self.pending_verneg.get().is_empty() { ms = Some(ms.map_or(SWEEP_POLL_MS, |m| m.min(SWEEP_POLL_MS))); } if self.followup_due.get() { @@ -1653,101 +1620,28 @@ impl QuicEndpoint { this_ref.process(global); } - /// Node announces server sessions at Initial receipt. - fn maybe_announce_provisional( - &self, - global: &JSGlobalObject, - payload: &[u8], - peer: *const c_void, - ) -> JsResult<()> { - if self.server_engine.get().is_null() - || self.with_state(|s| s.listening) == 0 - || self.closing.get() - { - return Ok(()); - } - // Long header: 0b1xxx_xxxx; version != 0 (0 = version negotiation); - // DCID length-prefixed at byte 5 (RFC 8999 §5.1). - if payload.len() < LONG_HEADER_MIN_LEN || payload[0] & LONG_HEADER_FORM_BIT == 0 { - return Ok(()); - } - let version = u32::from_be_bytes([payload[1], payload[2], payload[3], payload[4]]); - // Type bits (byte0 5:4): v1 Initial = 0b00 (RFC 9000 §17.2), v2 - // Initial = 0b01 (RFC 9369 §3.2). - let type_bits = (payload[0] >> 4) & LONG_HEADER_TYPE_MASK; - let is_initial = match version { - QUIC_VERSION_1 => type_bits == INITIAL_TYPE_V1, - QUIC_VERSION_2 => type_bits == INITIAL_TYPE_V2, - _ => false, - }; - if !is_initial { - return Ok(()); + /// Returns the client engine if `payload` is a long-header packet for one of its connections. + fn client_engine_owner(&self, payload: &[u8]) -> Option<*mut lsquic::lsquic_engine> { + let client = self.client_engine.get(); + if client.is_null() || self.server_engine.get().is_null() { + return None; + } + if payload.len() < LONG_HEADER_MIN_LEN || payload[0] & HEADER_FORM_LONG == 0 { + return None; } let dcid_len = payload[LONG_HEADER_DCID_LEN_OFFSET] as usize; let dcid_start = LONG_HEADER_DCID_LEN_OFFSET + 1; if dcid_len == 0 || dcid_len > MAX_CID_LEN || payload.len() < dcid_start + dcid_len { - return Ok(()); + return None; } let dcid = &payload[dcid_start..dcid_start + dcid_len]; - if self.provisional.get().iter().any(|p| p.dcid == dcid) { - return Ok(()); - } - // On a dual-mode endpoint the peer's Initial *response* carries our - // client's SCID, which only the client engine hashes -- checking the - // server engine alone would announce a phantom server session for it. - let known = [self.server_engine.get(), self.client_engine.get()] - .into_iter() - .filter(|e| !e.is_null()) - .any(|e| { - // SAFETY: engines live until release_socket. - unsafe { lsquic::lsquic_engine_cid_in_use(e, dcid.as_ptr(), dcid.len()) != 0 } - }); - if known { - return Ok(()); - } - let peer_stored = stored_addr_from_sockaddr(peer); - let peer_decoded = peer_stored.decode(); - let (busy, max_conns) = self.with_state(|s| (s.busy, s.max_connections_total)); - // `closing`: on_new_conn refuses these at promotion, so announcing one - // here would surface a session that can never open. - if self.closing.get() - || busy != 0 - || (max_conns > 0 && self.sessions.get().len() >= max_conns as usize) - { - return Ok(()); - } - bun_core::scoped_log!( - quic, - "announce provisional dcid={:02x?} peer={:?}", - dcid, - peer_decoded - ); - let endpoint_handle = self.this_value.get().get(); - let (session, _handle) = QuicSession::create( - global, - self.vtable_ptr, - core::ptr::from_ref(self).cast_mut(), - 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)); - self.pending_new_sessions.with_mut(|v| v.push(session)); - self.add_stat(IDX_STATS_SERVER_SESSIONS, 1); - self.provisional.with_mut(|v| { - v.push(ProvisionalSession { - dcid: dcid.to_vec(), - peer: peer_stored, - created_ns: now_ns(), - session, - }) - }); - applied + // SAFETY: engines live until release_socket. + let in_use = unsafe { lsquic::lsquic_engine_cid_in_use(client, dcid.as_ptr(), dcid.len()) }; + // The server engine doesn't know this DCID and would start a handshake for the packet. + (in_use != 0).then_some(client) } - /// Queues the handshake-failure close both timeout lists deliver. + /// Queues the handshake-failure close of a probe that got no reply. fn expire_session(&self, session: *mut QuicSession) -> bool { let Some(session) = self.live_session(session) else { return false; @@ -1761,28 +1655,16 @@ impl QuicEndpoint { true } - fn sweep_provisional(&self) { + fn sweep_verneg_probes(&self) { let now = now_ns(); let mut n_expired = 0usize; - // Unbounded, like `pending_verneg` below: a fixed cap left the overflow - // in `provisional`, where `on_new_conn` can still match it and promote - // a session that was already past its deadline as a successful one. let mut expired: Vec<*mut QuicSession> = Vec::new(); - self.provisional.with_mut(|v| { - v.retain(|p| { - if now.saturating_sub(p.created_ns) < PROVISIONAL_TIMEOUT_NS { - return true; - } - expired.push(p.session); - false - }); - }); // A probe has no lsquic conn, so no handshake or idle timeout covers // it, and RFC 9000 s6.1 makes the reply optional with no retransmit: // without this a dropped reply hangs `opened` and `close()` forever. self.pending_verneg.with_mut(|v| { v.retain(|(session, _, created_ns)| { - if now.saturating_sub(*created_ns) < PROVISIONAL_TIMEOUT_NS { + if now.saturating_sub(*created_ns) < VERNEG_PROBE_TIMEOUT_NS { return true; } expired.push(*session); @@ -1799,97 +1681,36 @@ impl QuicEndpoint { // ask `rearm_timer` for the follow-up pass that delivers these. self.followup_due.set(true); } - self.dead_provisional_peers - .with_mut(|d| d.retain(|&(_, at)| now.saturating_sub(at) < PROVISIONAL_TIMEOUT_NS)); } - pub(super) fn on_new_conn(&self, conn: *mut lsquic::lsquic_conn) -> *mut QuicSession { + /// Makes a new mini conn skip its TLS handshake and answer with CONNECTION_REFUSED. + fn refuse_mini_conn(conn: lsquic::MiniConn) -> *mut QuicSession { + conn.refuse(QUIC_TRANSPORT_CONNECTION_REFUSED); + null_mut() + } + + /// Creates the session that lsquic keeps as a new mini conn's ctx, or refuses the conn. + pub(super) fn on_new_mini_conn(&self, conn: lsquic::MiniConn) -> *mut QuicSession { let global_ptr = self.global.get(); - if global_ptr.is_null() { - return null_mut(); + // A session accepted during close() would keep `sessions` non-empty, and the close would never finish. + if global_ptr.is_null() || self.closing.get() { + return Self::refuse_mini_conn(conn); } // 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| { - let idx = peer - .as_ref() - .and_then(|peer| v.iter().position(|p| p.peer.decode() == peer.decode())); - idx.map(|i| v.remove(i).session) - }); - if let Some(session) = provisional { - if let Some(live) = self.live_session(session) { - live.bind_conn(conn); - live.note_handshake_ok(handshaken); - return session; - } - } - // A close() must stop accepting: a session promoted now keeps - // `sessions` non-empty and the finish gate never trips. CONNECTION_ - // REFUSED (not a code-0 close) is what makes the client reject. - if self.closing.get() { - // SAFETY: `conn` is the live conn lsquic just created. - unsafe { - lsquic::lsquic_conn_abort_error( - conn, - 0, - QUIC_TRANSPORT_CONNECTION_REFUSED, - core::ptr::null(), - ); - } - return null_mut(); - } - let (busy, max_conns) = self.with_state(|s| (s.busy, s.max_connections_total)); - if busy != 0 || (max_conns > 0 && self.sessions.get().len() >= max_conns as usize) { - // SAFETY: `conn` is the live conn lsquic just created. - unsafe { - lsquic::lsquic_conn_abort_error( - conn, - 0, - QUIC_TRANSPORT_CONNECTION_REFUSED, - core::ptr::null(), - ); - } + let peer = conn + .sockaddrs() + .map(|(_, peer)| stored_addr_from_sockaddr(peer)); + // As Node.js does: https://github.com/nodejs/node/blob/v26.3.0/src/quic/endpoint.cc#L1503 + if self.refuses_new_session(peer.as_ref()) { self.add_stat(IDX_STATS_SERVER_BUSY_COUNT, 1); - return null_mut(); - } - let peer_decoded = peer.as_ref().and_then(StoredAddr::decode); - let was_dead = peer_decoded.is_some() - && self - .dead_provisional_peers - .get() - .iter() - .any(|(addr, _)| addr.decode() == peer_decoded); - if was_dead { - // One marker per destroyed provisional, so consume exactly one: - // draining them all would treat a peer's later retries as live. - self.dead_provisional_peers.with_mut(|d| { - if let Some(i) = d.iter().position(|(addr, _)| addr.decode() == peer_decoded) { - d.swap_remove(i); - } - }); - // Node's dead server goes silent, and `lsquic_conn_abort` sends a - // CONNECTION_CLOSE -- use the silent variant the sibling in - // session.rs uses. - // SAFETY: `conn` is the live conn lsquic just created. - if let Some(c) = unsafe { lsquic::Conn::from_raw(conn) } { - c.abort_silent(); - } - return null_mut(); + return Self::refuse_mini_conn(conn); } match QuicSession::create( global, self.vtable_ptr, core::ptr::from_ref(self).cast_mut(), - endpoint_handle, - conn, + self.this_value.get().get(), true, self.application(true), ) { @@ -1897,24 +1718,50 @@ impl QuicEndpoint { if let Err(err) = self.apply_server_session_options(global, session) { crate::dispatch::fold(Err(err)); } - self.sessions.with_mut(|v| v.push(session)); - 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).note_handshake_ok(handshaken) }; + unsafe { (*session).bind_mini_conn(conn) }; + self.sessions.with_mut(|v| v.push(session)); session } Err(e) => { - // Abort like the sibling null-return branches, or the conn - // lingers with no session behind it. crate::dispatch::fold(Err(e)); - // SAFETY: `conn` is the live conn lsquic just created. - if let Some(c) = unsafe { lsquic::Conn::from_raw(conn) } { - c.abort_silent(); - } - null_mut() + Self::refuse_mini_conn(conn) + } + } + } + + /// Queues the listen callback for a server session. + pub(super) fn announce(&self, session: *mut QuicSession) { + self.pending_new_sessions.with_mut(|v| v.push(session)); + self.add_stat(IDX_STATS_SERVER_SESSIONS, 1); + } + + /// Binds a promoted server conn to its mini conn's session, or drops the conn if that session was destroyed. + pub(super) fn on_new_conn(&self, conn: *mut lsquic::lsquic_conn) -> *mut QuicSession { + // SAFETY: `conn` is the live conn lsquic just created. + let Some(new_conn) = (unsafe { lsquic::Conn::from_raw(conn) }) else { + return null_mut(); + }; + // SAFETY: `conn` is the live conn lsquic just created. + let ctx = unsafe { lsquic::lsquic_conn_get_ctx(conn) }; + // SAFETY: `ctx_ref` documents the shim's context-pointer contract. + let Some(session) = (unsafe { ctx_ref::(ctx) }) else { + if self.closing.get() { + // A code-0 close wouldn't make the client of a closing endpoint reject. + new_conn.abort_error(false, QUIC_TRANSPORT_CONNECTION_REFUSED, c""); + } else { + // Node's dead server goes silent: no CONNECTION_CLOSE. + new_conn.abort_silent(); } + return null_mut(); + }; + // `connect()` binds a client conn itself. + if session.is_server() { + session.bind_conn(conn); + // lsquic only promotes a server conn once its handshake is done, and never calls `on_hsk_done` for it. + session.note_handshake_ok(new_conn); } + ctx.cast() } pub(super) fn configured_alpn(&self, is_server: bool) -> Option> { @@ -1974,17 +1821,6 @@ impl QuicEndpoint { .with_mut(|v| v.retain(|&s| s != session)); self.pending_verneg .with_mut(|v| v.retain(|&(s, _, _)| s != session)); - let now = now_ns(); - self.provisional.with_mut(|v| { - v.retain(|p| { - if p.session != session { - return true; - } - self.dead_provisional_peers - .with_mut(|d| d.push((p.peer, now))); - false - }) - }); } fn build_engine( @@ -2021,6 +1857,7 @@ impl QuicEndpoint { let mut settings = lsquic::Settings::new(is_server, is_http); // Node always advertises datagram support. settings.datagrams(1); + // Delayed ACKs send ACK_FREQUENCY, and an lsquic server closes the connection on one in a 0-RTT packet. settings.delayed_acks(0); // Node's `closed` promise on the peer resolves on receipt of CONNECTION_CLOSE. settings.silent_close(0); @@ -2167,32 +2004,6 @@ impl QuicEndpoint { Ok(()) } - pub(super) fn buffer_early_keylog(&self, ssl: *mut c_void, peer: StoredAddr, line: Vec) { - self.early_keylog.with_mut(|v| v.push((ssl, peer, line))); - } - - /// Without this, buffered lines outlive the freed `SSL*` and a later - /// handshake at the recycled address claims a dead handshake's secrets. - fn discard_early_keylog(&self, peer: &StoredAddr) { - let peer_decoded = peer.decode(); - self.early_keylog - .with_mut(|v| v.retain(|(_, p, _)| p.decode() != peer_decoded)); - } - pub(super) fn take_early_keylog(&self, ssl: *mut c_void) -> Vec> { - self.early_keylog.with_mut(|v| { - let mut out = Vec::new(); - v.retain_mut(|(s, _, line)| { - if *s == ssl { - out.push(core::mem::take(line)); - false - } else { - true - } - }); - out - }) - } - fn deliver_endpoint_close(&self, global: &JSGlobalObject, context: u8, status: c_int) { self.poll_ref.with_mut(|p| p.disable()); if let Some(callback) = callbacks::get(global, "onEndpointClose") { @@ -2367,7 +2178,6 @@ impl QuicEndpoint { self.vtable_ptr, core::ptr::from_ref(self).cast_mut(), frame.this(), - null_mut(), false, self.application(false), )?; @@ -2420,7 +2230,7 @@ impl QuicEndpoint { unsafe { lsquic::lsquic_conn_set_ctx(conn, session.cast()); (*session).conn.set(conn); - (*session).cache_sockaddrs(conn); + (*session).cache_sockaddrs(lsquic::Conn::from_raw(conn).and_then(|c| c.sockaddrs())); // `conn` is set above, so teardown clears the conn's ctx and the // late callbacks no-op instead of reaching an unrooted session. if let Err(e) = (*session).apply_options(global, options) { @@ -2458,7 +2268,6 @@ impl QuicEndpoint { self.vtable_ptr, core::ptr::from_ref(self).cast_mut(), this_value, - null_mut(), false, session::Application::None, )?; @@ -2673,13 +2482,13 @@ impl QuicEndpoint { 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; + vt.on_mini_conn_failed = QuicSession::on_mini_conn_failed_detached; } }); } diff --git a/src/runtime/node/quic/ffi.rs b/src/runtime/node/quic/ffi.rs index 05cd446002df..a181187388a5 100644 --- a/src/runtime/node/quic/ffi.rs +++ b/src/runtime/node/quic/ffi.rs @@ -10,6 +10,7 @@ use core::ffi::c_void; /// back via `*(us_nq_vtable**)ctx`. Those structs are `#[repr(C)]` precisely /// so that first-field read stays valid, and the shim never invents or /// offsets a context pointer. +/// * A server session is the ctx of its handshake's mini conn first, then of the promoted conn. /// * The only other value the shim can pass is NULL — teardown clears the /// conn/stream ctx (and lsquic passes NULL for crypto/mini-conn streams that /// never got one), so a null check is the whole liveness test. A non-null diff --git a/src/runtime/node/quic/session.rs b/src/runtime/node/quic/session.rs index 3e5246669924..c74db4b46573 100644 --- a/src/runtime/node/quic/session.rs +++ b/src/runtime/node/quic/session.rs @@ -2,7 +2,7 @@ use core::cell::Cell; use core::ffi::{c_char, c_int, c_uint, c_void}; -use core::ptr::{null, null_mut}; +use core::ptr::null_mut; use std::collections::VecDeque; use bun_jsc::bun_string_jsc; @@ -72,8 +72,6 @@ const HEADERS_UNSUPPORTED: u8 = 2; const DEFAULT_MAX_HEADER_PAIRS: u64 = 128; const DEFAULT_MAX_HEADER_LENGTH: u64 = 16384; -const CONN_STATUS_ERRBUF_LEN: usize = 256; - const TICKET_DELIVERY_DELAY_NS: u64 = 500_000_000; /// Indices into the session stats buffer — must match @@ -284,6 +282,14 @@ pub(super) struct DeferredAbort { marker: u64, } +/// `ondatagramstatus`'s second argument (Node's `quic::DatagramStatus`). +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum DatagramStatus { + Acknowledged, + Lost, + Abandoned, +} + pub(super) enum SessionEvent { HandshakeDone { ok: bool, @@ -335,13 +341,11 @@ pub(super) enum SessionEvent { payload: Vec, early: bool, }, + /// `count` datagrams with consecutive ids, starting at `first_id`, got `status`. DatagramStatus { - id: u64, - sent: bool, - }, - DatagramAckStatus { - count: u32, - acked: bool, + first_id: u64, + count: u64, + status: DatagramStatus, }, EarlyDataFailed, /// HTTP/3 ORIGIN frame payload (RFC 9412). @@ -368,6 +372,8 @@ pub(crate) struct QuicSession { /// `*(us_nq_vtable**)conn_ctx`. vtable: *const lsquic::NqVtable, pub(super) conn: Cell<*mut lsquic::lsquic_conn>, + /// A server session's handshake conn, until lsquic promotes or destroys it. + mini_conn: Cell>, /// The owning endpoint (raw pointer; the JS-side Strong keeps it alive). endpoint: Cell<*mut QuicEndpoint>, endpoint_js: JsCell>, @@ -395,11 +401,10 @@ pub(crate) struct QuicSession { datagram_queue: JsCell)>>, /// Monotonic id assigned by `sendDatagram` (Node returns it as a BigInt). next_datagram_id: Cell, - inflight_datagrams: JsCell>, ticket_delivered: Cell, pending_tickets: JsCell)>>, close_after_streams: Cell, - final_conn_status: JsCell)>>, + final_conn_status: JsCell, Option)>>, pending_local_bidi: JsCell>, pending_local_uni: JsCell>, streams: JsCell>, @@ -407,6 +412,8 @@ pub(crate) struct QuicSession { handshake_reported: Cell, new_token_reported: Cell, close_when_bound: Cell, + /// Set once TLS has parsed a server session's ClientHello, which queues the listen callback. + announced: Cell, deferred_close: JsCell)>>, handshake_pending_ok: Cell, reject_unverified_peer: Cell, @@ -424,6 +431,7 @@ impl QuicSession { Self { vtable, conn: Cell::new(null_mut()), + mini_conn: Cell::new(None), endpoint: Cell::new(null_mut()), endpoint_js: JsCell::new(None), is_server: Cell::new(false), @@ -444,7 +452,6 @@ impl QuicSession { qlog_fin_sent: Cell::new(false), datagram_queue: JsCell::new(VecDeque::new()), next_datagram_id: Cell::new(1), - inflight_datagrams: JsCell::new(VecDeque::new()), ticket_delivered: Cell::new(false), pending_tickets: JsCell::new(VecDeque::new()), close_after_streams: Cell::new(false), @@ -456,6 +463,7 @@ impl QuicSession { handshake_reported: Cell::new(false), new_token_reported: Cell::new(false), close_when_bound: Cell::new(false), + announced: Cell::new(false), deferred_close: JsCell::new(None), handshake_pending_ok: Cell::new(false), reject_unverified_peer: Cell::new(true), @@ -474,7 +482,6 @@ impl QuicSession { vtable: *const lsquic::NqVtable, endpoint: *mut QuicEndpoint, endpoint_handle: JSValue, - conn: *mut lsquic::lsquic_conn, is_server: bool, application: Application, ) -> JsResult<(*mut QuicSession, JSValue)> { @@ -525,7 +532,6 @@ impl QuicSession { reason = "`stats_ptr` is the base of a fresh JSC ArrayBuffer (byteOffset 0), 16-byte aligned as above" )] this.stats.set(stats_ptr.cast::()); - this.conn.set(conn); this.endpoint.set(endpoint); this.endpoint_js .set(Some(Strong::create(endpoint_handle, global))); @@ -533,25 +539,40 @@ impl QuicSession { this.this_value.with_mut(|r| r.set_strong(handle, global)); this.write_stat(IDX_STATS_SESSION_CREATED_AT, now_ns()); let _ = this.vtable; - if !conn.is_null() { - this.cache_sockaddrs(conn); - } Ok((raw, handle)) } + /// `conn` is the mini conn whose ctx this session becomes. + pub(super) fn bind_mini_conn(&self, conn: lsquic::MiniConn) { + debug_assert!(self.conn.get().is_null() && self.mini_conn.get().is_none()); + self.mini_conn.set(Some(conn)); + self.cache_sockaddrs(conn.sockaddrs()); + } + + /// Node announces a session at its first Initial, after a Retry validated the address. Bun sends no Retry. + pub(super) fn on_client_hello(&self) { + if self.announced.replace(true) { + return; + } + if let Some(endpoint) = self.endpoint_ref() { + endpoint.announce(core::ptr::from_ref(self).cast_mut()); + } + } + + /// Frees a server session that was never announced. + fn discard(&self) { + // SAFETY: sessions only exist on the JS thread of this realm, which the global outlives. + if let Some(global) = unsafe { self.global.get().as_ref() } { + self.teardown(global); + } + } + + /// lsquic promoted the mini conn to `conn` and moved the ctx to it. pub(super) fn bind_conn(&self, conn: *mut lsquic::lsquic_conn) { + debug_assert!(self.conn.get().is_null() && self.mini_conn.get().is_some()); + self.mini_conn.set(None); self.conn.set(conn); - // SAFETY: `conn` is the live conn lsquic just promoted; `self` is - // the heap-allocated session (conn-ctx contract). - unsafe { lsquic::lsquic_conn_set_ctx(conn, core::ptr::from_ref(self).cast_mut().cast()) }; - // SAFETY: `conn` is the live conn lsquic just promoted. - let promoted = unsafe { lsquic::Conn::from_raw(conn) }; - if let (Some(ep), Some(c)) = (self.endpoint_ref(), promoted) { - for line in ep.take_early_keylog(c.ssl()) { - self.push_event(SessionEvent::Keylog(line)); - } - } - self.cache_sockaddrs(conn); + self.cache_sockaddrs(self.conn().and_then(|c| c.sockaddrs())); if self.close_when_bound.get() { // Carry the user's {type, code, reason} through rather than // sending a bare close. @@ -561,18 +582,9 @@ impl QuicSession { } } - pub(super) fn cache_sockaddrs(&self, conn: *mut lsquic::lsquic_conn) { - let mut local: *const c_void = null(); - let mut peer: *const c_void = null(); - // SAFETY: `conn` is live; out-params point at stack slots. - if unsafe { - lsquic::lsquic_conn_get_sockaddr( - conn, - core::ptr::from_mut(&mut local), - core::ptr::from_mut(&mut peer), - ) - } == 0 - { + /// `addrs` is the `(local, peer)` pair of a live conn. + pub(super) fn cache_sockaddrs(&self, addrs: Option<(*const c_void, *const c_void)>) { + if let Some((local, peer)) = addrs { self.local_addr .set(StoredAddr::from_raw(local.cast(), SOCKADDR_IN6_LEN)); self.remote_addr @@ -646,6 +658,11 @@ impl QuicSession { pub(super) fn is_server(&self) -> bool { self.is_server.get() } + /// Returns true if lsquic frames this session's streams as HTTP/3. + pub(super) fn is_http(&self) -> bool { + self.endpoint_ref() + .is_some_and(|ep| ep.is_http(self.is_server.get())) + } pub(super) fn push_event(&self, event: SessionEvent) { self.events.with_mut(|e| e.push(event)); } @@ -730,20 +747,16 @@ impl QuicSession { &self, uni: bool, ) -> Option<*mut super::stream::QuicStream> { - let queue = if uni { + let key = self.pending_local(uni).with_mut(VecDeque::pop_front)?; + self.stream_ptr(key) + } + /// One key per stream request that lsquic still counts as delayed, oldest first. + fn pending_local(&self, uni: bool) -> &JsCell> { + if uni { &self.pending_local_uni } else { &self.pending_local_bidi - }; - // 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(key) = queue.with_mut(VecDeque::pop_front) { - if let Some(stream) = self.stream_ptr(key) { - return Some(stream); - } } - None } /// Creates a stream for this session and registers it. fn create_stream( @@ -766,6 +779,17 @@ impl QuicSession { } pub(super) fn remove_stream(&self, key: super::stream::Key) { self.streams.with_mut(|v| v.retain(|&(k, _)| k != key)); + for uni in [false, true] { + let queue = self.pending_local(uni); + let Some(at) = queue.get().iter().position(|&k| k == key) else { + continue; + }; + queue.with_mut(|q| q.remove(at)); + // Otherwise lsquic later opens a stream that nothing waits for, and it uses up the peer's stream credit. + if let Some(c) = self.conn() { + c.cancel_pending_stream(uni); + } + } } fn bump_stream_stat(&self, id: u64, local: bool) { let idx = match (id & STREAM_ID_UNI_BIT != 0, local) { @@ -812,6 +836,11 @@ impl QuicSession { if let Some(code) = unsafe { (*qs).pre_reset_code() } { self.push_event(SessionEvent::StreamReset { stream: key, code }); } + // SAFETY: `qs` was just created. + if let Some(code) = unsafe { (*qs).peer_stop_sending_code() } { + // A STOP_SENDING can open the stream. lsquic's on_reset ran before on_new_stream then, with no ctx. + self.push_event(SessionEvent::StreamStopSending { stream: key, code }); + } qs } Err(e) => { @@ -1038,13 +1067,7 @@ impl QuicSession { // Already closing: the CONNECTION_CLOSE precedes this // stream's packet and a closing endpoint discards new // streams (RFC 9000 s10.2.1). Raw QUIC only. - if remote - && self.with_state(|st| st.graceful_close == 1) - && !self - .endpoint_ref() - .map(|ep| ep.is_http(self.is_server.get())) - .unwrap_or(false) - { + if remote && self.with_state(|st| st.graceful_close == 1) && !self.is_http() { stream.suppress_announce(); stream.close_raw_silently(); return Ok(()); @@ -1072,6 +1095,18 @@ impl QuicSession { &[handle, direction], ); } + } else if stream.was_pending() { + let handle = stream.handle(); + if let Some(cb) = callbacks::get(global, "onStreamAvailable") { + let vm = global.bun_vm().as_mut(); + vm.event_loop_ref().run_callback( + bun_event_loop::ContextId::NONE, + cb, + global, + handle, + &[], + ); + } } } SessionEvent::NewToken(token) => { @@ -1090,7 +1125,7 @@ impl QuicSession { cb, global, self.handle(), - &[buf], + &[buf, self.remote_addr.get().to_js_socket_address(global)], ); } } @@ -1278,27 +1313,31 @@ impl QuicSession { ); } } - SessionEvent::DatagramStatus { id, sent } => { - if sent { - self.add_stat(IDX_STATS_SESSION_DATAGRAMS_SENT, 1); - self.inflight_datagrams.with_mut(|q| q.push_back(id)); - return Ok(()); - } - if !self.has_listener(LISTENER_FLAG_DATAGRAM_STATUS) { - return Ok(()); - } - let id_js = JSValue::from_uint64_no_truncate(global, id)?; - let status_js = global.common_strings().quic_datagram_abandoned(); - if let Some(cb) = callbacks::get(global, "onSessionDatagramStatus") { - let vm = global.bun_vm().as_mut(); - vm.event_loop_ref().run_callback( - bun_event_loop::ContextId::NONE, - cb, - global, - self.handle(), - &[id_js, status_js], - ); + SessionEvent::DatagramStatus { + first_id, + count, + status, + } => { + let stat = match status { + DatagramStatus::Acknowledged => Some(IDX_STATS_SESSION_DATAGRAMS_ACKNOWLEDGED), + DatagramStatus::Lost => Some(IDX_STATS_SESSION_DATAGRAMS_LOST), + DatagramStatus::Abandoned => None, + }; + // Count each datagram even if its status can't be delivered. + let mut undelivered = Ok(()); + for id in first_id..first_id + count { + if let Some(stat) = stat { + self.add_stat(stat, 1); + } + if undelivered.is_ok() { + undelivered = self.emit_datagram_status(global, id, status); + } + // `ondatagramstatus` can destroy this session. + if self.destroyed.get() { + break; + } } + undelivered?; } SessionEvent::EarlyDataFailed => { // Node parity: their `closed` promises reject with an @@ -1318,47 +1357,6 @@ impl QuicSession { } } } - SessionEvent::DatagramAckStatus { count, acked } => { - let status_js = if acked { - global.common_strings().quic_datagram_acknowledged() - } else { - global.common_strings().quic_datagram_lost() - }; - // Every acknowledged/lost datagram is popped and counted even - // when its status cannot be delivered. - let mut undelivered = Ok(()); - for _ in 0..count { - let Some(id) = self.inflight_datagrams.with_mut(VecDeque::pop_front) else { - break; - }; - if acked { - self.add_stat(IDX_STATS_SESSION_DATAGRAMS_ACKNOWLEDGED, 1); - } else { - self.add_stat(IDX_STATS_SESSION_DATAGRAMS_LOST, 1); - } - if !self.has_listener(LISTENER_FLAG_DATAGRAM_STATUS) || undelivered.is_err() { - continue; - } - let id_js = match JSValue::from_uint64_no_truncate(global, id) { - Ok(id_js) => id_js, - Err(err) => { - undelivered = Err(err); - continue; - } - }; - if let Some(cb) = callbacks::get(global, "onSessionDatagramStatus") { - let vm = global.bun_vm().as_mut(); - vm.event_loop_ref().run_callback( - bun_event_loop::ContextId::NONE, - cb, - global, - self.handle(), - &[id_js, status_js], - ); - } - } - undelivered?; - } SessionEvent::VersionNegotiation { server_versions } => { let Some((requested, min)) = self.verneg.get() else { return Ok(()); @@ -1481,6 +1479,21 @@ impl QuicSession { return Ok(()); }; stream.apply_peer_stop_sending(code); + if !stream.wants_stop_sending() || stream.is_announce_suppressed() { + return Ok(()); + } + let handle = stream.handle(); + let err = make_application_error(global, code)?; + if let Some(cb) = callbacks::get(global, "onStreamStopSending") { + let vm = global.bun_vm().as_mut(); + vm.event_loop_ref().run_callback( + bun_event_loop::ContextId::NONE, + cb, + global, + handle, + &[err], + ); + } } SessionEvent::StreamClosed { stream: key } => { let Some(stream) = self.live_stream(key) else { @@ -1497,6 +1510,10 @@ impl QuicSession { stream.release_close_root(); return Ok(()); } + // lsquic tore it down with its connection. Leave it registered so session.destroy(error) gives it the connection's error, as in Node. + if self.conn.get().is_null() && stream.unfinished_at_close() { + return Ok(()); + } // Runs the parked reader wakeup — user JS, which may destroy // this stream and let GC free it. Re-acquire before any // further use; `mark_close_reported` above already fired. @@ -1765,28 +1782,14 @@ impl QuicSession { } let (error_type, code, reason): (i32, u64, Option>) = match taken { Some((app, code, reason)) => (if app { 1 } else { 0 }, code, Some(reason)), - None if self.conn.get().is_null() => { - match self.final_conn_status.with_mut(Option::take) { - Some((status, msg)) => { - map_conn_status(status, msg, self.handshake_reported.get()) - } - None => (0, 0, None), - } - } - None => { - // Map the lsquic conn status to Node's QuicError shape. - let mut buf = [0 as c_char; CONN_STATUS_ERRBUF_LEN]; - // SAFETY: `conn` is non-null (checked above) and live - // until lsquic frees it after `on_conn_closed` returns. - let status = unsafe { - lsquic::lsquic_conn_status(self.conn.get(), buf.as_mut_ptr(), buf.len()) - }; - // SAFETY: `buf` is NUL-terminated by lsquic. - let msg = unsafe { core::ffi::CStr::from_ptr(buf.as_ptr()) } - .to_bytes() - .to_vec(); - map_conn_status(status, msg, self.handshake_reported.get()) - } + None => match self + .final_conn_status + .with_mut(Option::take) + .or_else(|| self.conn().map(|c| c.status())) + { + Some(status) => map_conn_status(status, self.handshake_reported.get()), + None => (0, 0, None), + }, }; // `close_reported` is already latched above, so returning here would // mark the close delivered without ever delivering it and `closed` @@ -1847,6 +1850,10 @@ impl QuicSession { // late callbacks no-op. unsafe { lsquic::lsquic_conn_set_ctx(conn, null_mut()) }; } + // Without a ctx, the handshake logs no keys and `on_new_conn` drops the promoted conn. + if let Some(mini) = self.mini_conn.take() { + mini.clear_ctx(); + } self.events.with_mut(Vec::clear); let ep = self.endpoint.replace(null_mut()); if !ep.is_null() { @@ -1907,11 +1914,7 @@ impl QuicSession { } fn apply_graceful_close(&self, app: bool, code: u64, reason: Vec) { - let is_http = self - .endpoint_ref() - .map(|ep| ep.is_http(self.is_server.get())) - .unwrap_or(false); - if is_http && !app && code == 0 && self.has_opened_stream() { + if self.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() { @@ -1931,7 +1934,7 @@ impl QuicSession { /// Applies a graceful close stashed while a dispatch was on the stack. /// Returns whether one was applied. pub(super) fn flush_pending_graceful(&self) -> bool { - // A provisional session stashes its close here too, and `apply_close` + // A session without a conn stashes its close here too, and `apply_close` // silently no-ops without a conn — draining it here would consume the // close that `bind_conn` is waiting to apply. if self.conn.get().is_null() { @@ -2088,7 +2091,7 @@ impl QuicSession { unsafe { (*qs).outbound.with_mut(|o| { o.started = true; - o.data.extend(buf.byte_slice().iter().copied()); + o.data.extend(buf.byte_slice()); o.end = super::stream::PendingEnd::Fin; }); // As attach_source/init_streaming_source/send_headers do: this @@ -2105,6 +2108,9 @@ impl QuicSession { conn.make_stream(); } } + if let Some(stream) = self.live_stream(key) { + stream.mark_pending_if_unbound(); + } self.schedule_process(); Ok(handle) } @@ -2122,10 +2128,7 @@ impl QuicSession { }; // HTTP/3: the peer must have advertised SETTINGS_H3_DATAGRAM // (RFC 9297 §2.1.1); otherwise not sent (0n). - let is_http = self - .endpoint_ref() - .is_some_and(|ep| ep.is_http(self.is_server.get())); - if is_http && self.conn().and_then(|c| c.peer_h3_datagram()) == Some(false) { + if self.is_http() && self.conn().and_then(|c| c.peer_h3_datagram()) == Some(false) { return JSValue::from_uint64_no_truncate(global, 0); } // Oversized for the negotiated budget: not sent (Node returns 0n). @@ -2147,7 +2150,7 @@ impl QuicSession { // Node reports the abandonment synchronously from within // sendDatagram. if self.datagram_drop_newest.get() { - self.report_datagram_abandoned(global, id)?; + self.emit_datagram_status(global, id, DatagramStatus::Abandoned)?; // SAFETY: as above. unsafe { (&raw mut (*self.state_mut()).last_datagram_id).write_unaligned(id) }; return JSValue::from_uint64_no_truncate(global, id); @@ -2155,7 +2158,7 @@ impl QuicSession { if let Some((dropped_id, _)) = self.datagram_queue.with_mut(VecDeque::pop_front) { // Runs the user's `ondatagramstatus`, which can destroy this // session or close the conn before we get back here. - self.report_datagram_abandoned(global, dropped_id)?; + self.emit_datagram_status(global, dropped_id, DatagramStatus::Abandoned)?; if self.destroyed.get() || self.conn.get().is_null() { return JSValue::from_uint64_no_truncate(global, 0); } @@ -2171,14 +2174,23 @@ impl QuicSession { self.schedule_process(); JSValue::from_uint64_no_truncate(global, id) } - /// Runs inside `sendDatagram`: what building the status left pending is - /// thrown from there. - fn report_datagram_abandoned(&self, global: &JSGlobalObject, id: u64) -> JsResult<()> { + /// Calls `ondatagramstatus(id, status)`. `sendDatagram` throws the `Err`. + fn emit_datagram_status( + &self, + global: &JSGlobalObject, + id: u64, + status: DatagramStatus, + ) -> JsResult<()> { if !self.has_listener(LISTENER_FLAG_DATAGRAM_STATUS) { return Ok(()); } let id_js = JSValue::from_uint64_no_truncate(global, id)?; - let status_js = global.common_strings().quic_datagram_abandoned(); + let strings = global.common_strings(); + let status_js = match status { + DatagramStatus::Acknowledged => strings.quic_datagram_acknowledged(), + DatagramStatus::Lost => strings.quic_datagram_lost(), + DatagramStatus::Abandoned => strings.quic_datagram_abandoned(), + }; if let Some(cb) = callbacks::get(global, "onSessionDatagramStatus") { let vm = global.bun_vm().as_mut(); vm.event_loop_ref().run_callback( @@ -2424,6 +2436,14 @@ impl QuicSession { } } + /// Replaces `on_mini_conn_failed` after `QuicEndpoint::detach_for_finalize`. + pub(super) unsafe extern "C" fn on_mini_conn_failed_detached(ctx: *mut c_void, _code: u64) { + // SAFETY: as in `on_closed_detached`. + if let Some(session) = unsafe { super::ffi::ctx_ref::(ctx) } { + session.mini_conn.set(None); + } + } + #[expect( clippy::boxed_local, reason = "codegen's host_fn_finalize calls this as `|b| QuicSession::finalize(b)` and requires `self: Box`" @@ -2434,10 +2454,24 @@ impl QuicSession { // SAFETY: `on_conn_closed` and `teardown` null `conn`, so a non-null `conn` is live. unsafe { lsquic::lsquic_conn_set_ctx(conn, null_mut()) }; } + if let Some(mini) = self.mini_conn.get() { + mini.clear_ctx(); + } } } lsquic_callback! { + pub(super) fn on_new_mini_conn( + endpoint: &QuicEndpoint, + c: *mut lsquic::lsquic_conn, + ) -> *mut c_void = null_mut(); { + // SAFETY: `c` is the mini conn lsquic just created. + let Some(conn) = (unsafe { lsquic::MiniConn::from_raw(c) }) else { + return null_mut(); + }; + endpoint.on_new_mini_conn(conn).cast() + } + pub(super) fn on_new_conn( endpoint: &QuicEndpoint, c: *mut lsquic::lsquic_conn, @@ -2448,6 +2482,38 @@ lsquic_callback! { endpoint.on_new_conn(c).cast() } + /// lsquic frees the mini conn after this returns. + pub(super) fn on_mini_conn_failed(session: &QuicSession, error_code: u64) { + /// CRYPTO_ERROR base (RFC 9001 §4.8) + TLS no_application_protocol(120). + const CRYPTO_ERROR_NO_APPLICATION_PROTOCOL: u64 = 0x0100 + 120; + /// Set if the peer sent CONNECTION_CLOSE. The low bits are its code (connection-close-pns.patch). + const PEER_CLOSE_BIT: u64 = 1 << 63; + debug_assert!(session.mini_conn.get().is_some()); + session.mini_conn.set(None); + if !session.announced.get() { + session.discard(); + return; + } + let (code, reason): (u64, &[u8]) = if error_code & PEER_CLOSE_BIT != 0 { + // The client closed mid-handshake. Its code settles `closed`, as in Node. + (error_code & !PEER_CLOSE_BIT, b"") + } else if error_code == 0 { + // The peer went away without a frame. Node reports a clean close, not an error. + (0, b"") + } else if error_code == CRYPTO_ERROR_NO_APPLICATION_PROTOCOL { + (error_code, b"no application protocol") + } else { + (error_code, b"handshake failed") + }; + session.push_event(SessionEvent::PeerClose { + app_error: false, + code, + reason: reason.to_vec(), + }); + session.push_event(SessionEvent::Closed); + session.schedule_process(); + } + pub(super) fn on_goaway_received(session: &QuicSession) { session.push_event(SessionEvent::GoawayReceived); } @@ -2485,20 +2551,23 @@ lsquic_callback! { /// reason)` shape (`type`: 0=transport, 1=application, 2=version-neg, /// 3=idle). fn map_conn_status( - status: c_int, - msg: Vec, + (status, msg, local_error): (c_int, Vec, Option), handshake_reported: bool, ) -> (i32, u64, Option>) { - match status { + match (status, local_error) { + // lsquic reports an error that fails the handshake, such as a TLS alert, as a version negotiation failure. + (lsquic::LSCONN_ST_ERROR | lsquic::LSCONN_ST_VERNEG_FAILURE, Some(code)) => { + (0, code, Some(msg)) + } // Node rejects `opened` with a transport error. - lsquic::LSCONN_ST_TIMED_OUT if !handshake_reported => ( + (lsquic::LSCONN_ST_TIMED_OUT, _) if !handshake_reported => ( 0, CRYPTO_ERROR_HANDSHAKE_FAILURE, Some(b"handshake timed out".to_vec()), ), - lsquic::LSCONN_ST_TIMED_OUT => (3, 0, None), - lsquic::LSCONN_ST_VERNEG_FAILURE => (2, 0, None), - lsquic::LSCONN_ST_RESET | lsquic::LSCONN_ST_HSK_FAILURE | lsquic::LSCONN_ST_ERROR => { + (lsquic::LSCONN_ST_TIMED_OUT, _) => (3, 0, None), + (lsquic::LSCONN_ST_VERNEG_FAILURE, _) => (2, 0, None), + (lsquic::LSCONN_ST_RESET | lsquic::LSCONN_ST_HSK_FAILURE | lsquic::LSCONN_ST_ERROR, _) => { (0, 1, Some(msg)) } _ => (0, 0, None), @@ -2510,18 +2579,8 @@ lsquic_callback! { // The conn's streams die with it — pending deferred aborts hold raw // lsquic stream pointers that are about to be freed. session.deferred_aborts.with_mut(Vec::clear); - let conn = session.conn.get(); - if !conn.is_null() { - let mut buf = [0 as c_char; CONN_STATUS_ERRBUF_LEN]; - // SAFETY: `conn` is live for the duration of this callback. - let status = unsafe { lsquic::lsquic_conn_status(conn, buf.as_mut_ptr(), buf.len()) }; - // SAFETY: `buf` is NUL-terminated by lsquic. - let msg = unsafe { core::ffi::CStr::from_ptr(buf.as_ptr()) } - .to_bytes() - .to_vec(); - session - .final_conn_status - .with_mut(|f| *f = Some((status, msg))); + if let Some(conn) = session.conn() { + session.final_conn_status.set(Some(conn.status())); } session.push_event(SessionEvent::Closed); // The lsquic_conn is freed immediately after this callback returns. @@ -2595,7 +2654,11 @@ lsquic_callback! { let max = session.with_state(|s| s.max_datagram_size) as usize; if max == 0 || len > max { session.datagram_queue.with_mut(VecDeque::pop_front); - session.push_event(SessionEvent::DatagramStatus { id, sent: false }); + session.push_event(SessionEvent::DatagramStatus { + first_id: id, + count: 1, + status: DatagramStatus::Abandoned, + }); // SAFETY: the conn is live for this callback. if let Some(c) = unsafe { lsquic::Conn::from_raw(session.conn.get()) } { c.want_datagram_write(!session.datagram_queue.get().is_empty()); @@ -2610,23 +2673,40 @@ lsquic_callback! { unsafe { core::ptr::copy_nonoverlapping(payload.as_ptr(), buf.cast::(), payload.len()) }; - session.push_event(SessionEvent::DatagramStatus { id, sent: true }); - if !session.datagram_queue.get().is_empty() { - // SAFETY: the conn is live for this callback. - if let Some(c) = unsafe { lsquic::Conn::from_raw(session.conn.get()) } { + session.add_stat(IDX_STATS_SESSION_DATAGRAMS_SENT, 1); + // SAFETY: the conn is live for this callback. + if let Some(c) = unsafe { lsquic::Conn::from_raw(session.conn.get()) } { + c.set_datagram_id(id); + if !session.datagram_queue.get().is_empty() { c.want_datagram_write(true); } } payload.len() as isize } - pub(super) fn on_datagram_status(session: &QuicSession, count: c_uint, acked: c_int) { - if count == 0 { - return; - } - session.push_event(SessionEvent::DatagramAckStatus { - count, - acked: acked != 0, + pub(super) fn on_datagram_status(session: &QuicSession, id: u64, status: c_int) { + let status = match status { + lsquic::LSQ_DG_ACKED => DatagramStatus::Acknowledged, + lsquic::LSQ_DG_LOST => DatagramStatus::Lost, + // lsquic dropped the frame before the send and won't retry it. Node keeps such a datagram queued. + lsquic::LSQ_DG_UNSENT => DatagramStatus::Lost, + _ => { + debug_assert!(false, "unknown lsquic_dg_status"); + DatagramStatus::Lost + } + }; + // Fold a run of consecutive ids with the same status into one event. + session.events.with_mut(|events| match events.last_mut() { + Some(SessionEvent::DatagramStatus { + first_id, + count, + status: last, + }) if *last == status && *first_id + *count == id => *count += 1, + _ => events.push(SessionEvent::DatagramStatus { + first_id: id, + count: 1, + status, + }), }); } diff --git a/src/runtime/node/quic/stream.rs b/src/runtime/node/quic/stream.rs index 4754e6e7936c..dca9ed55e668 100644 --- a/src/runtime/node/quic/stream.rs +++ b/src/runtime/node/quic/stream.rs @@ -4,8 +4,8 @@ use core::ptr::null_mut; use std::collections::VecDeque; use bun_jsc::{ - ArrayBuffer, CallFrame, JSGlobalObject, JSType, JSValue, JsCell, JsRef, JsResult, StringJsc, - Strong, + ArrayBuffer, CallFrame, ErrorCode, JSGlobalObject, JSType, JSValue, JsCell, JsRef, JsResult, + StringJsc, Strong, }; use bun_lsquic_sys as lsquic; @@ -36,6 +36,7 @@ pub(crate) struct StreamState { pub(crate) wants_headers: u8, pub(crate) wants_reset: u8, pub(crate) wants_trailers: u8, + pub(crate) wants_stop_sending: u8, pub(crate) received_early_data: u8, pub(crate) write_desired_size: u32, pub(crate) high_water_mark: u32, @@ -68,6 +69,9 @@ const IDX_STATS_MAX_BYTES_ACCUMULATED: usize = 12; const DEFAULT_HIGH_WATER_MARK: u32 = 16 * 1024; +/// A stream with this many unread bytes stops raising its receive limit until JS has read half of them. +const INBOUND_HOLD_BYTES: usize = 256 * 1024; + /// Stream-id bit 1 selects the direction: 0 = bidirectional, /// 1 = unidirectional (RFC 9000 §2.1). const STREAM_ID_UNI_BIT: i64 = 0x2; @@ -100,9 +104,21 @@ pub(super) struct Outbound { pub started: bool, } +impl Outbound { + /// Drops the allocation too. `VecDeque::clear` would keep it until the stream is collected. + fn free(&mut self) { + self.data = VecDeque::new(); + } + fn has_output(&self) -> bool { + !self.data.is_empty() || self.end != PendingEnd::None + } +} + #[derive(Default)] pub(super) struct Inbound { pub chunks: VecDeque>, + pub unread: usize, + pub window_held: bool, pub ended: bool, pub errored: bool, } @@ -125,15 +141,21 @@ pub(crate) struct QuicStream { inbound: JsCell, wakeup: JsCell>, peer_stop_sending_code: Cell>, + /// `open_stream` returned before lsquic opened the stream. + was_pending: Cell, wrote_to_lsquic: Cell, headers_received: Cell, + headers_sent: Cell, + headers_refused: Cell, /// RFC 9218 (urgency, incremental). priority: Cell<(u8, bool)>, - pending_headers: JsCell, c_int, bool)>>, + /// (nul-joined pairs, pair count, eos, frameless). + pending_headers: JsCell, c_int, bool, bool)>>, trailers_requested: Cell, blocked_reported: Cell, announce_suppressed: Cell, close_reported: Cell, + unfinished_at_close: Cell, destroyed: Cell, } @@ -160,14 +182,18 @@ impl QuicStream { inbound: JsCell::new(Inbound::default()), wakeup: JsCell::new(None), peer_stop_sending_code: Cell::new(None), + was_pending: Cell::new(false), wrote_to_lsquic: Cell::new(false), headers_received: Cell::new(false), + headers_sent: Cell::new(false), + headers_refused: Cell::new(false), priority: Cell::new(DEFAULT_PRIORITY), pending_headers: JsCell::new(Vec::new()), trailers_requested: Cell::new(false), blocked_reported: Cell::new(false), announce_suppressed: Cell::new(false), close_reported: Cell::new(false), + unfinished_at_close: Cell::new(false), destroyed: Cell::new(false), }; let raw_ptr = bun_core::heap::into_raw(Box::new(stream)); @@ -235,7 +261,6 @@ impl QuicStream { s.close(); } } - // Bit 0 of the id is the initiator (RFC 9000 §2.1). let uni = id & STREAM_ID_UNI_BIT != 0; let (urgency, incremental) = self.priority.get(); if (urgency, incremental) != DEFAULT_PRIORITY { @@ -245,9 +270,9 @@ impl QuicStream { // drain_outbound needs a write event, which nothing else requests // unless the JS side already started the writer. let mut want_write = false; - for (bytes, count, eos) in self.pending_headers.with_mut(core::mem::take) { + for (bytes, count, eos, frameless) in self.pending_headers.with_mut(core::mem::take) { self.wrote_to_lsquic.set(true); - if s.send_headers(&bytes, count, eos) == 0 { + if self.put_header_block(s, &bytes, count, eos, frameless) { if eos { self.with_state(|st| { st.fin_sent = 1; @@ -260,34 +285,23 @@ impl QuicStream { } else { // sendHeaders() returned true when it queued this block, so // `closed` is what reports the failure. - s.reset(H3_INTERNAL_ERROR); - s.stop_sending(H3_INTERNAL_ERROR); - self.mark_reset(H3_INTERNAL_ERROR); - self.with_state(|st| st.write_ended = 1); - self.outbound.with_mut(|o| { - o.data.clear(); - o.end = PendingEnd::None; - }); + self.fail_message(s); break; } } if uni { - let session = self.session.get(); - // SAFETY: the session outlives its streams. - let is_server = !session.is_null() && unsafe { (*session).is_server() }; - let local = (id & 1 == 0) != is_server; - if local { - self.inbound.with_mut(|i| i.ended = true); - self.with_state(|s| s.read_ended = 1); - } else { + if self.is_receive_only() { self.with_state(|s| s.write_ended = 1); s.want_read(true); + } else { + self.inbound.with_mut(|i| i.ended = true); + self.with_state(|s| s.read_ended = 1); } } else { s.want_read(true); } if want_write || self.outbound.get().started { - s.want_write(true); + self.request_write(s); } } @@ -308,6 +322,31 @@ impl QuicStream { unsafe { lsquic::Stream::from_raw(self.raw.get()) } } + /// Sends one header block, or skips a `frameless` one. Returns true if the stream accepted it. + fn put_header_block( + &self, + s: lsquic::Stream, + bytes: &[u8], + count: c_int, + eos: bool, + frameless: bool, + ) -> bool { + // lsquic resets a request stream whose FIN arrives before a header block, so skipping trailers needs one sent. + if frameless + && (!eos || self.headers_sent.get()) + && self.stream_id() & STREAM_ID_UNI_BIT == 0 + && self.with_state(|s| s.fin_sent == 0 && s.write_ended == 0) + { + return true; + } + let accepted = s.send_headers(bytes, count, eos) == 0; + self.headers_refused.set(!accepted); + if accepted { + self.headers_sent.set(true); + } + accepted + } + fn state_mut(&self) -> *mut StreamState { self.state.get() } @@ -355,6 +394,17 @@ impl QuicStream { pub(super) fn pre_reset_code(&self) -> Option { self.with_state(|s| (s.reset != 0).then_some(s.reset_code)) } + pub(super) fn peer_stop_sending_code(&self) -> Option { + self.peer_stop_sending_code.get() + } + pub(super) fn mark_pending_if_unbound(&self) { + if self.raw.get().is_null() { + self.was_pending.set(true); + } + } + pub(super) fn was_pending(&self) -> bool { + self.was_pending.get() + } pub(super) fn release_close_root(&self) { self.this_value.with_mut(|r| r.downgrade()); } @@ -393,6 +443,16 @@ impl QuicStream { self.with_state(|s| s.id) } + /// Returns true for a unidirectional stream the peer opened. Bit 0 of the id is the initiator (RFC 9000 §2.1). + fn is_receive_only(&self) -> bool { + let id = self.stream_id(); + id >= 0 + && id & STREAM_ID_UNI_BIT != 0 + && self + .session_ref() + .is_some_and(|session| (id & 1 == 0) == session.is_server()) + } + fn push_inbound(&self, data: &[u8], fin: bool) { if self.destroyed.get() { return; @@ -400,6 +460,7 @@ impl QuicStream { self.inbound.with_mut(|inbound| { if !data.is_empty() { inbound.chunks.push_back(data.to_vec()); + inbound.unread += data.len(); } if fin { inbound.ended = true; @@ -420,6 +481,29 @@ impl QuicStream { } } + /// `on_stream_read` drains lsquic, which returns the peer's credit. Without this, an unread stream grows without bound. + fn update_window_hold(&self) { + let changed = self.inbound.with_mut(|i| { + let mark = if i.window_held { + INBOUND_HOLD_BYTES / 2 + } else { + INBOUND_HOLD_BYTES + }; + let hold = i.unread >= mark; + (core::mem::replace(&mut i.window_held, hold) != hold).then_some(hold) + }); + let (Some(hold), Some(s)) = (changed, self.ls()) else { + return; + }; + s.hold_window(hold); + if !hold { + // Sends the MAX_STREAM_DATA that the release queued. + if let Some(session) = self.session_ref() { + session.schedule_process(); + } + } + } + pub(super) fn take_wakeup(&self) -> Option { self.wakeup.replace(None) } @@ -429,7 +513,7 @@ impl QuicStream { pub(super) fn cancel_early_rejected(&self, code: u64) { self.mark_reset(code); self.outbound.with_mut(|o| { - o.data.clear(); + o.free(); o.end = PendingEnd::None; }); self.with_state(|st| st.write_ended = 1); @@ -449,6 +533,18 @@ impl QuicStream { s.reset_code = code; } }); + self.outbound.with_mut(|o| { + o.free(); + o.end = PendingEnd::None; + }); + // lsquic already queued its RESET_STREAM. Ending our write side lets the stream finish once that's acknowledged. + if let Some(s) = self.ls() { + if self.with_state(|st| st.read_ended != 0) { + s.close(); + } else { + s.shutdown(1); + } + } } fn mark_reset(&self, code: u64) { @@ -457,6 +553,7 @@ impl QuicStream { } self.inbound.with_mut(|inbound| { inbound.chunks.clear(); + inbound.unread = 0; inbound.errored = true; inbound.ended = true; }); @@ -472,6 +569,10 @@ impl QuicStream { }); } + /// Returns true if lsquic closed the stream before it finished on its own. + pub(super) fn unfinished_at_close(&self) -> bool { + self.unfinished_at_close.get() + } pub(super) fn mark_close_reported(&self) -> bool { self.close_reported.replace(true) } @@ -487,6 +588,9 @@ impl QuicStream { pub(super) fn wants_block(&self) -> bool { self.with_state(|s| s.wants_block != 0) } + pub(super) fn wants_stop_sending(&self) -> bool { + self.with_state(|s| s.wants_stop_sending != 0) + } fn note_write_blocked(&self) { if self.blocked_reported.replace(true) { return; @@ -500,30 +604,45 @@ impl QuicStream { } } + /// Resets an HTTP/3 stream whose message can't be sent. + fn fail_message(&self, s: lsquic::Stream) { + s.reset(H3_INTERNAL_ERROR); + s.stop_sending(H3_INTERNAL_ERROR); + self.mark_reset(H3_INTERNAL_ERROR); + self.with_state(|st| st.write_ended = 1); + self.outbound.with_mut(|o| { + o.free(); + o.end = PendingEnd::None; + }); + } + fn drain_outbound(&self) { let Some(s) = self.ls() else { return }; - loop { - let (slice, contig): (Vec, usize) = { - let out = self.outbound.get(); - if out.data.is_empty() { - break; - } - let (a, _) = out.data.as_slices(); - (a.to_vec(), a.len()) - }; - let n = s.write(&slice); - if n <= 0 { + if self.headers_refused.get() && self.outbound.get().has_output() { + // Neither a body nor a FIN can follow a header block that lsquic refused. + self.fail_message(s); + return; + } + while !self.outbound.get().data.is_empty() { + // lsquic copies what it accepts. Move the queue out of the cell so no borrow spans the call. + let mut data = self.outbound.with_mut(|o| core::mem::take(&mut o.data)); + let offered = data.as_slices().0.len(); + let n = s.write(data.as_slices().0); + data.drain(..usize::try_from(n).unwrap_or(0).min(offered)); + self.outbound.with_mut(|o| o.data = data); + if n < 0 { + // Not flow control, so no write event clears it. sendHeaders() re-arms a body that's waiting for its header block. + s.want_write(false); + break; + } + if n == 0 { self.note_write_blocked(); break; } self.blocked_reported.set(false); self.wrote_to_lsquic.set(true); - let n = n as usize; - self.outbound.with_mut(|out| { - out.data.drain(..n.min(contig)); - }); self.add_stat(IDX_STATS_BYTES_SENT, n as u64); - if n < slice.len() { + if (n as usize) < offered { self.note_write_blocked(); break; } @@ -534,14 +653,9 @@ impl QuicStream { }; if empty { if end == PendingEnd::Fin { - s.shutdown(1); - self.wrote_to_lsquic.set(true); - self.outbound.with_mut(|o| o.end = PendingEnd::None); - self.with_state(|s| s.fin_sent = 1); - if self.stream_id() & STREAM_ID_UNI_BIT != 0 { - s.shutdown(0); - } + self.send_fin(s); } else if end == PendingEnd::Trailers && !self.trailers_requested.replace(true) { + self.outbound.with_mut(Outbound::free); if let Some(session) = self.session_ref() { session.push_event(SessionEvent::StreamWantsTrailers { stream: self.key }); } @@ -562,9 +676,40 @@ impl QuicStream { } } + fn send_fin(&self, s: lsquic::Stream) { + s.shutdown(1); + self.wrote_to_lsquic.set(true); + self.outbound.with_mut(|o| { + o.free(); + o.end = PendingEnd::None; + }); + self.with_state(|s| s.fin_sent = 1); + if self.stream_id() & STREAM_ID_UNI_BIT != 0 { + s.shutdown(0); + } + } + + /// lsquic only gives a write event to a stream with flow-control credit, and a FIN needs none (RFC 9000 §4.1). + fn request_write(&self, s: lsquic::Stream) { + let bare_fin = { + let out = self.outbound.get(); + out.end == PendingEnd::Fin && out.data.is_empty() + }; + // HTTP/3 waits for the write event, because headers or trailers can still follow an ended body in the same turn. + if bare_fin + && self.with_state(|st| st.write_ended == 0) + && !self.session_ref().is_some_and(QuicSession::is_http) + && s.write_avail() == 0 + { + self.send_fin(s); + } else { + s.want_write(true); + } + } + fn kick_write(&self) { if let Some(s) = self.ls() { - s.want_write(true); + self.request_write(s); } if let Some(session) = self.session_ref() { session.schedule_process(); @@ -608,11 +753,9 @@ impl QuicStream { // stream. SAFETY: as above. unsafe { (*session).remove_stream(self.key) }; } - self.outbound.with_mut(|o| o.data.clear()); - self.inbound.with_mut(|i| { - i.chunks.clear(); - i.ended = true; - }); + self.outbound.with_mut(Outbound::free); + // Received bytes stay readable, as in Node. Only a peer's reset discards them. + self.inbound.with_mut(|i| i.ended = true); self.with_state(|s| s.read_ended = 1); if let Some(wakeup) = self.take_wakeup() { let vm = global.bun_vm().as_mut(); @@ -681,6 +824,7 @@ impl QuicStream { if inbound.errored { (PULL_STATUS_ERROR, None) } else if let Some(chunk) = inbound.chunks.pop_front() { + inbound.unread -= chunk.len(); (PULL_STATUS_DATA, Some(chunk)) } else if inbound.ended { (PULL_STATUS_EOS, None) @@ -695,6 +839,7 @@ impl QuicStream { self.read_stat(IDX_STATS_BYTES_ACCUMULATED) .saturating_sub(bytes.len() as u64), ); + self.update_window_hold(); ArrayBuffer::create::<{ JSType::ArrayBuffer }>(global, &bytes)? } None => JSValue::UNDEFINED, @@ -715,14 +860,15 @@ impl QuicStream { global: &JSGlobalObject, frame: &CallFrame, ) -> JsResult { - if self.destroyed.get() { + // Node's `Stream::set_outbound` drops the source of a stream that isn't writable. + if self.destroyed.get() || self.is_receive_only() { return Ok(JSValue::UNDEFINED); } let source = frame.arguments_as_array::<1>()[0]; - let bytes = if source.is_empty_or_undefined_or_null() { - Vec::new() + let body = if source.is_empty_or_undefined_or_null() { + None } else if let Some(buf) = source.as_array_buffer(global) { - buf.byte_slice().to_vec() + Some(buf) } else { return Err(global.throw(format_args!( "Unsupported QUIC stream body source (Blob and FileHandle sources are not implemented yet)" @@ -730,7 +876,9 @@ impl QuicStream { }; self.outbound.with_mut(|o| { o.started = true; - o.data.extend(bytes.iter().copied()); + if let Some(body) = &body { + o.data.extend(body.byte_slice()); + } o.end = PendingEnd::Fin; }); self.with_state(|s| s.has_outbound = 1); @@ -740,12 +888,20 @@ impl QuicStream { pub(crate) fn init_streaming_source( &self, - _g: &JSGlobalObject, + global: &JSGlobalObject, _f: &CallFrame, ) -> JsResult { if self.destroyed.get() { return Ok(JSValue::UNDEFINED); } + if self.is_receive_only() { + return Err(global + .err( + ErrorCode::INVALID_STATE, + format_args!("Stream is not writable"), + ) + .throw()); + } self.outbound.with_mut(|o| o.started = true); self.with_state(|s| { s.has_outbound = 1; @@ -762,8 +918,7 @@ impl QuicStream { let mut queued: u64 = 0; let mut append = |bytes: &[u8]| { queued += bytes.len() as u64; - self.outbound - .with_mut(|o| o.data.extend(bytes.iter().copied())); + self.outbound.with_mut(|o| o.data.extend(bytes)); }; if batch.is_array() { let len = batch.get_length(global)?; @@ -859,6 +1014,9 @@ impl QuicStream { if self.destroyed.get() { return Ok(JSValue::UNDEFINED); } + if self.is_receive_only() { + return Ok(JSValue::FALSE); + } let code = error_code_arg(frame.arguments_as_array::<1>()[0]); self.with_state(|s| { s.write_ended = 1; @@ -866,7 +1024,7 @@ impl QuicStream { s.reset_code = code; }); self.outbound.with_mut(|o| { - o.data.clear(); + o.free(); o.end = PendingEnd::None; }); if let Some(s) = self.ls() { @@ -924,7 +1082,7 @@ impl QuicStream { s.reset_code = code; }); self.outbound.with_mut(|o| { - o.data.clear(); + o.free(); o.end = PendingEnd::None; }); } @@ -989,18 +1147,18 @@ impl QuicStream { use crate::webcore::encoding::BunStringEncode as _; let bytes = bun_core::String::from_js(header_string, global)? .encode(crate::node::types::Encoding::Latin1); - let is_trailing = - kind_arg.coerce_to_i32(global)? as u32 == QUIC_STREAM_HEADERS_KIND_TRAILING; - let eos = is_trailing + let kind = kind_arg.coerce_to_i32(global)? as u32; + let eos = kind == QUIC_STREAM_HEADERS_KIND_TRAILING || flags.coerce_to_i32(global)? & (QUIC_STREAM_HEADERS_FLAGS_TERMINAL as i32) != 0; + // Node sends no HEADERS frame for hints or trailers without fields, and an ls-qpack peer closes the connection on one. + let frameless = header_count == 0 && kind != QUIC_STREAM_HEADERS_KIND_INITIAL; let Some(s) = self.ls() else { self.pending_headers - .with_mut(|q| q.push((bytes, header_count, eos))); + .with_mut(|q| q.push((bytes, header_count, eos, frameless))); self.with_state(|s| s.has_outbound = 1); return Ok(JSValue::js_boolean(true)); }; - let rv = s.send_headers(&bytes, header_count, eos); - if rv == 0 { + if self.put_header_block(s, &bytes, header_count, eos, frameless) { self.wrote_to_lsquic.set(true); self.with_state(|s| s.has_outbound = 1); if eos { @@ -1017,6 +1175,9 @@ impl QuicStream { } Ok(JSValue::js_boolean(true)) } else { + if self.outbound.get().has_output() { + self.kick_write(); + } Ok(JSValue::js_boolean(false)) } } @@ -1187,6 +1348,7 @@ pub(super) unsafe extern "C" fn on_stream_read(ctx: *mut c_void, s: *mut lsquic: } } if got_any { + qs.update_window_hold(); if let Some(session) = qs.session_ref() { session.push_event(SessionEvent::StreamWake { stream: qs.key }); } @@ -1199,6 +1361,7 @@ lsquic_callback! { } pub(super) fn on_stream_close(qs: &QuicStream, _s: *mut lsquic::lsquic_stream) { + qs.unfinished_at_close.set(qs.ls().is_some_and(|s| !s.is_finished())); // The lsquic_stream is freed immediately after this callback returns. qs.raw.set(null_mut()); if let Some(session) = qs.session_ref() { diff --git a/src/runtime/node/quic/tls.rs b/src/runtime/node/quic/tls.rs index 3c9add95e18b..aaf903e442d0 100644 --- a/src/runtime/node/quic/tls.rs +++ b/src/runtime/node/quic/tls.rs @@ -1,4 +1,5 @@ use core::ffi::c_int; +use core::net::IpAddr; use core::ptr::null_mut; use bun_boringssl_sys as ssl; @@ -206,52 +207,39 @@ pub(super) struct TlsContext { _alpn: Option>>, } -unsafe extern "C" fn keylog_cb(ssl: *const ssl::SSL, line: *const core::ffi::c_char) { - if line.is_null() { - return; - } - // SAFETY: `line` is the NUL-terminated NSS key log line BoringSSL - // guarantees valid for this callback; the SSL → conn → session walk is - // safe because lsquic owns all three for the SSL's lifetime. - let bytes = unsafe { core::ffi::CStr::from_ptr(line).to_bytes().to_vec() }; - // SAFETY: lsquic stored the conn on the SSL via SSL_set_app_data — but - // we can't rely on that index; use lsquic_ssl_to_conn instead. +/// Returns the session of the lsquic conn that owns `ssl`, which must be live. +unsafe fn session_of<'a>(ssl: *const ssl::SSL) -> Option<&'a super::session::QuicSession> { + // SAFETY: lsquic keeps the SSL -> conn link for as long as the SSL lives. let conn = unsafe { bun_lsquic_sys::lsquic_ssl_to_conn(ssl.cast()) }; if conn.is_null() { - return; + return None; } - // SAFETY: as above. - let ctx = unsafe { bun_lsquic_sys::lsquic_conn_get_ctx(conn) }; - if ctx.is_null() { - // SAFETY: peer_ctx is the Rust QuicEndpoint for every conn on it. - let peer_ctx = unsafe { bun_lsquic_sys::lsquic_conn_get_peer_ctx(conn, core::ptr::null()) }; - if !peer_ctx.is_null() { - // SAFETY: as above. - let endpoint = unsafe { &*peer_ctx.cast::() }; - let mut local = core::ptr::null(); - let mut peer = core::ptr::null(); - // SAFETY: `conn` is live; out-params point at stack slots. - let peer_addr = if unsafe { - bun_lsquic_sys::lsquic_conn_get_sockaddr( - conn, - core::ptr::from_mut(&mut local), - core::ptr::from_mut(&mut peer), - ) - } == 0 - { - super::endpoint::stored_addr_from_sockaddr(peer) - } else { - return; - }; - endpoint.buffer_early_keylog(ssl.cast_mut().cast(), peer_addr, bytes); - } + // SAFETY: `conn` is live, and its ctx is its session or null (`ctx_ref`). + unsafe { super::ffi::ctx_ref(bun_lsquic_sys::lsquic_conn_get_ctx(conn)) } +} + +unsafe extern "C" fn keylog_cb(ssl: *const ssl::SSL, line: *const core::ffi::c_char) { + if line.is_null() { return; } - // SAFETY: ctx is the QuicSession (conn-ctx). - let session = unsafe { &*ctx.cast::() }; + // SAFETY: BoringSSL passes the SSL of the handshake that made the line. + let Some(session) = (unsafe { session_of(ssl) }) else { + return; + }; + // SAFETY: BoringSSL passes a NUL-terminated line that is valid for this callback. + let bytes = unsafe { core::ffi::CStr::from_ptr(line).to_bytes().to_vec() }; session.push_event(super::session::SessionEvent::Keylog(bytes)); } +/// BoringSSL calls this once it has parsed a server handshake's ClientHello. +unsafe extern "C" fn client_hello_cb(hello: *const ssl::SSL_CLIENT_HELLO) -> c_int { + // SAFETY: BoringSSL passes a live ClientHello, and `ssl` is the SSL of its handshake. + if let Some(session) = unsafe { session_of((*hello).ssl) } { + session.on_client_hello(); + } + ssl::ssl_select_cert_success +} + unsafe extern "C" fn alpn_select_cb( _ssl: *mut ssl::SSL, out: *mut *const u8, @@ -483,6 +471,7 @@ impl TlsContext { ssl::SSL_CTX_set_keylog_callback(ctx, Some(keylog_cb)); } if config.is_server { + ssl::SSL_CTX_set_select_certificate_cb(ctx, Some(client_hello_cb)); if config.verify_client { // SSL_VERIFY_PEER alone matches Node's TLS 1.3 semantics (see QuicSession::maybe_report_handshake). ssl::SSL_CTX_set_verify(ctx, ssl::SSL_VERIFY_PEER, None); @@ -520,10 +509,24 @@ impl TlsContext { return Err("verifyHostname requires a non-empty servername"); } let param = ssl::SSL_CTX_get0_param(ctx); - if param.is_null() - || ssl::X509_VERIFY_PARAM_set1_host(param, host.as_ptr().cast(), host.len()) - != 1 - { + if param.is_null() { + return Err("failed to bind hostname for certificate verification"); + } + // BoringSSL's set1_host only matches DNS SANs. OpenSSL's SSL_set1_host, which Node calls, tries an IP first. + let bound = match bun_core::ip_address::parse_strict(host) { + Some(IpAddr::V4(ip)) => { + ssl::X509_VERIFY_PARAM_set1_ip(param, ip.octets().as_ptr(), 4) + } + Some(IpAddr::V6(ip)) => { + ssl::X509_VERIFY_PARAM_set1_ip(param, ip.octets().as_ptr(), 16) + } + None => ssl::X509_VERIFY_PARAM_set1_host( + param, + host.as_ptr().cast(), + host.len(), + ), + }; + if bound != 1 { return Err("failed to bind hostname for certificate verification"); } } diff --git a/src/runtime/server/DevErrorPage.rs b/src/runtime/server/DevErrorPage.rs index 1688c34e3597..11645b5dc8d2 100644 --- a/src/runtime/server/DevErrorPage.rs +++ b/src/runtime/server/DevErrorPage.rs @@ -120,7 +120,7 @@ fn write_stack_trace(w: &mut Vec, stack: &StackTrace) { w.push(b','); } write!(w, "{{\"line\":{},\"text\":", source_line.line + 1).unwrap(); - write_string(w, &source_line.text); + write_source_line(w, &source_line.text); w.push(b'}'); } w.extend_from_slice(b"]}"); @@ -171,7 +171,7 @@ fn write_message_data(w: &mut Vec, text: &[u8], location: Option<&Location>) w.extend_from_slice(b",\"namespace\":"); write_string(w, &location.namespace); w.extend_from_slice(b",\"line_text\":"); - write_string(w, location.line_text.as_deref().unwrap_or(b"")); + write_source_line(w, location.line_text.as_deref().unwrap_or(b"")); write!( w, ",\"line\":{},\"column\":{},\"offset\":{}}}", @@ -182,6 +182,20 @@ fn write_message_data(w: &mut Vec, text: &[u8], location: Option<&Location>) w.push(b'}'); } +/// Same cap as `MAX_LINE_LENGTH` in the terminal error printer, so the page doesn't carry a whole minified bundle. +const MAX_SOURCE_LINE_LENGTH: usize = 1024; + +fn write_source_line(w: &mut Vec, text: &[u8]) { + if text.len() <= MAX_SOURCE_LINE_LENGTH { + return write_string(w, text); + } + let mut end = MAX_SOURCE_LINE_LENGTH; + while end > 0 && !strings::is_utf8_char_boundary(text[end]) { + end -= 1; + } + write_string(w, &[&text[..end], "…".as_bytes()].concat()); +} + fn write_string(w: &mut Vec, bytes: &[u8]) { write!( w, diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs index 2b322ff2751b..4ffc9b439eea 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -248,7 +248,6 @@ impl DirectoryRoute { pollable: false, offset: body_offset, length: Some(body_len), - idle_timeout: server.config().idle_timeout, owner: StreamOwner::DirectoryRoute(guard.into_route()), }); } diff --git a/src/runtime/server/FileResponseStream.rs b/src/runtime/server/FileResponseStream.rs index 75b679af7d9d..3bbf739c5f12 100644 --- a/src/runtime/server/FileResponseStream.rs +++ b/src/runtime/server/FileResponseStream.rs @@ -39,7 +39,6 @@ pub(crate) struct FileResponseStream { event_loop_handle: Cell, fd: Cell, auto_close: Cell, - idle_timeout: Cell, /// Taken by whichever of complete / abort / error fires first. owner: Cell>, @@ -109,7 +108,6 @@ pub(crate) struct StartOptions { /// Maximum bytes to send; `None` reads to EOF. For regular files this /// should be `stat.size - offset` (after Range/slice clamping). pub length: Option, - pub idle_timeout: u8, pub owner: StreamOwner, } @@ -171,7 +169,6 @@ impl FileResponseStream { )), fd: Cell::new(opts.fd), auto_close: Cell::new(opts.auto_close), - idle_timeout: Cell::new(opts.idle_timeout), owner: Cell::new(Some(opts.owner)), mode: Cell::new(if use_sendfile { Mode::Sendfile @@ -189,7 +186,11 @@ impl FileResponseStream { let this_ref = unsafe { &*this }; let resp = this_ref.resp.get(); - resp.timeout(opts.idle_timeout); + // Arm without storing a value: `server.timeout()` may have set one, and sendfile(2) makes no uWS write that would arm it. + resp.reset_timeout(); + if opts.length.is_some() { + resp.mark_fixed_length_file_body(); + } resp.on_aborted( |p: *mut FileResponseStream, r| { // SAFETY: uWS hands back the userdata pointer set below; the @@ -223,49 +224,61 @@ impl FileResponseStream { return; } - // BufferedReader path - this_ref.reader.with_mut(|reader| { + // The caller's fd can be at any position, so read its file at `offset` even when that's 0. + let positional = opts.offset > 0 || (!opts.auto_close && opts.file_type == FileType::File); + this_ref.start_reader( + opts.file_type, + opts.pollable, + positional.then_some(opts.offset), + opts.length, + ); + } + + /// Reads with `pread` from `offset`, or with `read` from the fd's position if `None`. + fn start_reader( + &self, + file_type: FileType, + pollable: bool, + offset: Option, + length: Option, + ) { + let fd = self.fd.get(); + self.reader.with_mut(|reader| { reader.flags.remove(ReaderFlags::CLOSE_HANDLE); // we own fd via auto_close - reader.flags.set(ReaderFlags::POLLABLE, opts.pollable); + reader.flags.set(ReaderFlags::POLLABLE, pollable); reader .flags - .set(ReaderFlags::NONBLOCKING, opts.file_type != FileType::File); + .set(ReaderFlags::NONBLOCKING, file_type != FileType::File); #[cfg(unix)] - if opts.file_type == FileType::Socket { + if file_type == FileType::Socket { reader.flags.insert(ReaderFlags::SOCKET); } // The reader reports the end of the body as EOF, so `on_read_chunk` ends the response there like at a real EOF. - reader.set_limit(opts.length.map(|len| len as usize)); - reader.set_parent(this.cast::()); + reader.set_limit(length.map(|len| len as usize)); + reader.set_parent(self.as_ptr().cast::()); }); // SAFETY: `start()`/`start_file_offset()` re-enter this object through // the parent pointer (`loop_`/`event_loop`), so no cell borrow spans them. - let reader = this_ref.reader_mut(); - let start_result = if opts.offset > 0 { - reader.start_file_offset(opts.fd, opts.pollable, opts.offset as usize) - } else { - reader.start(opts.fd, opts.pollable) + let reader = self.reader_mut(); + let start_result = match offset { + Some(offset) => reader.start_file_offset(fd, pollable, offset as usize), + None => reader.start(fd, pollable), }; if let Err(err) = start_result { - this_ref.fail_with(err); + self.fail_with(err); return; } // SAFETY: as above — `update_ref` re-enters `event_loop` through the parent pointer. - this_ref.reader_mut().update_ref(true); + self.reader_mut().update_ref(true); #[cfg(unix)] - if let Some(poll) = this_ref.reader.get().handle.get_poll() { - if this_ref - .reader - .get() - .flags - .contains(ReaderFlags::NONBLOCKING) - { + if let Some(poll) = self.reader.get().handle.get_poll() { + if self.reader.get().flags.contains(ReaderFlags::NONBLOCKING) { poll.set_flag(FilePollFlag::Nonblocking); } - match opts.file_type { + match file_type { FileType::Socket => poll.set_flag(FilePollFlag::Socket), FileType::NonblockingPipe | FileType::Pipe => poll.set_flag(FilePollFlag::Fifo), FileType::File => {} @@ -273,10 +286,10 @@ impl FileResponseStream { } // hold a ref for the in-flight read; released in on_reader_done/on_reader_error - this_ref.hold_read_ref(); + self.hold_read_ref(); // SAFETY: `reader` is live for the stream's lifetime; `read` is the // raw re-entrancy-safe entry (its dispatch runs user JS). - unsafe { BufferedReader::read(this_ref.reader.as_ptr()) }; + unsafe { BufferedReader::read(self.reader.as_ptr()) }; } #[inline] @@ -313,12 +326,11 @@ impl FileResponseStream { } let resp = self.resp.get(); - resp.timeout(self.idle_timeout.get()); if state == ReadState::Eof { self.insert_state(State::RESPONSE_DONE); self.detach_resp(); - resp.end(chunk, resp.should_close_connection()); + resp.end(chunk, self.should_close_connection()); self.deliver(resp, StreamEnd::Complete); return false; } @@ -390,7 +402,6 @@ impl FileResponseStream { self.finish(); return true; } - self.resp.get().timeout(self.idle_timeout.get()); self.hold_read_ref(); // A paused POSIX reader ignores `read()`. self.reader_mut().unpause(); @@ -465,6 +476,13 @@ impl FileResponseStream { } sys::E::EINTR => continue, sys::E::EAGAIN => return self.arm_sendfile_writable(), + // The kernel, a seccomp filter or the file's filesystem doesn't allow sendfile(2). + sys::E::EINVAL | sys::E::ENOSYS | sys::E::ENOTSUP | sys::E::EPERM => { + self.mode.set(Mode::Reader); + let sf = self.sendfile.get(); + self.start_reader(FileType::File, false, Some(sf.offset), Some(sf.remain)); + return true; + } _ => { self.fail_with( sys::Error::from_code(errno, sys::Tag::sendfile).with_fd(self.fd.get()), @@ -497,6 +515,8 @@ impl FileResponseStream { ); } self.resp.get().mark_needs_more(); + // Re-arm now that the socket is marked blocked, so uWS records how much the peer has taken so far. + self.resp.get().reset_timeout(); true } @@ -509,7 +529,7 @@ impl FileResponseStream { self.insert_state(State::RESPONSE_DONE); self.detach_resp(); let resp = self.resp.get(); - resp.end_send_file(self.sendfile.get().offset, resp.should_close_connection()); + resp.end_send_file(self.sendfile.get().offset, self.should_close_connection()); self.deliver(resp, StreamEnd::Complete); // `end_send_file` bypasses every shouldCloseConnection() gate: it does // not go through internalEnd, and the onWritable gate is skipped @@ -544,6 +564,15 @@ impl FileResponseStream { self.finish(); } + /// A file that ran out before `StartOptions::length` leaves the client waiting for the rest of its Content-Length, so close. + fn should_close_connection(&self) -> bool { + let ended_short = match self.mode.get() { + Mode::Reader => self.reader.get().is_below_limit(), + Mode::Sendfile => self.sendfile.get().remain > 0, + }; + ended_short || self.resp.get().should_close_connection() + } + fn fail_with(&self, err: sys::Error) { if !self.state.get().contains(State::RESPONSE_DONE) { self.insert_state(State::RESPONSE_DONE | State::ERRORED); @@ -588,7 +617,7 @@ impl FileResponseStream { let resp = self.resp.get(); // `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()); + resp.end(b"", self.should_close_connection()); self.deliver(resp, StreamEnd::Complete); } diff --git a/src/runtime/server/FileRoute.rs b/src/runtime/server/FileRoute.rs index e4278582cef0..7c30a16101e6 100644 --- a/src/runtime/server/FileRoute.rs +++ b/src/runtime/server/FileRoute.rs @@ -3,7 +3,7 @@ use core::mem::size_of; use bun_core::String as BunString; use bun_core::strings; -use bun_http::{Headers, Method}; +use bun_http::{Headers, Method, header_has_token}; use bun_http_types::ETag; use bun_http_types::ETag::StringPointer; use bun_io::Closer; @@ -36,9 +36,10 @@ pub(crate) struct FileRoute { // event loop. stat_hash: Cell, has_last_modified_header: bool, - has_content_length_header: bool, has_content_range_header: bool, has_date_header: bool, + has_connection_header: bool, + has_connection_close: bool, } pub(crate) struct InitOptions<'a> { @@ -49,13 +50,20 @@ pub(crate) struct InitOptions<'a> { use crate::webcore::headers_ref::blob_content_type; -#[inline] +/// Drops the framing headers, because `serve` frames each response from the file's size. 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)) + let mut headers = bun_http_jsc::headers_jsc::from_fetch_headers( + global, + fetch_headers, + blob_content_type(blob), + )?; + headers.remove(b"transfer-encoding"); + headers.remove(b"content-length"); + Ok(headers) } #[inline] @@ -118,9 +126,12 @@ impl FileRoute { ref_count: Cell::new(1), server: Cell::new(server), has_last_modified_header: headers.get(b"last-modified").is_some(), - has_content_length_header: headers.get(b"content-length").is_some(), has_content_range_header: headers.get(b"content-range").is_some(), has_date_header: headers.get(b"date").is_some(), + has_connection_header: headers.get(b"connection").is_some(), + has_connection_close: headers + .get(b"connection") + .is_some_and(|value| header_has_token(value, b"close")), blob, headers, status_code, @@ -232,10 +243,6 @@ impl FileRoute { } self.stat_hash.set(sh); } - - if self.has_content_length_header { - resp.mark_wrote_content_length_header(); - } } pub(crate) fn on_head_request(this: ThisPtr, req: AnyRequest, resp: AnyResponse) { @@ -295,7 +302,7 @@ impl FileRoute { }; // Every non-streaming outcome — bodiless status codes - // (304/204/205/307/308), HEAD, non-streamable files, and the JS-exception + // (1xx/204/205/304), HEAD, non-streamable files, and the JS-exception // early returns — is `Serve::Done`, so neither the fd nor the route ref // (or the server's pending_requests counter) can leak regardless of // which branch ran. @@ -323,7 +330,6 @@ impl FileRoute { pollable, offset, length, - idle_timeout: server.config().idle_timeout, owner: StreamOwner::FileRoute(route), }); } @@ -413,17 +419,18 @@ impl FileRoute { if self.has_date_header { resp.mark_wrote_date_header(); } + if self.has_connection_header { + resp.mark_wrote_connection_header(self.has_connection_close); + } resp.write_mark(); self.write_headers(resp); - // Bodiless statuses end before the range switch so a 304 emits no - // Content-Range. FileResponseStream ships via sendfile/write(), so a - // null-body status must never start it; 307/308 routes skip it too. - if HTTPStatusText::is_null_body(status_code) || matches!(status_code, 307 | 308) { + // 1xx, 204 and 304 end at the blank line (RFC 9112 6.3). 205 and 412 need Content-Length: 0. + if HTTPStatusText::is_null_body(status_code) && status_code != 205 { resp.end_without_body(resp.should_close_connection()); return Serve::Done; } - if status_code == 412 { + if matches!(status_code, 205 | 412) { resp.end(b"", resp.should_close_connection()); return Serve::Done; } @@ -449,10 +456,8 @@ impl FileRoute { }; if let Some(len) = body_len { - if !resp.state().has_written_content_length_header() { - resp.write_header_int(b"content-length", len); - resp.mark_wrote_content_length_header(); - } + resp.write_header_int(b"content-length", len); + resp.mark_wrote_content_length_header(); } if method == Method::HEAD { diff --git a/src/runtime/server/HTMLBundle.rs b/src/runtime/server/HTMLBundle.rs index fd39b91d699f..06162109633f 100644 --- a/src/runtime/server/HTMLBundle.rs +++ b/src/runtime/server/HTMLBundle.rs @@ -107,6 +107,8 @@ pub(crate) struct Route { pub(crate) dev_server_id: Cell>, /// When state == .pending, incomplete responses are stored here. pending_responses: JsCell>, + /// The chunk routes the last successful build appended to the server's static routes. + assets: JsCell, RefPtr)>>, } pub(crate) enum State { @@ -149,9 +151,19 @@ impl Route { server: Cell::new(None), state: JsCell::new(State::Pending), dev_server_id: Cell::new(None), + assets: JsCell::new(Vec::new()), }) } + /// Copies the chunk routes of `old`, the route `server.reload()` replaces, so pages that clients already loaded keep working. + pub(crate) fn adopt_assets(&self, old: &Route) -> Vec<(Box<[u8]>, RefPtr)> { + if self.bundle.as_ptr() != old.bundle.as_ptr() || !self.assets.get().is_empty() { + return Vec::new(); + } + self.assets.set(old.assets.get().clone()); + self.assets.get().clone() + } + pub(crate) fn on_request(this: ThisPtr, req: AnyRequest, resp: AnyResponse) { Self::on_any_request(this, req, resp, false); } @@ -245,8 +257,7 @@ impl Route { bstr::BStr::new(req.url()) ); } - // TODO: use the code from bake/dev_server/failures.rs to render the error - resp.end_without_body(true); + Self::end_build_failed(resp); } State::Html(html) => { if bun_core::Environment::ENABLE_LOGS { @@ -312,14 +323,12 @@ impl Route { self.server.get().expect("server set").on_request_complete(); } - /// Production keeps the reason to itself, see `resume_pending_responses`. - fn set_build_error(&self, server: AnyServer, log: Log) { - if server.config().is_development() { - // `Log::print` takes the process-global writer as a `*mut io::Writer` through `IntoLogWrite`. - let writer: *mut bun_core::io::Writer = bun_output::error_writer_buffered(); - let _ = log.print(writer); - bun_output::flush(); - } + /// Production only hides the reason from the client, see `end_build_failed`. + fn set_build_error(&self, log: Log) { + // `Log::print` takes the process-global writer as a `*mut io::Writer` through `IntoLogWrite`. + let writer: *mut bun_core::io::Writer = bun_output::error_writer_buffered(); + let _ = log.print(writer); + bun_output::flush(); self.state.set(State::Err(log)); } @@ -403,7 +412,11 @@ impl Route { config.define.put(b"import.meta.env.SSR", b"false")?; config.define.put(b"import.meta.env.STATIC", b"false")?; - if let Some(define) = &cli.args.serve_define { + // `[serve.static] define` comes last so it overrides a top-level define with the same key. + for define in [&cli.args.define, &cli.args.serve_define] + .into_iter() + .flatten() + { debug_assert_eq!(define.keys.len(), define.values.len()); // `StringMap` exposes only put/insert (no bulk re-index); // profile if hot. @@ -466,7 +479,7 @@ impl Route { } let mut log = Log::init(); completion_task.log.clone_to_with_recycled(&mut log, true); - self.set_build_error(server, log); + self.set_build_error(log); } BundleV2Result::Value(bundle) => 'bundle: { if bun_core::Environment::ENABLE_LOGS { @@ -501,7 +514,7 @@ impl Route { output_file.output_kind == bundler_options::OutputKind::EntryPoint && output_file.loader == Loader::Html }) else { - self.set_build_error(server, self.bundle.no_html_page_log()); + self.set_build_error(self.bundle.no_html_page_log()); break 'bundle; }; @@ -509,6 +522,7 @@ impl Route { // needs it by `&mut` before it is shared. Static routes are keyed // by `dest_path`, so registration order is immaterial. let mut this_html_route: Option<(StaticRoute, Box<[u8]>)> = None; + self.assets.set(Vec::new()); // Create static routes for each output file // Index loop because the SourceMap branch reads a sibling entry. @@ -581,9 +595,12 @@ impl Route { continue; } + let static_route = RefPtr::new(static_route); + self.assets + .with_mut(|assets| assets.push((route_path.into(), static_route.clone()))); bun_core::handle_oom(server.append_static_route( route_path, - AnyRoute::Static(RefPtr::new(static_route)), + AnyRoute::Static(static_route), MethodOptional::Any, )); } @@ -591,11 +608,16 @@ impl Route { let (mut html_route, html_route_path) = this_html_route.expect("the loop above visited html_index"); let html_route_clone = html_route.clone(global_this); - bun_core::handle_oom(server.append_static_route( - &html_route_path, - AnyRoute::Static(RefPtr::new(html_route)), - MethodOptional::Any, - )); + // This copy would replace an HTML route mounted at the same path, and the page would never build again. + if !server.config().static_routes.iter().any(|entry| { + matches!(entry.route, AnyRoute::Html(_)) && *entry.path == *html_route_path + }) { + bun_core::handle_oom(server.append_static_route( + &html_route_path, + AnyRoute::Static(RefPtr::new(html_route)), + MethodOptional::Any, + )); + } self.state.set(State::Html(html_route_clone)); if !bun_core::handle_oom(server.reload_static_routes()) { @@ -632,26 +654,7 @@ impl Route { StaticRoute::on(html.this_ptr(), resp); } } - State::Err(_log) => { - if self - .server - .get() - .expect("server set") - .config() - .is_development() - { - // TODO: use the code from bake/dev_server/failures.rs to render the error - } else { - // To protect privacy, do not show errors to end users in production. - // TODO: Show a generic error page. - } - // This runs from a JS event-loop task, not a uWS handler, - // so `end_without_body(true)` alone cannot close the - // socket; write Content-Length so the client has framing. - resp.write_status(b"500 Build Failed"); - resp.write_header_int(b"Content-Length", 0); - resp.end_without_body(true); - } + State::Err(_log) => Self::end_build_failed(resp), _ => { resp.write_header_int(b"Content-Length", 0); resp.end_without_body(true); @@ -659,6 +662,15 @@ impl Route { } } } + + // No body, so production doesn't leak the build error. TODO: render it in development like bake/dev_server/failures.rs. + fn end_build_failed(resp: AnyResponse) { + resp.write_status(b"500 Build Failed"); + resp.write_header_int(b"Content-Length", 0); + resp.end_without_body(true); + // `end_without_body` only marks the connection. Outside a uWS handler nothing else closes it. + resp.close_if_done_and_marked(); + } } impl Drop for Route { diff --git a/src/runtime/server/NodeHTTPResponse.bind.ts b/src/runtime/server/NodeHTTPResponse.bind.ts index e38a807bcba6..20b4413745c2 100644 --- a/src/runtime/server/NodeHTTPResponse.bind.ts +++ b/src/runtime/server/NodeHTTPResponse.bind.ts @@ -3,7 +3,7 @@ import * as b from "../../codegen/bindgen-lib.ts"; const eventHandler = { getter: b.any, setter: { capability: "js" }, passThis: true } as const; const head = { statusCode: b.i32.validateInteger(100, 999).default(200), - statusMessage: b.string.loose.utf8.nullable, + statusMessage: b.string.loose.nullable, /** The flat `[name, value, ...]` array, which C++ reads in place. */ headers: b.any, }; diff --git a/src/runtime/server/NodeHTTPResponse.rs b/src/runtime/server/NodeHTTPResponse.rs index 0767eb31bc76..96f6f655ef68 100644 --- a/src/runtime/server/NodeHTTPResponse.rs +++ b/src/runtime/server/NodeHTTPResponse.rs @@ -127,6 +127,10 @@ bitflags! { /// The server socket granted the connection to this response. A queued pipelined response waits for it. const CURRENT = 1 << 4; const IS_REQUEST_PENDING = 1 << 5; + /// Set by `pause_socket`, cleared by `resume_socket`. + const READS_PAUSED = 1 << 6; + /// See `update_parked`. + const PARKED = 1 << 7; /// node:http handed this connection to a raw 'upgrade'/'connect' /// tunnel (JSNodeHTTPServerSocket::upgradeToTunnelMode). const TUNNELED = 1 << 8; @@ -184,6 +188,7 @@ unsafe extern "C" { // node:http flood prevention (JSNodeHTTPServerSocket.cpp): unsent response bytes and queued responses hold a paused socket. safe fn Bun__NodeHTTP__onReadsPaused(ssl: core::ffi::c_int, socket: *mut c_void); safe fn Bun__NodeHTTP__onReadsResumable(ssl: core::ffi::c_int, socket: *mut c_void); + safe fn Bun__NodeHTTP__onRequestParked(ssl: core::ffi::c_int, socket: *mut c_void); // False when no read of this socket is being parsed. safe fn Bun__NodeHTTP__notifyWhenReadParsed(ssl: core::ffi::c_int, socket: *mut c_void) -> bool; @@ -498,6 +503,8 @@ impl NodeHTTPResponse { return; } raw.pause(); + self.update_flags(|f| f.insert(Flags::READS_PAUSED)); + self.update_parked(); } /* Pipelined flood prevention pauses READS on the connection, legal after the in-flight @@ -523,6 +530,8 @@ impl NodeHTTPResponse { fn resume_socket(&self) { scoped_log!(NodeHTTPResponse, "resumeSocket"); + self.update_flags(|f| f.remove(Flags::READS_PAUSED)); + self.update_parked(); let flags = self.flags.get(); let Some(raw) = self.reader() else { return; @@ -664,12 +673,39 @@ impl NodeHTTPResponse { self.release_body_slot(); } self.body_read_state.set(to); + self.update_parked(); if self.body_read_ref.get().has { self.body_read_ref.with_mut(|r| r.unref(vm_get())); } true } + /// Parks a request whose response is sent and whose body reads are paused. Like libuv after `uv_read_stop()`, it stops holding the event loop: https://github.com/libuv/libuv/blob/v1.51.0/src/unix/stream.c#L1465-L1477 + #[uws::uws_callback(export = "Bun__NodeHTTPResponse_updateParked", no_catch)] + pub(crate) fn update_parked(&self) { + let flags = self.flags.get(); + let pending = self.body_still_arriving(); + let parked = pending + && flags.contains(Flags::READS_PAUSED | Flags::REQUEST_HAS_COMPLETED) + && self.reader().is_some_and(|raw| raw.has_fully_drained()); + if parked == flags.contains(Flags::PARKED) { + return; + } + self.update_flags(|f| f.toggle(Flags::PARKED)); + self.server.note_request_parked(parked); + if parked { + self.body_read_ref.with_mut(|r| r.unref(vm_get())); + if let Some(raw) = self.reader() { + Bun__NodeHTTP__onRequestParked( + any_response_is_ssl(&raw) as core::ffi::c_int, + raw.socket().cast(), + ); + } + } else if pending { + self.body_read_ref.with_mut(|r| r.r#ref(vm_get())); + } + } + fn mark_socket_closed(&self) { self.update_flags(|f| f.insert(Flags::SOCKET_CLOSED)); self.leave_pending(BodyReadState::Aborted); @@ -742,6 +778,7 @@ impl NodeHTTPResponse { let vm = vm_get(); self.clear_on_data_callback(self.get_this_value(), vm.global()); + debug_assert!(!self.flags.get().contains(Flags::PARKED)); self.clear_pending_pinned_write(vm.global(), JSValue::ZERO); let mut server = self.server; @@ -874,7 +911,7 @@ impl NodeHTTPResponse { &self, cx: &mut JsScope<'_>, status_code: i32, - status_message: Option<&[u8]>, + status_message: Option, headers: JsLocal<'_>, auto_header_bits: u32, keep_alive_timeout_secs: u32, @@ -885,7 +922,20 @@ impl NodeHTTPResponse { } else { JSValue::UNDEFINED }; - let status_message_bytes: &[u8] = status_message.unwrap_or_default(); + // The status line is Latin-1, like Node's. A code unit above 0xFF becomes NUL, which the check below rejects. + let narrowed: Vec; + let status_message_bytes: &[u8] = match &status_message { + None => &[], + Some(message) if message.is_8bit() => message.latin1(), + Some(message) => { + narrowed = message + .utf16() + .iter() + .map(|&unit| u8::try_from(unit).unwrap_or(0)) + .collect(); + &narrowed + } + }; if self.is_requested_completed_or_ended() { return err_throw( @@ -1002,7 +1052,7 @@ impl NodeHTTPResponse { this_value: JsWrapper<'_, Self>, cx: &mut JsScope<'_>, status_code: i32, - status_message: Option<&[u8]>, + status_message: Option, headers: JsLocal<'_>, chunk: Option>, encoding: JsLocal<'_>, @@ -1116,6 +1166,8 @@ impl NodeHTTPResponse { handle_ended_if_necessary(state, global_object)?; raw_response.write_continue(); + // The socket is corked until the handler returns. Flush now: a 1xx is only useful ahead of the final response. + raw_response.send_corked(); Ok(()) } @@ -1150,6 +1202,7 @@ impl NodeHTTPResponse { }; handle_ended_if_necessary(raw_response.state(), global_object)?; raw_response.write_informational(string_or_buffer.slice()); + raw_response.send_corked(); Ok(()) } } @@ -1395,6 +1448,7 @@ impl NodeHTTPResponse { self.update_flags(|f| f.insert(Flags::REQUEST_HAS_COMPLETED)); self.poll_ref.with_mut(|r| r.unref(vm_get())); + self.update_parked(); self.mark_request_as_done_if_necessary(); } } @@ -1521,7 +1575,7 @@ impl NodeHTTPResponse { if let Some(raw_response) = self.writer() { raw_response.clear_on_writable(); raw_response.clear_timeout(); - raw_response.end_without_body(true); + raw_response.abandon(); } self.on_request_complete(); } @@ -1553,6 +1607,7 @@ impl NodeHTTPResponse { if last && event == AbortEvent::None && self.body_read_state.get() == BodyReadState::Pending { self.body_read_state.set(BodyReadState::Complete); + self.update_parked(); } if let Some(callback) = js::on_data_get_cached(this_value) { @@ -1901,6 +1956,11 @@ impl NodeHTTPResponse { self.get_this_value() }; + // uWS skips an empty write, but Node's _send("") still ends the header section. + if !IS_END && bytes.is_empty() && !state.is_http_write_called() { + self.writer().unwrap().flush_headers(false); + } + // An empty write looks flushed to uWS; WantMore would disarm the drain that is still owed. if !IS_END && bytes.is_empty() && self.has_unflushed_write() { if !callback_value.is_undefined() { @@ -2177,8 +2237,8 @@ impl NodeHTTPResponse { raw_response.on_data(on_data_shim, self.as_ctx_ptr()); } - // `body_read_ref` is still held from create(): every unref also leaves `.pending`. - debug_assert!(self.body_read_ref.get().has); + // `body_read_ref` is still held from create(): every unref also leaves `.pending` or parks the request. + debug_assert!(self.body_read_ref.get().has != flags.contains(Flags::PARKED)); } pub(crate) fn write( diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index f853265c2776..d34c4f08107d 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -168,7 +168,7 @@ pub(crate) struct RequestContext< pub(crate) request_body_readable_stream_ref: JsCell, /// Owning `+1` handle into the per-VM `Body::Value` hive pool. Shared with - /// `Request.body` (each holds its own `+1`). `Drop` releases the count. + /// `Request.body` (each holds its own `+1`). Released at the last chunk, or in `deinit`. pub(crate) request_body: JsCell>, pub(crate) request_body_buf: JsCell>, pub(crate) request_body_content_len: Cell, @@ -276,7 +276,6 @@ mod NativePromiseContext { 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}; use bun_jsc::SysErrorJsc as _; @@ -376,6 +375,44 @@ fn report_unhandled_error(vm: &mut jsc::VirtualMachine, global: &JSGlobalObject, } } +/// Rejects reads of a request body that outgrew `maxRequestBodySize`. +fn request_body_too_large() -> Body::ValueError { + Body::ValueError::Message(BunString::static_( + "Request body exceeded maxRequestBodySize", + )) +} + +/// Answers 413, or resets a response that already started. `resp` is already detached from its context. +#[cold] +#[inline(never)] +fn refuse_oversized_request( + resp: uws::AnyResponse, + status_written: bool, + close_connection: bool, + body: Option<&mut Body::Value>, + global_this: &JSGlobalObject, +) { + debug_assert!(!resp.has_responded()); + if !status_written { + resp.write_status(b"413 Payload Too Large"); + resp.end_without_body(close_connection); + resp.close_if_done_and_marked(); + } else { + // Never the terminating chunk: it would make a truncated body look complete. + resp.force_close(); + } + // Fail an unread body now, or a first read from an abort listener would find it empty. + if let Some(body) = body { + let untouched = match body { + Body::Value::Locked(locked) => !locked.has_consumer() && !locked.readable.has(), + _ => false, + }; + if untouched { + let _ = body.to_error_instance(request_body_too_large(), global_this); + } + } +} + /// Release the body's hold on a stream the sink is done with, and mark a /// `Locked` body used. Non-generic and out of line: the eight `RequestContext` /// monomorphizations share one copy. @@ -445,15 +482,7 @@ mod shim { signal.pending_activity_unref(); signal.unref(); } - /// `Blob::is_s3()` / `Blob::needs_to_read_file()` have duplicate impls - /// (E0034); inline the body here. - #[inline] - pub(super) fn blob_is_s3(b: &Blob) -> bool { - b.store - .get() - .as_ref() - .is_some_and(|s| matches!(s.data, crate::webcore::blob::store::Data::S3(_))) - } + /// `Blob::needs_to_read_file()` has duplicate impls (E0034); inline the body here. #[inline] pub(super) fn blob_needs_to_read_file(b: &Blob) -> bool { b.store @@ -752,20 +781,28 @@ where Ok(JSValue::UNDEFINED) } - /// Cancel the body stream of a Response the server will not transmit, unless a consumer reads it. + /// Cancel the body of a Response the server will not transmit, unless a consumer or a reader holds it. fn cancel_unread_body(response: &Response, global_this: &JSGlobalObject) { - if let Body::Value::Locked(locked) = response.get_body_value() - && locked.has_consumer() - { - return; + match response.get_body_value() { + // Nothing to release, and the handler can return this Response again. + Body::Value::Null | Body::Value::Empty => return, + Body::Value::Locked(locked) if locked.has_consumer() => return, + _ => {} } if let Some(stream) = response.get_body_readable_stream() { let _keep = jsc::EnsureStillAlive(stream.value); response.detach_readable_stream(global_this); - // Not `cancel()`: it skips a stream with no reader, which an unattached body is. - crate::dispatch::fold(stream.cancel_with_reason(global_this, JSValue::UNDEFINED)); + // A locked stream belongs to its reader, which can be another Response's sink. + if !stream.is_locked(global_this) { + // Not `cancel()`: it skips a stream with no reader, which an unattached body is. + crate::dispatch::fold(stream.cancel_with_reason(global_this, JSValue::UNDEFINED)); + } + } + let mut body = core::mem::replace(response.get_body_value(), Body::Value::Used); + // A pending body that was never read has no stream to cancel, only its producer. + if let Body::Value::Locked(locked) = &mut body { + locked.producer.cancel(JSValue::UNDEFINED); } - *response.get_body_value() = Body::Value::Used; } /// [`Self::cancel_unread_body`] for a rooted handler result: a `Response` or a settled promise of one. @@ -873,6 +910,10 @@ where self.run_error_handler(error); return; } + // `value` came from a deferred `error()`, so respond like a sync `error()` without a Response. + if self.flags.is_error_promise_pending() { + return self.render_production_error(500); + } self.render_missing(); } @@ -1257,8 +1298,8 @@ where resp.write_header(b"content-type", &bun_http_types::MimeType::HTML.value); resp.write_header(b"content-encoding", b"gzip"); - resp.write_header_int(b"content-length", WELCOME_PAGE_HTML_GZ.len() as u64); if ctx.method == Method::HEAD { + resp.write_header_int(b"content-length", WELCOME_PAGE_HTML_GZ.len() as u64); ctx.end_without_body(ctx.should_close_connection()); } else { ctx.end(WELCOME_PAGE_HTML_GZ, ctx.should_close_connection()); @@ -1269,9 +1310,9 @@ where b"Welcome to Bun! To get started, return a Response object."; resp.write_status(b"200 OK"); resp.write_header(b"content-type", &bun_http_types::MimeType::TEXT.value); - resp.write_header_int(b"content-length", MISSING_CONTENT.len() as u64); ctx.flags.set_has_written_status(true); if ctx.method == Method::HEAD { + resp.write_header_int(b"content-length", MISSING_CONTENT.len() as u64); ctx.end_without_body(ctx.should_close_connection()); } else { ctx.end(MISSING_CONTENT, ctx.should_close_connection()); @@ -1417,7 +1458,7 @@ where self.detach_response(); // uWS markDone() clears onAborted on end, so on_abort can never // run for this request; this is the last chance to reclaim an - // outstanding claim (e.g. a 413 while the handler promise parks). + // outstanding claim (e.g. a 426 while the handler promise parks). self.reclaim_promise_cell(); // SAFETY: FFI handle resp.end_without_body(close_connection); @@ -1447,29 +1488,6 @@ where self.end_without_body(true); } - /// Answers 413 to a request whose body outgrew `maxRequestBodySize`, after that body has failed. - fn end_with_payload_too_large(&self) { - let Some(resp) = self.resp.get() else { return }; - // A dev server bundle that this request waits for must not answer it later. - if let Some(abort) = self.additional_on_abort.replace(None) { - (abort.cb)(abort.data.as_ptr()); - abort.deref(); - } - // Failing the body runs JS, which can answer with a file. The file stream - // owns the response from then on, and it hears the close as an abort. - if self.flags.has_sendfile_ctx() { - resp.force_close(); - return; - } - if !resp.has_responded() { - self.flags.set_has_written_status(true); - resp.write_status(b"413 Payload Too Large"); - } - // The normal end path detaches `self.resp` and releases the base ref. uWS - // markDone() clears onAborted, so no abort would ever do either. - self.end_without_body(!MUX); - } - pub(crate) fn force_close(&self) { if let Some(resp) = self.resp.get() { self.detach_response(); @@ -1617,6 +1635,15 @@ where return; } } + this.abort(jsc::CommonAbortReason::ConnectionClosed); + } + + /// Tears down a request whose response can't complete. The caller pins `self` and holds an event loop scope. + #[inline(never)] + fn abort(&self, reason: jsc::CommonAbortReason) { + let this = self; + let server = this.server(); + let vm = server.vm(); debug_assert!(!this.flags.aborted()); // mark request as aborted this.flags.set_aborted(true); @@ -1626,9 +1653,23 @@ where abort.deref(); } + let resp = this.resp.get(); this.detach_response(); let any_js_calls = core::cell::Cell::new(false); let global_this = server.global_this(); + if reason == jsc::CommonAbortReason::RequestBodyTooLarge { + this.flags.set_request_body_refused(true); + if let Some(resp) = resp { + refuse_oversized_request( + resp, + this.flags.has_written_status(), + !MUX, + this.request_body_mut(), + global_this, + ); + this.flags.set_has_written_status(true); + } + } let _ref = RequestContextRef::adopt(this.as_ctx_ptr()); // This is a task in the event loop. // If we called into JavaScript, we must drain the microtask queue. @@ -1645,11 +1686,7 @@ where // if signal is not aborted, abort the signal if let Some(signal) = this.signal.take() { if !shim::signal_aborted(signal) { - shim::signal_fire( - signal, - global_this, - jsc::CommonAbortReason::ConnectionClosed, - ); + shim::signal_fire(signal, global_this, reason); any_js_calls.set(true); } shim::signal_release(signal); @@ -1665,6 +1702,7 @@ where unsafe { ResponseStream::::abort( sink_ptr.as_ptr().cast::>(), + reason, ); } // Reject a parked request-body read while this abort still drains microtasks. @@ -1912,6 +1950,28 @@ where RangeRequest::if_range_matches(if_range, etag.as_deref(), last_modified_ms) } + /// Returns the default status for a `Bun.file()` body that can't be opened. + fn file_open_error_status(&self, errno: bun_sys::E) -> u16 { + // `error()` returned this file, so the failure is the handler's fault. + if self.flags.has_called_error_handler() { + return 500; + } + match errno { + bun_sys::E::ENOENT + | bun_sys::E::ENOTDIR + | bun_sys::E::EISDIR + | bun_sys::E::ENAMETOOLONG => 404, + bun_sys::E::EACCES | bun_sys::E::EPERM => 403, + _ => 500, + } + } + + /// Drops the failed file first, so `error()`'s Response doesn't get its Content-Type or filename. + fn fail_sendfile(&self, js_err: JSValue, status: u16) { + self.blob.with_mut(|b| b.detach()); + self.run_error_handler_with_status_code(js_err, status); + } + pub(crate) fn do_sendfile(&self, blob: Blob) { if self.is_aborted_or_ended() { return; @@ -1943,10 +2003,11 @@ where ) { bun_sys::Result::Ok(fd_) => fd_, bun_sys::Result::Err(err) => { + let status = self.file_open_error_status(err.get_errno()); let js_err = err .with_path(file.pathlike.path().slice()) .to_js(global_this); - return self.run_error_handler(js_err); + return self.fail_sendfile(js_err, status); } } }; @@ -1966,7 +2027,7 @@ where err.with_fd(*pathlike_fd).to_js(global_this) } }; - return self.run_error_handler(js_err); + return self.fail_sendfile(js_err, 500); } }; @@ -1999,7 +2060,10 @@ where }; let mut sys: jsc::SystemError = err.to_system_error().into(); sys.message = BunString::static_("Cannot stream a directory as a response body"); - return self.run_error_handler(sys.to_error_instance(global_this)); + return self.fail_sendfile( + sys.to_error_instance(global_this), + self.file_open_error_status(bun_sys::E::EISDIR), + ); } (bun_io::FileType::File, false) }; @@ -2009,13 +2073,6 @@ where _ => unreachable!(), }; let stat_size: BlobSizeType = BlobSizeType::try_from(stat.st_size.max(0)).unwrap(); - if let AnyBlob::Blob(b) = blob_ref { - b.size.set(if is_regular { - stat_size - } else { - original_size.min(stat_size) - }); - } self.flags.set_needs_content_length(is_regular); let mut sendfile = SendfileContext { @@ -2023,11 +2080,6 @@ where offset: blob_offset, total: 0, }; - if is_regular && auto_close { - self.flags.set_needs_content_range( - sendfile.remain.saturating_sub(sendfile.offset) != stat_size, - ); - } if is_regular { sendfile.offset = sendfile.offset.min(stat_size); sendfile.remain = sendfile @@ -2035,6 +2087,10 @@ where .max(sendfile.offset) .min(stat_size) .saturating_sub(sendfile.offset); + if auto_close { + self.flags + .set_needs_content_range(sendfile.remain != stat_size); + } } self.sendfile.set(sendfile); @@ -2152,7 +2208,6 @@ where } else { None }, - idle_timeout: server.config().idle_timeout, owner: file_response_stream::StreamOwner::Ctx { ctx: self.as_ctx_ptr().cast::(), on_complete: Self::on_file_stream_complete, @@ -2243,6 +2298,50 @@ where readable_ref.deinit(); } + /// Sends the head after the pending microtasks ran, so a body that ends in them keeps `Content-Length`. + fn defer_head_commit(&self) { + let ctx = NonNull::new(self.as_ctx_ptr().cast::()); + let already_posted = self + .server() + .vm() + .as_mut() + .event_loop_mut() + .deferred_tasks + .post_task(ctx, Self::on_deferred_head_commit); + if !already_posted { + // Released by the task. + self.ref_(); + } + } + + extern "C" fn on_deferred_head_commit(ctx: *mut c_void) -> bool { + let pinned = RequestContextRef::adopt(ctx.cast::()); + let this = pinned.ctx(); + + // The response already ended, failed or was aborted. + let Some(resp) = this.resp.get() else { + return false; + }; + // The stream already settled, and its teardown ends the response. + let Some(wrapper) = this.sink_mut() else { + return false; + }; + if !wrapper.sink.sends_head_itself() { + resp.commit_head(); + } + false + } + + /// Sends the whole head for a body that has no bytes yet. + fn render_committed_head_corked(this: *mut Self) { + // SAFETY: this is the live RequestContext threaded through cork user-data. + let this = unsafe { &*this }; + this.render_metadata(); + if let Some(resp) = this.resp.get() { + resp.commit_head(); + } + } + fn do_render_stream(pair: *mut StreamPair<'_, ThisServer, SSL_ENABLED, DEBUG_MODE, MUX>) { ctx_log!("doRenderStream"); // SAFETY: pair is a stack local threaded through cork user-data. @@ -2385,6 +2484,16 @@ where this.render_metadata(); } + // The client gets the head now instead of with the first chunk. + if !MUX { + if this.is_async() { + // `drain_microtasks()` above was a no-op, so the body's microtasks are still queued. + this.defer_head_commit(); + } else if !response_stream.sink.sends_head_itself() { + resp.commit_head(); + } + } + // TODO: should this timeout? let body_value = this.response_mut().unwrap().get_body_value(); *body_value = Body::Value::Locked(Body::PendingValue { @@ -2545,6 +2654,14 @@ where } } + /// Rejects reads of a request body that will never complete. + fn request_body_error(&self) -> Body::ValueError { + if self.flags.request_body_refused() { + return request_body_too_large(); + } + Body::ValueError::AbortReason(jsc::CommonAbortReason::ConnectionClosed) + } + fn end_request_streaming(&self) -> JsResult { debug_assert!(self.server.get().is_some()); @@ -2556,10 +2673,7 @@ where if let Some(body) = self.request_body_mut() { if matches!(body, Body::Value::Locked(_)) { let global_this = self.server().global_this(); - body.to_error_instance( - Body::ValueError::AbortReason(jsc::CommonAbortReason::ConnectionClosed), - global_this, - )?; + body.to_error_instance(self.request_body_error(), global_this)?; any_js_calls = true; } } @@ -2578,8 +2692,7 @@ where // False unless `to_error_instance` above reached this same stream through the body. if !bytes.has_received_last_chunk.get() { let global_this = self.server().global_this(); - let mut err = - Body::ValueError::AbortReason(jsc::CommonAbortReason::ConnectionClosed); + let mut err = self.request_body_error(); bytes.on_data(WebCore::streams::Result::Err( err.to_stream_error(global_this), )); @@ -2713,39 +2826,20 @@ where // handler-supplied Content-Length / Transfer-Encoding header) let body_value = response.get_body_value(); match body_value { - Body::Value::InternalBlob(_) | Body::Value::WTFStringImpl(_) => { - let mut blob = body_value.use_as_any_blob_allow_non_utf8_string(); - let size = blob.size(); - this.render_metadata(); - - if size == crate::webcore::blob::MAX_SIZE { - resp.write_header_int(b"content-length", 0); - } else { - resp.write_header_int(b"content-length", size as u64); + Body::Value::InternalBlob(_) | Body::Value::WTFStringImpl(_) | Body::Value::Blob(_) => { + // `render_metadata` reads Content-Type and the filename from `this.blob`. HEAD doesn't consume a Blob body. + this.blob.set(match body_value { + Body::Value::Blob(blob) => AnyBlob::Blob(blob.dupe()), + _ => body_value.use_as_any_blob_allow_non_utf8_string(), + }); + // `do_sendfile` opens the file like GET does and ends a HEAD response after the headers. + if this.blob.get().needs_to_read_file() { + this.render_with_blob_from_body_value(); + return; } - this.end_without_body(this.should_close_connection()); - blob.detach(); - } - - Body::Value::Blob(blob) => { - // 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. - 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 { - resp.write_header_int(b"content-length", 0); - } else { - resp.write_header_int(b"content-length", blob_size as u64); - } + // GET sends these bytes (`render_bytes`). + resp.write_header_int(b"content-length", this.blob.get().slice().len() as u64); this.end_without_body(this.should_close_connection()); } Body::Value::Locked(_) => { @@ -3247,7 +3341,10 @@ where ), ..Default::default() }; - stream.value.unprotect(); + // The stream belongs to its reader, so teardown must not find it and end it. + if let Some(response) = this.response_mut() { + response.detach_readable_stream(global_this); + } let js_err = err.to_error_instance(global_this); this.run_error_handler(js_err); return; @@ -3343,15 +3440,16 @@ where || matches!( byte_stream.parent_const().producer.get(), WebCore::streams::SourceHandle::HTMLRewriter(_) + | WebCore::streams::SourceHandle::S3DownloadBody(_) ) { // Defer status/headers to the first chunk/end - // so a pre-first-byte handler failure can + // so a failure before the first byte can // still reach `error()`. } else { - // if we only have metadata to send, send it now + // No body bytes yet: send the whole head now. resp.run_corked_with_type( - Self::render_metadata_corked, + Self::render_committed_head_corked, this.as_ctx_ptr(), ); } @@ -3414,7 +3512,7 @@ where } let resp = this.resp.get().expect("infallible: resp bound"); - // A rewriter-produced `Source::Bytes` body defers metadata until the + // A rewriter or S3 `Source::Bytes` body defers metadata until the // first chunk so a pre-first-byte failure can still reach the // server's `error()` hook. Flush it now, corked. if !this.flags.has_written_status() { @@ -3584,8 +3682,6 @@ where if let Some(len) = app_content_length { resp.write_header_int(b"content-length", len); } - // end_without_body skips writeMark(); keep Date as try_end did. - resp.write_mark(); this.end_without_body(this.should_close_connection()); return; } @@ -3621,12 +3717,11 @@ where if let Some(resp) = self.resp.get() { // `AnyResponse` is a `Copy` handle; methods take `self` by value. match status { - 404 => { + 403 | 404 => { if !self.flags.has_written_status() { - resp.write_status(b"404 Not Found"); - self.flags.set_has_written_status(true); + self.do_write_status(status); } - self.end_without_body(self.should_close_connection()); + self.end(b"", self.should_close_connection()); } _ => { const BODY: &[u8] = b"Something went wrong!"; @@ -3695,6 +3790,12 @@ where // `ServerLike::vm()` is the process-static VM `BackRef`; `as_mut()` is // the single audited `&mut VirtualMachine` accessor. let vm = server.vm().as_mut(); + // A missing or unreadable `Bun.file()` isn't a bug in the handler, so skip the report and the dev error page. + if matches!(status, 403 | 404) { + self.render_production_error(status); + vm.log_mut().unwrap().reset(); + return; + } if DEBUG_MODE { let mut exception_list: jsc::ExceptionList = Vec::new(); let prev_exception_list = vm.on_unhandled_rejection_exception_list; @@ -3713,9 +3814,7 @@ where log.reset(); return; } - if status != 404 { - report_unhandled_error(vm, global_this, value); - } + report_unhandled_error(vm, global_this, value); self.render_production_error(status); vm.log_mut().unwrap().reset(); } @@ -3751,7 +3850,7 @@ where } if !result.is_empty_or_undefined_or_null() { if let Some(err) = result.to_error() { - self.finish_running_error_handler(err, status); + self.finish_running_error_handler(err, 500); return; } else if let Some(promise) = result.as_any_promise() { self.process_on_error_promise(result, promise, value, status); @@ -3826,7 +3925,7 @@ where return; } jsc::PromiseResult::Rejected(err) => { - ctx.finish_running_error_handler(err, status); + ctx.finish_running_error_handler(err, 500); return; } } @@ -3861,14 +3960,7 @@ where let sendfile = self.sendfile.get(); let mut status = response.status_code(); let blob = self.blob.get(); - let mut needs_content_range = self.flags.needs_content_range() - && (sendfile.total > 0 || sendfile.remain < blob.size()); - - let size = if needs_content_range { - sendfile.remain - } else { - blob.size() - }; + let mut needs_content_range = self.flags.needs_content_range(); let (content_type, needs_content_type, content_type_needs_free) = get_content_type(response.get_init_headers_mut(), blob); @@ -3881,12 +3973,17 @@ where let mut has_content_disposition = false; let mut has_content_range = false; if let Some(mut headers_) = response.swap_init_headers() { + if response.body_decoded() { + headers_.fast_remove(jsc::HTTPHeaderName::ContentEncoding); + } has_content_disposition = headers_.fast_has(jsc::HTTPHeaderName::ContentDisposition); has_content_range = headers_.fast_has(jsc::HTTPHeaderName::ContentRange); // For .slice()-driven ranges, only promote to 206 if the user // also set Content-Range (preserves the old contract). For an // incoming Range: header (sendfile.total > 0) we always 206. - needs_content_range = needs_content_range && (sendfile.total > 0 || has_content_range); + // A list implied by the body counts as no list. + needs_content_range = needs_content_range + && (sendfile.total > 0 || has_content_range || headers_.is_implied_by_body()); if needs_content_range { status = 206; } @@ -3925,8 +4022,9 @@ where if needs_content_type // do not insert the content type if it is the fallback value // we may not know the content-type when streaming + // `OTHER` is a `const` with no stable address, so compare the bytes. && (!blob.is_detached() - || content_type.value.as_ptr() != bun_http_types::MimeType::OTHER.value.as_ptr()) + || content_type.value.as_ref() != bun_http_types::MimeType::OTHER.value.as_ref()) && !strings::contains_any(&content_type.value, b"\r\n\0") { resp.write_header(b"content-type", &content_type.value); @@ -3950,10 +4048,12 @@ where if !basename.is_empty() { let mut filename_buf = [0u8; 1024]; let truncated = &basename[..basename.len().min(1024 - 32)]; - if !strings::contains_any(truncated, b"\r\n\0\"") { + if truncated.iter().all(|&byte| is_qdtext(byte)) { let header_value = { let mut w = &mut filename_buf[..]; - if write!(w, "filename=\"{}\"", bstr::BStr::new(truncated)).is_ok() { + if write!(w, "inline; filename=\"{}\"", bstr::BStr::new(truncated)) + .is_ok() + { let written = 1024 - w.len(); &filename_buf[..written] } else { @@ -3969,7 +4069,7 @@ where } if self.flags.needs_content_length() { - resp.write_header_int(b"content-length", size as u64); + resp.write_header_int(b"content-length", sendfile.remain as u64); resp.mark_wrote_content_length_header(); self.flags.set_needs_content_length(false); } @@ -4157,51 +4257,8 @@ where .saturating_add(chunk.len()); this.request_body_streamed_len.set(streamed_len); if streamed_len > server.config().max_request_body_size { - this.resp - .get() - .expect("infallible: resp bound") - .clear_on_data(); - this.flags.set_is_waiting_for_request_body(false); - this.resume_request_body_socket(); - let _exit = vm.enter_event_loop_scope(); - - // Body first (a tee branch after `req.clone()`), before endRequestStreaming()'s ConnectionClosed. - if let Some(body) = this.request_body_mut() { - if matches!(body, Body::Value::Locked(_)) { - let _ = body.to_error_instance( - Body::ValueError::Message(BunString::static_( - "Request body exceeded maxRequestBodySize", - )), - global_this, - ); - } - } - - // Release the strong stream ref like the `last` arm does, then - // error the stream so a pending or future read rejects instead - // of hanging forever. - let _strong = this - .request_body_readable_stream_ref - .replace(readable_stream::Strong::default()); - - readable.value.ensure_still_alive(); - if let Some(bytes) = readable.ptr.bytes() { - let source = bytes.parent_const(); - source.producer.set(WebCore::streams::SourceHandle::None); - // False unless `to_error_instance` above reached this same stream through the body. - if !bytes.has_received_last_chunk.get() { - let mut err = Body::ValueError::Message(BunString::static_( - "Request body exceeded maxRequestBodySize", - )); - bytes.on_data(WebCore::streams::Result::Err( - err.to_stream_error(global_this), - )); - err.reset(); - } - } - - this.end_with_payload_too_large(); + this.abort(jsc::CommonAbortReason::RequestBodyTooLarge); return; } @@ -4272,27 +4329,8 @@ where .saturating_add(chunk.len()) > server.config().max_request_body_size { - this.request_body_buf.set(Vec::new()); - this.resp - .get() - .expect("infallible: resp bound") - .clear_on_data(); - this.flags.set_is_waiting_for_request_body(false); - let _exit = vm.enter_event_loop_scope(); - // Reject the pending body first so endRequestStreaming() - // below (via this.endWithoutBody) doesn't substitute a - // generic ConnectionClosed. toErrorInstance handles - // .Locked itself (rejects the promise, deinits the - // readable, calls onReceiveValue). - let _ = body.to_error_instance( - Body::ValueError::Message(BunString::static_( - "Request body exceeded maxRequestBodySize", - )), - global_this, - ); - - this.end_with_payload_too_large(); + this.abort(jsc::CommonAbortReason::RequestBodyTooLarge); return; } @@ -4318,6 +4356,8 @@ where let _ = Body::Value::resolve(&mut old, body, &global_this.js_thread(context), None); // TODO: properly propagate exception upwards } + // A complete body belongs to the `Request` alone, so ending the request can't fail it. + this.request_body_take_unref(); return; } @@ -4337,9 +4377,12 @@ where }); // Pre-stream backpressure; resumed by `on_stream_drained` / `on_start_buffering`. - if !this.flags.request_body_buffer_all() - && this.request_body_buf.get().len() >= REQUEST_BODY_HIGH_WATER_MARK - { + let mark = if MUX { + MUX_REQUEST_BODY_HIGH_WATER_MARK + } else { + REQUEST_BODY_HIGH_WATER_MARK + }; + if !this.flags.request_body_buffer_all() && this.request_body_buf.get().len() >= mark { this.pause_request_body_socket(); } } @@ -4542,6 +4585,8 @@ const MUX_REQUEST_BODY_PREALLOCATE_LENGTH: usize = 64 * 1024; /// Pause socket reads at this many unconsumed request-body bytes (two 512 KB uWS recv buffers). const REQUEST_BODY_HIGH_WATER_MARK: usize = 1024 * 1024; +/// For an HTTP/2 or HTTP/3 body no handler has asked for yet. Under half of the HTTP/3 stream window, so the pause lands before lsquic raises the limit. +const MUX_REQUEST_BODY_HIGH_WATER_MARK: usize = 256 * 1024; // ─── per-monomorphization C-ABI exports ────────────────────────────────────── // The exported symbol name is "Bun__HTTPRequestContext" + (debug ? "Debug" : "") @@ -4742,6 +4787,8 @@ bitflags::bitflags! { const REQUEST_BODY_PAUSED = 1 << 16; /// `on_start_buffering` fired (`.text()` etc.); skip pre-stream backpressure. const REQUEST_BODY_BUFFER_ALL = 1 << 17; + /// The body outgrew `maxRequestBodySize`, so its reads reject with that error. + const REQUEST_BODY_REFUSED = 1 << 18; } } @@ -4823,6 +4870,11 @@ impl Flags { set_request_body_buffer_all, REQUEST_BODY_BUFFER_ALL ); + flag_accessor!( + request_body_refused, + set_request_body_refused, + REQUEST_BODY_REFUSED + ); #[inline] pub(crate) fn is_web_browser_navigation(&self) -> bool { @@ -4850,6 +4902,11 @@ impl Flags { } } +/// RFC 9110 5.6.4 `qdtext`: a byte a quoted-string can carry without a backslash escape. +fn is_qdtext(byte: u8) -> bool { + matches!(byte, b'\t' | b' ' | 0x21 | 0x23..=0x5B | 0x5D..=0x7E | 0x80..=0xFF) +} + fn get_content_type(headers: Option<&mut FetchHeaders>, blob: &AnyBlob) -> (MimeType, bool, bool) { let mut needs_content_type = true; let mut content_type_needs_free = false; diff --git a/src/runtime/server/ServerConfig.rs b/src/runtime/server/ServerConfig.rs index 27575cf6cd4e..cec29a81fa97 100644 --- a/src/runtime/server/ServerConfig.rs +++ b/src/runtime/server/ServerConfig.rs @@ -59,6 +59,8 @@ pub(crate) struct ServerConfig { pub(crate) websocket: Option, pub(crate) reuse_port: bool, + /// node:http only. Listen on this already-bound descriptor instead of binding `address`. + pub(crate) listen_fd: Option, pub(crate) id: Box<[u8]>, pub(crate) allow_hot: bool, pub(crate) ipv6_only: bool, @@ -94,6 +96,7 @@ impl Default for ServerConfig { is_node_http_server: false, websocket: None, reuse_port: false, + listen_fd: None, id: Box::default(), allow_hot: true, ipv6_only: false, @@ -147,7 +150,7 @@ pub(crate) enum DevelopmentOption { } impl DevelopmentOption { - fn is_hmr_enabled(self) -> bool { + pub(crate) fn is_hmr_enabled(self) -> bool { self == DevelopmentOption::Development } @@ -175,6 +178,24 @@ impl ServerConfig { cost } + + /// `base_uri` is formatted before the listener binds, so `bound_port` replaces a port of `0`. + pub(crate) fn base_url_string_for_joining(&self, bound_port: Option) -> Box<[u8]> { + let base = bun_core::trim(&self.base_uri, b"/"); + let port = URL::parse(base).port; + if let Some(bound_port) = bound_port + && port == b"0" + && let Some([start, len]) = bun_alloc::range_of_slice_in_buffer(port, base) + { + let (start, len) = (start as usize, len as usize); + let mut buf: Vec = Vec::with_capacity(base.len() + 4); + buf.extend_from_slice(&base[..start]); + let _ = write!(&mut buf, "{bound_port}"); + buf.extend_from_slice(&base[start + len..]); + return buf.into_boxed_slice(); + } + Box::from(base) + } } // We need to be able to apply the route to multiple Apps even when there is only one RouteList. @@ -296,6 +317,7 @@ impl ServerConfig { is_node_http_server: self.is_node_http_server, websocket: self.websocket.take(), reuse_port: self.reuse_port, + listen_fd: self.listen_fd, id: core::mem::take(&mut self.id), allow_hot: self.allow_hot, ipv6_only: self.ipv6_only, @@ -489,6 +511,10 @@ impl ServerConfig { let mut arraylist: Vec = Vec::new(); let _ = arraylist.write_all(b"[http]-"); + if let Some(fd) = self.listen_fd { + let _ = write!(&mut arraylist, "fd:{fd}"); + return arraylist; + } match &self.address { Address::Tcp { port, hostname } => { if let Some(host) = hostname { @@ -534,14 +560,12 @@ fn validate_route_name(global: &JSGlobalObject, path: &[u8]) -> JsResult<()> { // For now, we don't support params that start with a number. // Mostly because it makes the params object more complicated to implement and it's easier to cut scope this way for now. - let mut remaining = path; let mut duped_route_names: StringHashMap<()> = StringHashMap::new(); - while let Some(index) = strings::index_of_char(remaining, b':') { - remaining = &remaining[(index + 1) as usize..]; - let end = strings::index_of_char(remaining, b'/') - .map(|i| i as usize) - .unwrap_or(remaining.len()); - let route_name = &remaining[..end]; + for segment in strings::split(&path[1..], b"/") { + // uWS::HttpRouter only captures a segment that starts with ':'. + let Some(route_name) = segment.strip_prefix(b":") else { + continue; + }; if !route_name.is_empty() && route_name[0].is_ascii_digit() { return Err(global.throw_todo( b"Route parameter names cannot start with a number.\n\n\ @@ -556,12 +580,26 @@ fn validate_route_name(global: &JSGlobalObject, path: &[u8]) -> JsResult<()> { If you run into this, please file an issue and we will add support for it.", )); } - - remaining = &remaining[end..]; } Ok(()) } +/// Returns true if JavaScript set `process.env.NODE_ENV` or `BUN_ENV` to "production". `env_loader()` only has the values from startup. +fn process_env_is_production(global: &JSGlobalObject) -> JsResult { + let Some(process_env) = global.process_env_if_created() else { + return Ok(false); + }; + for key in ["NODE_ENV", "BUN_ENV"] { + if let Some(value) = process_env.get(global, key)? + && value.is_string() + && value.to_js_string_view(global)?.eq_ascii(b"production") + { + return Ok(true); + } + } + Ok(false) +} + fn get_routes_object(global: &JSGlobalObject, arg: JSValue) -> JsResult> { for key in ["routes", "static"] { if let Some(routes) = arg.get(global, key)? { @@ -576,6 +614,82 @@ fn get_routes_object(global: &JSGlobalObject, arg: JSValue) -> JsResult crate::bake::UserOptions { + use bun_options_types::schema::api::DotEnvBehavior; + + let mut user_options = crate::bake::UserOptions { + arena: bun_alloc::Arena::new(), + root: bun_core::ZBox::from_bytes(bun_paths::fs::FileSystem::instance().top_level_dir()), + framework, + bundler_options, + }; + + let o = &vm.transpiler.options.transform_options; + + match o.serve_env_behavior { + DotEnvBehavior::prefix => { + user_options + .bundler_options + .client + .env_prefix + .clone_from(&o.serve_env_prefix); + user_options.bundler_options.client.env = DotEnvBehavior::prefix; + } + DotEnvBehavior::load_all => { + user_options.bundler_options.client.env = DotEnvBehavior::load_all; + } + DotEnvBehavior::disable => { + user_options.bundler_options.client.env = DotEnvBehavior::disable; + } + _ => {} + } + + // `[serve.static] define` comes last so it overrides a top-level define with the same key. + let mut define = o.define.clone().unwrap_or_default(); + if let Some(serve_define) = &o.serve_define { + define.keys.extend_from_slice(&serve_define.keys); + define.values.extend_from_slice(&serve_define.values); + } + user_options.bundler_options.client.define = define.clone(); + user_options.bundler_options.server.define = define.clone(); + user_options.bundler_options.ssr.define = define; + + user_options + } + + /// A reload can't change the server's mode, so an HTML route gets DevServer options even if `new_config` has `development: false`. + pub(crate) fn take_dev_server_options_from( + &mut self, + new_config: &mut ServerConfig, + global: &JSGlobalObject, + ) -> bool { + self.bake = new_config.bake.take(); + if self.bake.is_none() + && new_config + .static_routes + .iter() + .any(|entry| matches!(entry.route, AnyRoute::Html(_))) + { + let resolver = &mut global.bun_vm().as_mut().transpiler.resolver; + let framework = crate::bake::Framework::auto(resolver, Vec::new()); + self.bake = Some(Self::dev_server_options( + global.bun_vm(), + framework, + crate::bake::SplitBundlerOptions::default(), + )); + } + self.bake.is_some() + } + + pub(crate) fn drop_dev_server_options(&mut self) { + self.bake = None; + } + pub(crate) fn from_js( global: &JSGlobalObject, arguments: &mut bun_jsc::call_frame::ArgumentsSlice, @@ -610,7 +724,7 @@ impl ServerConfig { args.development = DevelopmentOption::Production; } - if arguments.vm.transpiler.options.production { + if arguments.vm.transpiler.options.production || process_env_is_production(global)? { args.development = DevelopmentOption::Production; } @@ -896,8 +1010,6 @@ impl ServerConfig { || bake_pages { if args.development.is_hmr_enabled() { - use bun_options_types::schema::api::DotEnvBehavior; - // SAFETY: `bun_vm()` returns the live VM for this global; // we need `&mut Resolver` for `Framework::auto`. let mut bundler_options = crate::bake::SplitBundlerOptions::default(); @@ -924,42 +1036,7 @@ impl ServerConfig { ), }; - let mut user_options = crate::bake::UserOptions { - arena: bun_alloc::Arena::new(), - root: bun_core::ZBox::from_bytes( - bun_paths::fs::FileSystem::instance().top_level_dir(), - ), - framework, - bundler_options, - }; - - let o = &vm.transpiler.options.transform_options; - - match o.serve_env_behavior { - DotEnvBehavior::prefix => { - user_options - .bundler_options - .client - .env_prefix - .clone_from(&o.serve_env_prefix); - user_options.bundler_options.client.env = DotEnvBehavior::prefix; - } - DotEnvBehavior::load_all => { - user_options.bundler_options.client.env = DotEnvBehavior::load_all; - } - DotEnvBehavior::disable => { - user_options.bundler_options.client.env = DotEnvBehavior::disable; - } - _ => {} - } - - if let Some(define) = &o.serve_define { - user_options.bundler_options.client.define = define.clone(); - user_options.bundler_options.server.define = define.clone(); - user_options.bundler_options.ssr.define = define.clone(); - } - - args.bake = Some(user_options); + args.bake = Some(Self::dev_server_options(vm, framework, bundler_options)); } else { if !init_ctx.framework_router_list.is_empty() { return Err(global.throw_invalid_arguments(format_args!( @@ -1155,6 +1232,16 @@ impl ServerConfig { } args.on_node_http_request = on_request_; args.is_node_http_server = true; + + if let Some(fd) = arg.get(global, "fd")? { + let number = fd.get_number().unwrap_or(-1.0); + if number.fract() != 0.0 || !(0.0..=f64::from(i32::MAX)).contains(&number) { + return Err(global.throw_invalid_arguments(format_args!( + "Expected fd to be a non-negative integer" + ))); + } + args.listen_fd = Some(number as i32); + } } if let Some(on_request_) = arg.get_truthy(global, "fetch")? { diff --git a/src/runtime/server/StaticRoute.rs b/src/runtime/server/StaticRoute.rs index 70fcc0a2b8bf..86df53959014 100644 --- a/src/runtime/server/StaticRoute.rs +++ b/src/runtime/server/StaticRoute.rs @@ -6,7 +6,7 @@ use core::mem::size_of; use bun_http::headers::api::StringPointer; use bun_http::headers::append_etag; -use bun_http::{Headers, Method}; +use bun_http::{Headers, Method, header_has_token}; use bun_http_types::ETag; use bun_ptr::{RefPtr, ThisPtr}; @@ -18,7 +18,7 @@ use crate::server::jsc::{JSGlobalObject, JSValue, JsResult}; use crate::server::{AnyServer, HTTPStatusText, write_status}; use crate::webcore::body::Value as BodyValue; use crate::webcore::headers_ref::any_blob_content_type; -use crate::webcore::{AnyBlob, FetchHeaders, InternalBlob, Response}; +use crate::webcore::{AnyBlob, Blob, FetchHeaders, InternalBlob, Response}; #[derive(bun_ptr::CellRefCounted)] pub(crate) struct StaticRoute { @@ -35,6 +35,8 @@ pub(crate) struct StaticRoute { pub(crate) blob: AnyBlob, pub(crate) cached_blob_size: u64, pub(crate) has_date: bool, + pub(crate) has_connection: bool, + has_connection_close: bool, pub(crate) headers: Headers, } @@ -68,6 +70,10 @@ impl StaticRoute { pending_responses: Cell::new(0), cached_blob_size: blob.size(), has_date: headers.get(b"date").is_some(), + has_connection: headers.get(b"connection").is_some(), + has_connection_close: headers + .get(b"connection") + .is_some_and(|value| header_has_token(value, b"close")), blob, headers, server: Cell::new(server), @@ -150,6 +156,8 @@ impl StaticRoute { blob: AnyBlob::Blob(duped), cached_blob_size: self.cached_blob_size, has_date: self.has_date, + has_connection: self.has_connection, + has_connection_close: self.has_connection_close, headers: self.headers.clone(), server: Cell::new(self.server.get()), status_code: self.status_code, @@ -228,6 +236,9 @@ impl StaticRoute { if let Some(h) = response.get_init_headers_mut() { h.fast_remove(HTTPHeaderName::TransferEncoding); h.fast_remove(HTTPHeaderName::ContentLength); + if response.body_decoded() { + h.fast_remove(HTTPHeaderName::ContentEncoding); + } } // Consuming the body left a plain `Blob` behind, which no longer implies @@ -250,6 +261,22 @@ impl StaticRoute { )?)); } + // `Bun.file()` of a file embedded in a compiled executable is an in-memory Blob. Serve it like `new Response(blob)`. + if let Some(blob) = argument.as_class_ref::() + && !blob.needs_to_read_file() + && !blob.is_s3() + { + let blob = blob.dupe(); + blob.global_this + .set(std::ptr::from_ref::(global_this)); + return Ok(Some(Self::init_from_any_blob_with_headers( + global_this, + AnyBlob::Blob(blob), + None, + 200, + )?)); + } + Ok(None) } @@ -393,9 +420,8 @@ impl StaticRoute { // Content-Length (uWS suppresses it for 1xx/204) desyncs keep-alive. if HTTPStatusText::is_null_body(self.status_code) { // 304: try_end would write Content-Length: 0 (RFC 9110 §8.6 forbids - // any but the 200's length); write_mark keeps Date. + // any but the 200's length). if self.status_code == 304 { - resp.write_mark(); resp.end_without_body(resp.should_close_connection()); *did_finish = true; } else { @@ -447,6 +473,9 @@ impl StaticRoute { if self.has_date { resp.mark_wrote_date_header(); } + if self.has_connection { + resp.mark_wrote_connection_header(self.has_connection_close); + } let entries = self.headers.entries.slice(); let names: &[StringPointer] = entries.items_name(); let values: &[StringPointer] = entries.items_value(); diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index c4c759b62293..13453fa95ec3 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -204,6 +204,8 @@ bitflags::bitflags! { pub struct ServerFlags: u8 { const DEINIT_SCHEDULED = 1 << 0; const TERMINATED = 1 << 1; + /// Set by `server.unref()`, cleared by `server.ref()`. + const UNREFED_BY_USER = 1 << 2; } } @@ -270,15 +272,17 @@ pub(crate) struct NewServer { /// ([`NewServer::is_drained`]); for Bun.serve it also holds the /// graceful-stop promise open ([`NewServer::is_closed`]). pub(crate) active_connection_count: core::cell::Cell, - /// The node:http tunnels in `active_connection_count` that are at read EOF and have nothing left to send. + /// The connections in `active_connection_count` that don't hold the loop: node:http tunnels that don't read (stopped or at EOF) with nothing left to send, and connections with a parked request. pub(crate) idle_tunnel_count: core::cell::Cell, + /// The node:http requests in `pending_requests` that are parked (`NodeHTTPResponse::update_parked`). + pub(crate) parked_request_count: core::cell::Cell, /// Live `ServerWebSocket` count. Lives on the server (not the websocket /// context) so a reload's context swap cannot reset it, and sits in a /// `Cell` because the open/close accounting arrives through shared /// `AnyServer` handles on the JS thread. pub(crate) active_websocket_count: core::cell::Cell, /// Set across [`NewServer::deinit_if_we_can`] and the synchronous - /// `app.close()` drain in `stop_listening`; lets a nested call (reached + /// `app.close()` drains in `stop_listening` and `deinit`; lets a nested call (reached /// via a callback the body fires) early-return instead of re-running the /// downgrade/teardown while the outer frame still holds `&mut self`. deinit_running: core::cell::Cell, @@ -288,12 +292,12 @@ pub(crate) struct NewServer { /// handler runs (its `AbortSignal`) belongs to it. pub(crate) context: core::cell::Cell, pub(crate) request_pool: - *mut request_context::RequestContextStackAllocator, + *const request_context::RequestContextStackAllocator, /// Null until `listen()` creates an HTTP/2 or HTTP/3 app. Kept as a raw /// nullable pointer rather than a conditional field so the struct stays /// uniform across monomorphizations. pub(crate) mux_request_pool: - *mut request_context::RequestContextStackAllocator, + *const request_context::RequestContextStackAllocator, /// Authoritative GC root for the `server.stop()` promise. Lazily filled by /// `get_all_closed_promise`; read in `deinit_if_we_can` (which can run /// after the wrapper is collected, so a wrapper-traced slot would not @@ -509,7 +513,7 @@ impl NewServer { /// uWS filter: `+2` at TCP accept (before any TLS handshake), `-2` on /// `HttpContext::onClose` / `HttpResponse::upgrade()` — see /// `AsyncSocketData::filteredAccept`. Feeds [`Self::active_connection_count`]. - /// `-3` / `+3`: a node:http tunnel becomes idle / has bytes to send again. `-4`: it closes idle. + /// `-3`: a node:http tunnel becomes idle. `+3` / `+4`: it has bytes to send again / that, or it reads again. `-4`: it closes idle. extern "C" fn on_connection_filter( _socket: *mut uws_sys::us_socket_t, opened: i32, @@ -528,11 +532,14 @@ impl NewServer { this.note_connection_opened(); return; } - 3 => { + 3 | 4 => { this.note_tunnel_idle(false); - // With a listener open, the loop ref is the user's to drop (`server.unref()`). - if !this.has_listener() { - // SAFETY: `this` is not used again, and a tunnel write never runs inside `app.close()`. + // With a listener open, the loop ref is the user's to drop (`server.unref()`). After that, a tunnel's reads don't take it back. + if !this.has_listener() + && !this.deinit_running.get() + && (opened == 3 || !this.flags.contains(ServerFlags::UNREFED_BY_USER)) + { + // SAFETY: `this` is not used again, and `deinit_running` rules out an outer `&mut self` (the `app.close()` drain). unsafe { &mut *user_data.cast::() }.ref_(); } return; @@ -558,6 +565,20 @@ impl NewServer { } } + /// quic.c closed the HTTP/3 UDP socket and the last QUIC connection is gone ([`Self::has_open_h3_transport`]). + extern "C" fn on_h3_released(user_data: *mut c_void) { + // SAFETY: `user_data` is the `*mut Self` registered in `listen()`, and quic.c drops the + // hook before it frees the app's context, so the server is alive. Only `&Self` is formed: + // the H3 close calls in `stop_listening` can reach this under its `&mut self`. + if unsafe { &*user_data.cast::() }.deinit_running.get() { + return; + } + // SAFETY: `stop_listening` holds `deinit_running` across those calls, so no `&mut self` + // is live. This can run inside lsquic's `process_conns`: `deinit_if_we_can` reads cells + // and queues tasks, it doesn't enter the engine. + unsafe { &mut *user_data.cast::() }.deinit_if_we_can(); + } + /// Build the server's base URL string (`http(s)://host:port/`, or a /// `unix://`/abstract-socket URL) from the configured listen address. /// Errors only on allocation failure. @@ -620,7 +641,7 @@ impl NewServer { /// Returns the wrapper while it is alive (`Strong` or `Weak`) and its VM may still run script, /// else `None`. It stays `Strong` — the WriteBarrier root of the handler shadows — until the - /// server is drained (no listener, request, HTTP/1 connection or websocket left; connections count + /// server is drained (no listener, request, connection or websocket left; TCP connections count /// from accept), so nothing dispatches through a wrapper the GC may have collected. A VM whose /// script gate has closed (a worker that called `process.exit()` or was asked to terminate, still /// draining the current loop tick before its stop phase closes the listener) must not have a @@ -630,9 +651,8 @@ impl NewServer { if !self.vm().script_allowed() { return None; } - // HTTP/3 connections are not counted yet, so a stream on one can still arrive after the drain. debug_assert!( - self.h3_app.is_some() || self.js_value.is_strong() || self.js_value.try_get().is_none(), + self.js_value.is_strong() || self.js_value.try_get().is_none(), "a socket outlived the server's connection accounting" ); self.js_value.try_get() @@ -810,8 +830,8 @@ impl NewServer { ); } - // SAFETY: `request_pool` points at a process-static (or - // server-owned) `HiveArray::Fallback`; valid for the server's lifetime. + // SAFETY: `request_pool` points at the VM's `HiveArray::Fallback` + // (see `RequestPools`); valid for the server's lifetime. // The `HiveSlot` token is leaked deliberately: the slot is recycled by // `RequestContext::deinit` via `put_raw`, never via `HiveSlot::Drop`. let ctx_slot = std::mem::ManuallyDrop::new(unsafe { (*server.request_pool).claim() }) @@ -1609,7 +1629,7 @@ impl NewServer { fn note_tunnel_idle(&self, idle: bool) { let count = self.idle_tunnel_count.get(); - // `AsyncSocketData::filteredIdleTunnel` pairs every -3 with one +3 or -4. + // `AsyncSocketData::filteredIdleTunnel` pairs every -3 with one +3, +4 or -4. debug_assert!(idle || count > 0); self.idle_tunnel_count.set(if idle { count + 1 @@ -1618,7 +1638,13 @@ impl NewServer { }); } - /// An idle tunnel is open but, like a libuv handle at EOF with no write pending, does not hold the loop. + pub(crate) fn note_request_parked(&self, parked: bool) { + let count = self.parked_request_count.get(); + self.parked_request_count + .set(if parked { count + 1 } else { count - 1 }); + } + + /// An idle tunnel is open but, like a libuv handle that doesn't read and has no write pending, does not hold the loop. fn has_loop_holding_connections(&self) -> bool { self.active_connection_count.get() > self.idle_tunnel_count.get() } @@ -1645,19 +1671,27 @@ impl NewServer { self.active_sockets_count() > 0 } - /// What the `stop()` promise (node:http: 'close') and the loop unref wait for. A connection counts from accept, before its TLS handshake completes. + /// What the `stop()` promise (node:http: 'close') and the loop unref wait for. A connection counts from accept, before its TLS handshake completes. A parked request doesn't count. pub(crate) fn is_closed(&self) -> bool { - self.pending_requests.get() == 0 + self.pending_requests.get() == self.parked_request_count.get() as usize && !self.has_listener() && !self.has_active_web_sockets() && !self.has_loop_holding_connections() + && !self.has_open_h3_transport() + } + + /// Returns true if the HTTP/3 app still has its UDP socket or a QUIC connection. [`Self::on_h3_released`] runs when both are gone. + fn has_open_h3_transport(&self) -> bool { + Self::HAS_H3 + && self + .h3_app + // S008: `h3::App` is an `opaque_ffi!` ZST — safe deref. + .is_some_and(|h3a| !bun_opaque::opaque_deref_mut(h3a).is_released()) } - /// Nothing is left that can dispatch a handler: [`Self::is_closed`] and - /// no open HTTP connection. The wrapper may only go `Weak` from here on - /// (see [`Self::js_value_for_dispatch`]). + /// Nothing is left that can dispatch a handler: [`Self::is_closed`], no parked request and no open HTTP connection. The wrapper may only go `Weak` from here on (see [`Self::js_value_for_dispatch`]). pub(crate) fn is_drained(&self) -> bool { - self.is_closed() && !self.has_active_connections() + self.is_closed() && self.pending_requests.get() == 0 && !self.has_active_connections() } pub(crate) fn has_listener(&self) -> bool { @@ -1721,9 +1755,11 @@ impl NewServer { if Self::HAS_H3 { let h3l = self.h3_listener.take(); + // Both close calls can reach `on_h3_released`. + let was_running = self.deinit_running.replace(true); // Graceful: GOAWAY + drain via the still-open UDP socket; the // engine rejects new conns and the timer keeps in-flight streams - // progressing until deinit. Abrupt: close the fd now. + // progressing until the last connection is gone. Abrupt: close the fd now. if !abrupt { if let (Some(h3l), Some(h3a)) = (h3l, self.h3_app) { // S008: `h3::App` is an `opaque_ffi!` ZST — safe deref. @@ -1734,12 +1770,16 @@ impl NewServer { // S008: `h3::ListenSocket` is an `opaque_ffi!` ZST — safe deref. bun_opaque::opaque_deref_mut(h3l).close(); } + self.deinit_running.set(was_running); } let already_terminated = self.flags.contains(ServerFlags::TERMINATED); let Some(listener) = self.listener.take() else { if Self::HAS_H3 && self.h3_app.is_some() { - self.unref(); + // As in the TCP arm below: a graceful stop keeps the ref while QUIC connections drain. + if abrupt || self.is_closed() { + self.unref(); + } self.notify_inspector_server_stopped(); if abrupt { self.flags.insert(ServerFlags::TERMINATED); @@ -1756,10 +1796,10 @@ impl NewServer { } self.flags.insert(ServerFlags::TERMINATED); if let Some(app) = self.app { - self.deinit_running.set(true); + let was_running = self.deinit_running.replace(true); // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. bun_opaque::opaque_deref_mut(app).close(); - self.deinit_running.set(false); + self.deinit_running.set(was_running); } if let Some(ws) = self.config.websocket.as_mut() { ws.handler.server = None; @@ -1787,13 +1827,14 @@ impl NewServer { self.notify_inspector_server_stopped(); match &self.config.address { - server_config::Address::Unix(path) => { + // The socket file of an inherited descriptor belongs to the process that bound it. + server_config::Address::Unix(path) if self.config.listen_fd.is_none() => { let bytes = path.as_bytes(); if !bytes.is_empty() && bytes[0] != 0 { let _ = bun_sys::unlink(path.as_zstr()); } } - server_config::Address::Tcp { .. } => {} + server_config::Address::Unix(_) | server_config::Address::Tcp { .. } => {} } if !abrupt { @@ -1813,10 +1854,10 @@ impl NewServer { // reaps it — verified against Node v26. if !self.config.is_node_http_server { if let Some(app) = self.app { - self.deinit_running.set(true); + let was_running = self.deinit_running.replace(true); // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. let _closed = bun_opaque::opaque_deref_mut(app).close_idle_connections(true); - self.deinit_running.set(false); + self.deinit_running.set(was_running); } } } else if !self.flags.contains(ServerFlags::TERMINATED) { @@ -1830,10 +1871,10 @@ impl NewServer { // while this frame still holds `&mut self`. Hold the re-entrance // guard across the drain so that nested call early-returns — // `stop()` runs `deinit_if_we_can` itself right after this returns. - self.deinit_running.set(true); + let was_running = self.deinit_running.replace(true); // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. bun_opaque::opaque_deref_mut(self.app.unwrap()).close(); - self.deinit_running.set(false); + self.deinit_running.set(was_running); // Only clear after the drain — `on_close` defers reach // `on_websocket_closed` through `handler.server`, so wiping it // earlier would strand the live-socket count and the idle pass @@ -2004,11 +2045,19 @@ impl NewServer { } } - pub(crate) fn on_listen(&mut self, socket: Option<*mut uws_sys::app::ListenSocket>) { - let Some(socket) = socket else { - return self.on_listen_failed(); + pub(crate) fn on_listen( + &mut self, + listened: Result<*mut uws_sys::app::ListenSocket, uws_sys::app::ListenError>, + ) { + let socket = match listened { + Ok(socket) => socket, + Err(failure) => return self.on_listen_failed(failure), }; self.listener = Some(socket); + // `None` for a unix socket. + if let Some(port) = bun_opaque::opaque_deref_mut(socket).get_local_port() { + self.base_url_string_for_joining = self.config.base_url_string_for_joining(Some(port)); + } // SAFETY: `vm_mut()` is the process-static `*mut VirtualMachine` (non-null // for the server's lifetime); single-threaded JS context. unsafe { (*self.vm_mut()).event_loop_handle = Some(bun_io::Loop::get()) }; @@ -2021,89 +2070,58 @@ impl NewServer { } } - /// Build the bind/listen failure as a `SystemError` (so JS sees - /// `err.code`/`err.syscall`) and `globalThis.throwValue` it. The BoringSSL - /// error-stack drain is still TODO; the EADDRINUSE/ - /// EACCES paths below cover the node:http `server.listen` error contract. #[cold] - pub(crate) fn on_listen_failed(&mut self) { + pub(crate) fn on_listen_failed(&mut self, failure: uws_sys::app::ListenError) { self.listener = None; let global = self.global_this(); - let error_instance = match &self.config.address { - server_config::Address::Tcp { - port, - hostname: _hostname, - } => { - // Rust's `target_os = "linux"` excludes - // Android, so match both explicitly. - #[cfg(any(target_os = "linux", target_os = "android"))] - { - let errno = bun_sys::last_error(); - if errno == bun_sys::E::EACCES { - let host = _hostname - .as_ref() - .map(|h| h.as_bytes()) - .unwrap_or(b"0.0.0.0"); - let err = jsc::SystemError { - message: bun_core::String::create_format(format_args!( - "permission denied {}:{}", - bstr::BStr::new(host), - port - )), - code: bun_core::String::static_("EACCES"), - syscall: bun_core::String::static_("listen"), - ..Default::default() - }; - let _ = global.throw_value(err.to_error_instance(global)); - return; - } - // e.g. ENOSPC from epoll_ctl(EPOLL_CTL_ADD). Linux-only because - // on other platforms errno is not reliably preserved through - // the C++/callback chain to here; see PR #30364. - if errno != bun_sys::E::SUCCESS && errno != bun_sys::E::EADDRINUSE { - let err = jsc::SystemError::from( - bun_sys::Error::from_code(errno, bun_sys::Tag::listen) - .to_system_error(), - ); - let _ = global.throw_value(err.to_error_instance(global)); - return; - } - } - jsc::SystemError { - message: bun_core::String::create_format(format_args!( + let hostname = match &self.config.address { + server_config::Address::Tcp { hostname, .. } => hostname.as_ref().map(|h| h.as_bytes()), + server_config::Address::Unix(_) => None, + }; + if let Some(err) = + crate::dns_jsc::cares_jsc::getaddrinfo_error(failure.dns_error, hostname.unwrap_or(b"")) + { + let _ = global.throw_value(err.to_error_instance(global)); + return; + } + + let errno = uws_sys::SocketGroup::listen_errno(failure.error) + .unwrap_or(bun_sys::SystemErrno::EUNKNOWN); + let err = match &self.config.address { + server_config::Address::Tcp { port, .. } => { + // Winsock has no privileged ports: WSAEACCES means the port is held or in an excluded range. + #[cfg(windows)] + let errno = if errno == bun_sys::SystemErrno::EACCES { + bun_sys::SystemErrno::EADDRINUSE + } else { + errno + }; + let mut err = jsc::SystemError::from( + bun_sys::Error::new(errno, bun_sys::Tag::listen).to_system_error(), + ); + if errno == bun_sys::SystemErrno::EADDRINUSE { + err.message = bun_core::String::create_format(format_args!( "Failed to start server. Is port {} in use?", port - )), - code: bun_core::String::static_("EADDRINUSE"), - syscall: bun_core::String::static_("listen"), - ..Default::default() - } - .to_error_instance(global) - } - server_config::Address::Unix(unix) => { - let unix = unix.as_bytes(); - match bun_sys::last_error() { - bun_sys::E::SUCCESS => jsc::SystemError { - message: bun_core::String::create_format(format_args!( - "Failed to listen on unix socket {}", - bun_core::fmt::QuotedFormatter { text: unix } - )), - code: bun_core::String::static_("EADDRINUSE"), - syscall: bun_core::String::static_("listen"), - ..Default::default() - } - .to_error_instance(global), - e => jsc::SystemError::from( - bun_sys::Error::from_code(e, bun_sys::Tag::listen) - .with_path(unix) - .to_system_error(), - ) - .to_error_instance(global), + )); + } else if errno == bun_sys::SystemErrno::EACCES { + err.message = bun_core::String::create_format(format_args!( + "permission denied {}:{}", + bstr::BStr::new(hostname.unwrap_or(b"0.0.0.0")), + port + )); } + err } + server_config::Address::Unix(unix) => jsc::SystemError::from( + bun_sys::Error::new(errno, bun_sys::Tag::listen) + .with_path(unix.as_bytes()) + .to_system_error(), + ), }; + let error_instance = err.to_error_instance(global); error_instance.ensure_still_alive(); let _ = global.throw_value(error_instance); } @@ -2120,6 +2138,8 @@ impl NewServer { self.h3_alt_svc = format!("h3=\":{port}\"; ma=86400") .into_bytes() .into_boxed_slice(); + // With `http1: false` this is the only listener. + self.base_url_string_for_joining = self.config.base_url_string_for_joining(Some(port)); } // An `http3_server` feature counter is not (yet) declared in // `bun_analytics`. No-op until it is. @@ -2135,6 +2155,17 @@ impl NewServer { /// must not be used after this returns. pub(super) fn deinit(this: *mut Self) { httplog!("deinit"); + // SAFETY: `this` is live and uniquely owned. `NewApp::destroy` closes nothing, and close + // callbacks re-derive `&Self` from `this`, so close before the Box is reclaimed. + unsafe { + if !(*this).flags.contains(ServerFlags::TERMINATED) + && let Some(app) = (*this).app + { + (*this).flags.insert(ServerFlags::TERMINATED); + (*this).deinit_running.set(true); + bun_opaque::opaque_deref_mut(app).close(); + } + } // SAFETY: paired with heap::alloc in `init()`; `this` is uniquely // owned here, so reclaim the Box up front and let its `&mut` drive // the teardown (dropped — and freed — at scope exit). @@ -2181,10 +2212,7 @@ impl NewServer { where Self: ServerPools, { - let base_url: Box<[u8]> = bun_core::trim(&config.base_uri, b"/") - .to_vec() - .into_boxed_slice(); - // `base_url` drops on Err automatically. + let base_url = config.base_url_string_for_joining(None); let server = bun_core::heap::into_raw(Box::new(Self { global_this: std::ptr::from_ref(global), @@ -2206,6 +2234,7 @@ impl NewServer { pending_requests: core::cell::Cell::new(0), active_connection_count: core::cell::Cell::new(0), idle_tunnel_count: core::cell::Cell::new(0), + parked_request_count: core::cell::Cell::new(0), active_websocket_count: core::cell::Cell::new(0), deinit_running: core::cell::Cell::new(false), abort_handle: jsc::AbortHandle::for_owner::(), @@ -2213,7 +2242,7 @@ impl NewServer { request_pool: >::request_pool(), // Servers that enable neither HTTP/2 nor HTTP/3 never allocate the // ~816 KB mux pool; `listen()` materializes it on demand. - mux_request_pool: core::ptr::null_mut(), + mux_request_pool: core::ptr::null(), all_closed_promise: jsc::JSPromiseStrong::default(), poll_ref: KeepAlive::default(), flags: ServerFlags::default(), @@ -2241,31 +2270,10 @@ impl NewServer { // (since-moved) stack slot. On Err, the `Box` drop frees the // half-built server. // SAFETY: `server` is the freshly-boxed `*mut Self`; uniquely owned here. - if let Some(bake_options) = unsafe { &mut (*server).config.bake } { - // SAFETY: `server` is the freshly-boxed `*mut Self`; uniquely owned here. - let broadcast = unsafe { - (*server) - .config - .broadcast_console_log_from_browser_to_server_for_bake - }; - let dev = match crate::bake::DevServer::init(crate::bake::DevServer::Options { - arena: &bake_options.arena, - root: bake_options.root.as_zstr(), - // SAFETY: per-thread VM singleton; STATIC lifetime. - vm: jsc::VirtualMachine::get(), - framework: core::mem::take(&mut bake_options.framework), - bundler_options: core::mem::take(&mut bake_options.bundler_options), - broadcast_console_log_from_browser_to_server: broadcast, - }) { - Ok(d) => d, - Err(e) => { - // SAFETY: paired with heap::alloc above. - drop(unsafe { bun_core::heap::take(server) }); - return Err(e); - } - }; - // SAFETY: `server` is uniquely owned here. - unsafe { (*server).dev_server = Some(dev) }; + if let Err(e) = unsafe { (*server).init_dev_server() } { + // SAFETY: paired with heap::alloc above. + drop(unsafe { bun_core::heap::take(server) }); + return Err(e); } if SSL { @@ -2277,6 +2285,28 @@ impl NewServer { Ok(server) } + /// Creates the DevServer from `self.config.bake`, which must already be at its final heap address. + fn init_dev_server(&mut self) -> JsResult<()> { + let Some(bake_options) = &mut self.config.bake else { + return Ok(()); + }; + let mut dev = crate::bake::DevServer::init(crate::bake::DevServer::Options { + arena: &bake_options.arena, + root: bake_options.root.as_zstr(), + // SAFETY: per-thread VM singleton; STATIC lifetime. + vm: jsc::VirtualMachine::get(), + framework: core::mem::take(&mut bake_options.framework), + bundler_options: core::mem::take(&mut bake_options.bundler_options), + broadcast_console_log_from_browser_to_server: self + .config + .broadcast_console_log_from_browser_to_server_for_bake, + })?; + // A reload gets here after `set_inspector_server_id` ran. + dev.inspector_server_id = self.inspector_server_id; + self.dev_server = Some(dev); + Ok(()) + } + // ─── set_routes ────────────────────────────────────────────────────────── /// Register HTTP routes on `self.app` (and `h2_app`/`h3_app` when present). Returns /// the JS `RouteList` value for codegen-backed user routes, or `.zero` when @@ -2351,8 +2381,7 @@ impl NewServer { } // --- 3. Register compiled user routes & track "/*" coverage --- - let mut star_methods_covered_by_user = http_method::Set::empty(); - let mut has_any_user_route_for_star_path = false; + let mut has_any_method_route_for_star_path = false; let mut has_any_ws_route_for_star_path = false; // reshaped for borrowck — `app.ws(..)` reads `to_behavior()` @@ -2369,9 +2398,6 @@ impl NewServer { let ud: *mut c_void = std::ptr::from_mut::>(user_route).cast(); let path = user_route.route.path.as_bytes(); let is_star_path = path == b"/*"; - if is_star_path { - has_any_user_route_for_star_path = true; - } if should_add_chrome_devtools_json_route && (path == CHROME_DEVTOOLS_ROUTE || path.starts_with(b"/.well-known/")) { @@ -2394,7 +2420,7 @@ impl NewServer { ); }); if is_star_path { - star_methods_covered_by_user = http_method::Set::all(); + has_any_method_route_for_star_path = true; } if let Some(websocket) = websocket_ptr { if is_star_path { @@ -2424,9 +2450,6 @@ impl NewServer { Self::on_mux_user_route_request, ); }); - if is_star_path { - star_methods_covered_by_user.insert(method_val); - } // Setup user websocket in the route if needed. if let Some(websocket) = websocket_ptr { // Websocket upgrade is a GET request @@ -2446,9 +2469,9 @@ impl NewServer { // --- 4. Register negative routes --- // A `false` route means "fall through to the default handler": same - // ladder as the `/*` fallback in step 9. H2/H3 stay on on_mux_request, + // handler as the `/*` fallback in step 9. H2/H3 stay on on_mux_request, // which already falls back to on_mux_404 when on_request is empty. - let negative_h1 = if !self.config.on_node_http_request.is_empty() { + let default_h1 = if !self.config.on_node_http_request.is_empty() { trampoline::on_node_http_request:: } else if !self.config.on_request.is_empty() { trampoline::on_request:: @@ -2457,8 +2480,8 @@ impl NewServer { }; for route_path in self.config.negative_routes.iter() { let p = route_path.as_bytes(); - app.head(p, Some(negative_h1), self_ptr.cast()); - app.any(p, Some(negative_h1), self_ptr.cast()); + app.head(p, Some(default_h1), self_ptr.cast()); + app.any(p, Some(default_h1), self_ptr.cast()); for_each_mux_app!(self, |mux| { mux.head(p, self_ptr, Self::on_mux_request); mux.any(p, self_ptr, Self::on_mux_request); @@ -2467,19 +2490,10 @@ impl NewServer { // --- 5. Register static routes & track "/*" coverage --- let mut needs_plugins = dev_server.is_some(); - let mut has_static_route_for_star_path = false; for entry in &self.config.static_routes { - if &*entry.path == b"/*" { - has_static_route_for_star_path = true; - match &entry.method { - server_config::MethodOptional::Any => { - star_methods_covered_by_user = http_method::Set::all(); - } - server_config::MethodOptional::Method(method) => { - star_methods_covered_by_user |= *method; - } - } + if &*entry.path == b"/*" && matches!(entry.method, server_config::MethodOptional::Any) { + has_any_method_route_for_star_path = true; } if should_add_chrome_devtools_json_route && (&*entry.path == CHROME_DEVTOOLS_ROUTE @@ -2631,21 +2645,20 @@ impl NewServer { // Snapshot "/*" coverage from user/static routes before DevServer // (which is H1-only and not mirrored to the H2/H3 routers) marks it full. - let mux_star_covered = star_methods_covered_by_user; + let mux_has_any_method_route_for_star_path = has_any_method_route_for_star_path; // --- 8. Handle DevServer routes & track "/*" coverage --- - let mut has_dev_server_for_star_path = false; if let Some(dev) = dev_server { // dev.setRoutes might register its own "/*" HTTP handler // SAFETY: `dev` is the live `*mut DevServer` snapshotted from // `self.dev_server` above; `self_ptr` is the live server. The two // allocations are disjoint so the `&mut` borrows do not alias. - has_dev_server_for_star_path = bun_core::handle_oom( + let has_dev_server_for_star_path = bun_core::handle_oom( unsafe { &mut *dev }.set_routes::(unsafe { &mut *self_ptr }), ); if has_dev_server_for_star_path { // Assume dev server "/*" covers all methods if it exists - star_methods_covered_by_user = http_method::Set::all(); + has_any_method_route_for_star_path = true; } } @@ -2665,72 +2678,22 @@ impl NewServer { // --- 9. Consolidated "/*" HTTP fallback registration --- let ud = self_ptr.cast::(); - let has_node_http = !self.config.on_node_http_request.is_empty(); - let has_on_request = !self.config.on_request.is_empty(); - if star_methods_covered_by_user == http_method::Set::all() { - // User/Static/Dev has already provided a "/*" handler for ALL methods. - // No further global "/*" HTTP fallback needed. - } else if has_any_user_route_for_star_path - || has_static_route_for_star_path - || has_dev_server_for_star_path - { - // A "/*" route exists, but doesn't cover all methods. Apply the - // global handler to the *remaining* methods for "/*". - for method_to_cover in !star_methods_covered_by_user { - if has_node_http { - app.method( - method_to_cover, - b"/*", - Some(trampoline::on_node_http_request::), - ud, - ); - } else if has_on_request { - app.method( - method_to_cover, - b"/*", - Some(trampoline::on_request::), - ud, - ); - } else { - app.method( - method_to_cover, - b"/*", - Some(trampoline::on_404::), - ud, - ); - } - } - } else if has_node_http { - app.any( - b"/*", - Some(trampoline::on_node_http_request::), - ud, - ); - } else if has_on_request { - app.any(b"/*", Some(trampoline::on_request::), ud); - } else { - app.any(b"/*", Some(trampoline::on_404::), ud); + // An any-method route runs after a "/*" route for one method: it takes the other methods and the requests that route yields. + if !has_any_method_route_for_star_path { + app.any(b"/*", Some(default_h1), ud); } - // H2/H3 fallback — same three-way as H1 above, but driven by user/static - // "/*" coverage only (DevServer routes are not mirrored to H2/H3). - for_each_mux_app!(self, |mux| { - if mux_star_covered == http_method::Set::all() { - // user/static "/*" already covers every method - } else if has_any_user_route_for_star_path || has_static_route_for_star_path { - for m in !mux_star_covered { - if has_on_request { - mux.method(m, b"/*", self_ptr, Self::on_mux_request); - } else { - mux.method(m, b"/*", self_ptr, Self::on_mux_404); - } + // H2/H3 fallback: the same, but only user and static "/*" routes count (DevServer routes are not mirrored to H2/H3). + if !mux_has_any_method_route_for_star_path { + let has_on_request = !self.config.on_request.is_empty(); + for_each_mux_app!(self, |mux| { + if has_on_request { + mux.any(b"/*", self_ptr, Self::on_mux_request); + } else { + mux.any(b"/*", self_ptr, Self::on_mux_404); } - } else if has_on_request { - mux.any(b"/*", self_ptr, Self::on_mux_request); - } else { - mux.any(b"/*", self_ptr, Self::on_mux_404); - } - }); + }); + } if should_add_chrome_devtools_json_route { app.get( @@ -3057,11 +3020,21 @@ impl NewServer { // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. bun_opaque::opaque_deref_mut(app).filter(Self::on_connection_filter, this.cast::()); + if Self::HAS_H3 { + if let Some(h3_app) = this_ref.h3_app { + // S008: `h3::App` is an `opaque_ffi!` ZST — safe deref. + bun_opaque::opaque_deref_mut(h3_app) + .on_released(Self::on_h3_released, this.cast::()); + } + } if this_ref.config.is_node_http_server { // SAFETY: `this` is the live boxed server from `init()`; no other // borrow is live — `&mut` scoped to this call. unsafe { (*this).set_using_custom_expect_handler(true) }; + } else { + bun_opaque::opaque_deref_mut(app) + .set_max_request_body_size(this_ref.config.max_request_body_size as u64); } // the listen_* trampolines re-derive `&mut *this` synchronously @@ -3082,11 +3055,13 @@ impl NewServer { enum Addr { Tcp { port: u16, host: *const c_char }, Unix { ptr: *const u8, len: usize }, + Fd(i32), } let (addr, tcp, options) = { let cfg = &this_ref.get().config; - let addr = match &cfg.address { - server_config::Address::Tcp { port, hostname } => { + let addr = match (cfg.listen_fd, &cfg.address) { + (Some(fd), _) => Addr::Fd(fd), + (None, server_config::Address::Tcp { port, hostname }) => { let mut host: *const c_char = core::ptr::null(); if let Some(existing) = hostname.as_deref() { let bytes = existing.as_bytes(); @@ -3101,7 +3076,7 @@ impl NewServer { } Addr::Tcp { port: *port, host } } - server_config::Address::Unix(unix) => Addr::Unix { + (None, server_config::Address::Unix(unix)) => Addr::Unix { ptr: unix.as_ptr().cast(), len: unix.as_bytes().len(), }, @@ -3110,6 +3085,19 @@ impl NewServer { }; match addr { + Addr::Fd(fd) => { + // 511 is net.Server's default backlog. S012: `NewApp` is a ZST opaque — safe deref. + let fd = fd as uws_sys::LIBUS_SOCKET_DESCRIPTOR; + match bun_opaque::opaque_deref_mut(app).listen_fd(fd, 511, options) { + // SAFETY: `this` is the live boxed server from `init()`; no other borrow is live. + Ok(socket) => unsafe { (*this).on_listen(Ok(socket)) }, + Err(errno) => { + let err = bun_sys::Error::from_code_int(errno, bun_sys::Tag::listen); + let err = jsc::SystemError::from(err.to_system_error()); + let _ = global.throw_value(err.to_error_instance(global)); + } + } + } Addr::Tcp { port, host } => { // With `{port: 0, http3: true}` we bind TCP:0 (kernel picks N), // then must bind UDP:N for QUIC so Alt-Svc works. UDP:N may @@ -3126,20 +3114,16 @@ impl NewServer { loop { attempt += 1; if tcp { - // SAFETY: app is a live uws handle owned by this server. No - // `&*this` is live across this call; the trampoline's - // `&mut *this` is the sole borrow while it runs. - unsafe { - (*app).listen_with_config( - Some(trampoline::on_listen::), - this.cast::(), - uws_app_c::uws_app_listen_config_t { - port: port as c_int, - host, - options, - }, - ); - } + // SAFETY: app is a live uws handle owned by this server. + let listened = unsafe { + (*app).listen_with_config(uws_app_c::uws_app_listen_config_t { + port: port as c_int, + host, + options, + }) + }; + // SAFETY: `this` is the live boxed server; no other borrow is live. + unsafe { (*this).on_listen(listened) }; } if Self::HAS_H3 { @@ -3214,16 +3198,10 @@ impl NewServer { // SAFETY: ptr/len reference `config.address`'s ZBox; NUL // sentinel at `ptr[len]` holds for ZStr::from_raw. let z = unsafe { bun_core::ZStr::from_raw(ptr, len) }; - // SAFETY: app is a live uws handle owned by this server. No - // `&*this` is live across this call. - unsafe { - (*app).listen_on_unix_socket( - trampoline::on_listen_unix::, - this.cast::(), - z, - options, - ); - } + // SAFETY: app is a live uws handle owned by this server. + let listened = unsafe { (*app).listen_on_unix_socket(z, options) }; + // SAFETY: `this` is the live boxed server; no other borrow is live. + unsafe { (*this).on_listen(listened) }; } } @@ -3434,30 +3412,7 @@ impl NewServer { // the bodies downcast `user_data` and forward into the typed method. mod trampoline { use super::*; - use bun_uws_sys::{ListenSocket as UwsListenSocket, Request as UwsRequest, uws_res}; - - pub(super) extern "C" fn on_listen( - socket: *mut UwsListenSocket, - user_data: *mut c_void, - ) { - // SAFETY: user_data is the `*mut NewServer<..>` passed to listen_with_config. - let server = unsafe { bun_ptr::callback_ctx::>(user_data) }; - let socket = if socket.is_null() { - None - } else { - Some(socket.cast::>()) - }; - server.on_listen(socket); - } - - pub(super) extern "C" fn on_listen_unix( - socket: *mut UwsListenSocket, - _domain: *const c_char, - _flags: i32, - user_data: *mut c_void, - ) { - on_listen::(socket, user_data); - } + use bun_uws_sys::{Request as UwsRequest, uws_res}; pub(super) extern "C" fn on_404( res: *mut uws_res, @@ -3544,60 +3499,82 @@ mod trampoline { } // ─── per-monomorphization request pools ────────────────────────────────────── -// Rust generics cannot own statics, so -// declare one `thread_local!` per concrete (SSL, DEBUG, H3) combo via macro and -// hand the leaked pointer back through a trait. -// -// THREAD-SAFETY: this MUST be thread-local, not process-global. `hive_array:: -// Fallback::{get,put,claim}` take `&mut self` with no internal synchronization; -// a process-static would race when two `Bun.serve` instances run on distinct -// Worker threads (each Worker has its own event loop and may host a server). +// `claim`/`put` are unsynchronized and Workers host servers too, so each VM owns its pools. pub(crate) trait ServerPools: Sized { - fn request_pool() -> *mut request_context::RequestContextStackAllocator; - fn mux_request_pool() - -> *mut request_context::RequestContextStackAllocator; + fn request_pool() -> *const RequestPool; + fn mux_request_pool() -> *const RequestPool; +} + +type RequestPool = + request_context::RequestContextStackAllocator, SSL, DEBUG, MUX>; + +type RequestPoolSlot = + core::cell::OnceCell>>; + +/// One ~816 KB pool per server type, allocated by the first server of that type. +#[derive(Default)] +pub(crate) struct RequestPools { + http: RequestPoolSlot, + https: RequestPoolSlot, + debug_http: RequestPoolSlot, + debug_https: RequestPoolSlot, + http_mux: RequestPoolSlot, + https_mux: RequestPoolSlot, + debug_http_mux: RequestPoolSlot, + debug_https_mux: RequestPoolSlot, +} + +impl RequestPools { + fn get( + slot: &RequestPoolSlot, + ) -> *const RequestPool { + // SAFETY: `new_boxed` leaks a fully initialized `Box` without staging the pool on the stack. + &raw const **slot.get_or_init(|| unsafe { + bun_core::heap::take(RequestPool::::new_boxed().as_ptr()) + }) + } +} + +impl Drop for RequestPools { + fn drop(&mut self) { + fn release( + slot: &mut RequestPoolSlot, + ) { + let Some(pool) = slot.take() else { return }; + if pool.hive.used.find_first_set().is_some() { + // The JSC VM is gone, so a context that was never released can't be dropped now. + bun_core::asan::ignore_object(bun_core::heap::into_raw(pool).cast()); + } + } + release(&mut self.http); + release(&mut self.https); + release(&mut self.debug_http); + release(&mut self.debug_https); + release(&mut self.http_mux); + release(&mut self.https_mux); + release(&mut self.debug_http_mux); + release(&mut self.debug_https_mux); + } } macro_rules! impl_server_pools { - ($(($ssl:literal, $debug:literal)),* $(,)?) => {$( + ($(($ssl:literal, $debug:literal, $pool:ident, $mux_pool:ident)),* $(,)?) => {$( impl ServerPools<$ssl, $debug> for NewServer<$ssl, $debug> { - fn request_pool() -> *mut request_context::RequestContextStackAllocator { - type Pool = request_context::RequestContextStackAllocator, $ssl, $debug, false>; - thread_local! { - static POOL: core::cell::Cell<*mut Pool> = - const { core::cell::Cell::new(core::ptr::null_mut()) }; - } - POOL.with(|cell| { - let mut p = cell.get(); - if p.is_null() { - // `Box::new(Pool::init())` builds the ~816 KB pool on - // the stack and `memcpy`s it into the heap (no NRVO); - // `new_boxed` writes only the 256 B bitset in place. - p = Pool::new_boxed().as_ptr(); - cell.set(p); - } - p - }) + fn request_pool() -> *const RequestPool<$ssl, $debug, false> { + RequestPools::get(&crate::jsc_hooks::request_pools().$pool) } - fn mux_request_pool() -> *mut request_context::RequestContextStackAllocator { - type Pool = request_context::RequestContextStackAllocator, $ssl, $debug, true>; - thread_local! { - static POOL: core::cell::Cell<*mut Pool> = - const { core::cell::Cell::new(core::ptr::null_mut()) }; - } - POOL.with(|cell| { - let mut p = cell.get(); - if p.is_null() { - p = Pool::new_boxed().as_ptr(); - cell.set(p); - } - p - }) + fn mux_request_pool() -> *const RequestPool<$ssl, $debug, true> { + RequestPools::get(&crate::jsc_hooks::request_pools().$mux_pool) } } )*}; } -impl_server_pools!((false, false), (true, false), (false, true), (true, true)); +impl_server_pools!( + (false, false, http, http_mux), + (true, false, https, https_mux), + (false, true, debug_http, debug_http_mux), + (true, true, debug_https, debug_https_mux), +); // ─── FFI ───────────────────────────────────────────────────────────────────── mod ffi { @@ -4106,6 +4083,10 @@ impl AnyServer { any_server_dispatch_mut!(self, |s| s.on_request_complete()) } + pub(crate) fn note_request_parked(&self, parked: bool) { + any_server_dispatch!(self, |s| s.note_request_parked(parked)) + } + pub(crate) fn on_static_request_complete(&mut self) { any_server_dispatch_mut!(self, |s| s.on_static_request_complete()) } diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index 81a6905cdffb..dc97d3739990 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -21,6 +21,8 @@ use bun_core::{EncodedSlice, String as BunString, Utf8Bytes, strings}; use bun_core::{Output, fmt as bun_fmt}; use bun_http::header_has_token; use bun_http::{self as http, Method, MimeType}; +use bun_http_jsc::method_jsc; +use bun_http_types::Method::OwnedMethod; use bun_jsc::Debugger::DebuggerId; use bun_jsc::uuid::UUID; use bun_jsc::{ @@ -1235,7 +1237,7 @@ impl UpgradeOptions { else { return Ok(this); }; - if let Some(headers) = FetchHeaders::cast_(headers_value, global.vm()) { + if let Some(headers) = FetchHeaders::cast_as_init(headers_value) { this.headers = Some(headers); this._headers_object = jsc::EnsureStillAlive(headers_value); } else if headers_value.is_object() { @@ -1642,10 +1644,6 @@ where let seconds = arguments[1]; - if self.config.address.is_unix() { - return Ok(JSValue::NULL); - } - if !seconds.is_number() { return Err(self .global() @@ -1898,9 +1896,12 @@ where &mut self, new_config: &mut ServerConfig, global: &JSGlobalObject, - ) { + ) -> JsResult<()> { httplog!("onReload"); + // Runs before any route is swapped, so a failure leaves the server as it was. + self.init_dev_server_for_reload(new_config, global)?; + // SAFETY: `on_reload` is only reachable while the server is running // (`self.app` set in `listen()`). self.app_mut().clear_routes(); @@ -1973,6 +1974,26 @@ where dev_server.html_router.fallback = None; } + // The chunk routes of a built HTML route are only in the old list. + let mut adopted_assets = Vec::new(); + for new_entry in &new_config.static_routes { + let AnyRoute::Html(new_route) = &new_entry.route else { + continue; + }; + for old_entry in &self.config.static_routes { + if let AnyRoute::Html(old_route) = &old_entry.route { + adopted_assets.extend(new_route.adopt_assets(old_route)); + } + } + } + for (path, route) in adopted_assets { + bun_core::handle_oom(new_config.append_static_route( + &path, + AnyRoute::Static(route), + server_config::MethodOptional::Any, + )); + } + // NOTE: `Vec` impls `Drop`, so // a move-assign frees the old `static_routes`. self.config.static_routes = core::mem::take(&mut new_config.static_routes); @@ -1998,6 +2019,27 @@ where )); } } + Ok(()) + } + + /// Creates the DevServer when a reload brings the server's first HTML route. `init()` only does it for the first config. + fn init_dev_server_for_reload( + &mut self, + new_config: &mut ServerConfig, + global: &JSGlobalObject, + ) -> JsResult<()> { + // The DevServer is HTTP/1-only. With it, an HTML route answers HTTP/2 and HTTP/3 with a 503. + if self.dev_server.is_some() + || !self.config.development.is_hmr_enabled() + || self.h2_app.is_some() + || self.h3_app.is_some() + || !self.has_listener() + || !self.config.take_dev_server_options_from(new_config, global) + { + return Ok(()); + } + self.init_dev_server() + .inspect_err(|_| self.config.drop_dev_server_options()) } pub(crate) fn reload_static_routes(&mut self) -> Result { @@ -2049,7 +2091,7 @@ where // ws shadows, and each `wrap_handler_slot` call allocates via // `with_async_context_if_needed`. Same window as `serve()`; same fix. let _handler_pins = super::protect_handler_shadows(&new_config); - self.on_reload_from_zig(&mut new_config, global); + self.on_reload_from_zig(&mut new_config, global)?; Ok(self.js_value.try_get().unwrap_or(JSValue::UNDEFINED)) } @@ -2084,7 +2126,7 @@ where } let mut headers: Option = None; - let mut method = Method::GET; + let mut method = OwnedMethod::from(Method::GET); // SAFETY: bun_vm() returns the live per-thread VM singleton. let mut args = jsc::ArgumentsSlice::init(ctx.bun_vm(), arguments); @@ -2093,8 +2135,20 @@ where // TODO: set Host header // TODO: set User-Agent header // TODO: unify with fetch() implementation. - let existing_request: Box = if first_arg.is_string() { - let url_utf8 = arguments[0].to_utf8(ctx)?; + let request_arg = first_arg + .is_object() + .then(|| ::from_js(first_arg)) + .flatten(); + let url_arg = if first_arg.is_string() { + Some(first_arg.to_utf8(ctx)?) + } else if first_arg.is_object() && request_arg.is_none() { + // A `URL`, or any other object, is read as its href, like fetch() does. + let href = ::href_from_js(first_arg, ctx)?; + (href.tag() != bun_core::Tag::Dead).then(|| href.into_utf8()) + } else { + None + }; + let existing_request: Box = if let Some(url_utf8) = url_arg { let temp_url_str = url_utf8.slice(); if temp_url_str.is_empty() { @@ -2125,12 +2179,11 @@ where if arguments.len() >= 2 && arguments[1].is_object() { let opts = arguments[1]; if let Some(method_) = opts.fast_get(ctx, jsc::BuiltinName::Method)? { - let slice_ = method_.to_utf8(ctx)?; - method = Method::which(slice_.slice()).unwrap_or(method); + method = method_jsc::request_method_from_js(ctx, method_)?; } if let Some(headers_) = opts.fast_get(ctx, jsc::BuiltinName::Headers)? { - if let Some(headers__) = FetchHeaders::cast_(headers_, ctx.vm()) { + if let Some(headers__) = FetchHeaders::cast_as_init(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()) @@ -2162,15 +2215,16 @@ where crate::webcore::body::hive_alloc(body), method, )) - } else if let Some(request_) = first_arg - .is_object() - .then(|| ::from_js(first_arg)) - .flatten() - { + } else if let Some(request_) = request_arg { // SAFETY: JsClass::from_js returns a live *mut Request. // NOTE: `Request::clone()` (Request.rs:1627) seeds a fully-initialized // sentinel and calls `clone_into(.., preserve_url=false)`. - unsafe { (*request_).clone(&ctx.js_thread_of_caller(callframe))? } + match unsafe { (*request_).clone(&ctx.js_thread_of_caller(callframe)) } { + Ok(cloned) => cloned, + Err(err) => { + return Ok(JSPromise::rejected_promise_with_caught_exception(ctx, err)?.to_js()); + } + } } else { let fetch_error = Fetch::fetch_type_error_string(first_arg); let err = jsc::ErrorCode::INVALID_ARG_TYPE.fmt(ctx, format_args!("{}", fetch_error)); @@ -2189,8 +2243,9 @@ where // SAFETY: `request` was just allocated via `heap::alloc`; ownership // transfers to the JS wrapper inside `to_js`. let request_value = unsafe { (*request).to_js(&global_this) }; + let this_value = self.js_value_assert_alive(); let response_value = - match on_request.call(&global_this, self.js_value_assert_alive(), &[request_value]) { + match on_request.call(&global_this, this_value, &[request_value, this_value]) { Ok(v) => v, Err(err) => { return Ok(JSPromise::rejected_promise_with_caught_exception(ctx, err)?.to_js()); @@ -2415,13 +2470,11 @@ where // unconditional. `Box::into_raw` (not `heap::release`) keeps raw-owner // provenance for the `deinit()` dealloc — a `&mut self`-derived tag // there would be Stacked-Borrows UB. JSC-handle Drops are no-ops past - // `is_shutting_down()`; `TERMINATED` means `app.close()` already ran, - // so `NewApp::destroy` can't orphan a keep-alive socket. + // `is_shutting_down()`, and teardown already closed every socket. unsafe { (*this_ptr).js_value.finalize(); (*this_ptr).deinit_if_we_can(); if (*this_ptr).flags.contains(ServerFlags::DEINIT_SCHEDULED) - && (*this_ptr).flags.contains(ServerFlags::TERMINATED) && (*this_ptr).vm().is_shutting_down() { Self::deinit(this_ptr); @@ -2478,6 +2531,7 @@ where callframe: &CallFrame, ) -> JsResult { let this_value = callframe.this(); + self.flags.remove(ServerFlags::UNREFED_BY_USER); self.ref_(); Ok(this_value) } @@ -2489,6 +2543,7 @@ where callframe: &CallFrame, ) -> JsResult { let this_value = callframe.this(); + self.flags.insert(ServerFlags::UNREFED_BY_USER); self.unref(); Ok(this_value) } diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index d4e345f386eb..fb54354f5bb2 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -524,6 +524,7 @@ impl Listener { this_ref.secure_ctx.get().as_ref().map(|p| p.as_ptr()); let mut errno: c_int = 0; + let mut dns_error: c_int = 0; let listen_socket: *mut uws_sys::ListenSocket = match &mut connection { UnixOrHost::Host { host, port } => { let hostz = bun_core::ZBox::from_bytes(&host[..]); @@ -537,6 +538,7 @@ impl Listener { socket_flags, size_of::<*mut c_void>() as c_int, &mut errno, + &mut dns_error, ) }); if !ls.is_null() { @@ -579,13 +581,18 @@ impl Listener { UnixOrHost::Unix(u) => u, UnixOrHost::Fd(_) => b"", }; + log!("Failed to listen {} {}", errno, dns_error); + if let Some(err) = + crate::dns_jsc::cares_jsc::getaddrinfo_error(dns_error, hostname_bytes) + { + return Err(cx.global().throw_value(err.to_error_instance(cx.global()))); + } let err = cx.global().create_error_instance(format_args!( "Failed to listen at {}", bstr::BStr::new(hostname_bytes) )); - log!("Failed to listen {}", errno); - let mapped = bun_sys::SystemErrno::init(errno as i64); - let errno = if mapped == Some(bun_sys::SystemErrno::ENAMETOOLONG) + let mapped = uws::SocketGroup::listen_errno(errno); + let mapped = if mapped == Some(bun_sys::SystemErrno::ENAMETOOLONG) || (matches!(connection, UnixOrHost::Fd(_)) && matches!( mapped, @@ -593,9 +600,9 @@ impl Listener { | Some(bun_sys::SystemErrno::EBADF) | Some(bun_sys::SystemErrno::EOPNOTSUPP) )) { - bun_sys::SystemErrno::EINVAL as c_int + Some(bun_sys::SystemErrno::EINVAL) } else { - errno + mapped }; if errno != 0 { err.put( @@ -603,7 +610,15 @@ impl Listener { b"syscall", BunString::static_("listen").to_js(cx.global())?, ); - err.put(cx.global(), b"errno", JSValue::js_number(errno as f64)); + let js_errno = match mapped { + Some(code) => { + bun_sys::Error::new(code, bun_sys::Tag::listen) + .to_system_error() + .errno + } + None => errno, + }; + err.put(cx.global(), b"errno", JSValue::js_number(js_errno as f64)); err.put( cx.global(), b"address", @@ -612,11 +627,11 @@ impl Listener { if let Some(p) = port { err.put(cx.global(), b"port", JSValue::js_number(p as f64)); } - if let Some(str_) = bun_sys::SystemErrno::init(errno as i64) { + if let Some(code) = mapped { err.put( cx.global(), b"code", - BunString::static_(<&'static str>::from(str_)).to_js(cx.global())?, + BunString::static_(<&'static str>::from(code)).to_js(cx.global())?, ); } } diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index c56871d2a525..c46fec8f8824 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -2680,6 +2680,15 @@ impl NewSocket { res } + /// Returns true if a write refused with would-block can still go out after a writable event. + pub(crate) fn write_side_open(&self) -> bool { + let socket = self.socket.get(); + socket.is_established() + && !socket.is_shutdown() + && !socket.is_closed() + && !(SSL && self.flags.get().contains(Flags::REJECTED)) + } + pub(crate) fn write_maybe_corked(&self, buffer: &[u8]) -> i32 { let socket = self.socket.get(); if socket.is_shutdown() || socket.is_closed() { @@ -3279,7 +3288,7 @@ impl NewSocket { let [arg] = callframe.arguments_as_array::<1>(); if callframe.arguments_count() > 0 && arg.to_boolean() { this.socket.get().shutdown_read(); - } else { + } else if !this.native_callback.get().defer_shutdown() { this.socket.get().shutdown(); } @@ -4188,6 +4197,13 @@ impl NativeCallbacks { unsafe { (*h2).on_native_writable() }; true } + /// Returns true if the attached parser takes over the half-close and sends the FIN after its own output. + pub(crate) fn defer_shutdown(&self) -> bool { + match self { + NativeCallbacks::H2(h2) => h2.defer_shutdown(), + NativeCallbacks::None => false, + } + } } // ────────────────────────────────────────────────────────────────────────── @@ -5227,6 +5243,17 @@ pub(crate) mod testing_apis { Ok(JSValue::js_number(count as f64)) } + /// `runSocketTimeoutSweepSoon` in `bun:internal-for-testing`. + #[bun_jsc::host_fn] + pub(crate) fn js_run_socket_timeout_sweep_soon( + _global: &JSGlobalObject, + _frame: &CallFrame, + ) -> JsResult { + // SAFETY: `Loop::get()` returns this thread's live loop, and the call only moves its sweep deadline. + unsafe { bun_uws_sys::loop_::us_internal_sweep_soon(bun_uws_sys::Loop::get()) }; + Ok(JSValue::UNDEFINED) + } + /// Live `DuplexUpgradeContext`s (one per TLS socket over a JS stream). #[bun_jsc::host_fn] pub(crate) fn js_duplex_upgrade_context_live_count( diff --git a/src/runtime/socket/udp_socket.rs b/src/runtime/socket/udp_socket.rs index 7339ae52aceb..1a7a5a305e32 100644 --- a/src/runtime/socket/udp_socket.rs +++ b/src/runtime/socket/udp_socket.rs @@ -723,7 +723,7 @@ impl UDPSocket { on_recv_error, hostname_z.as_ptr(), config.port, - config.flags, + config.flags | uws::LIBUS_UDP_LINUX_RECVERR, Some(&mut err), this_ptr.cast::(), ) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 0be26515328c..2ce20f934955 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -179,7 +179,11 @@ pub(crate) trait BlobExt { ) -> Blob where Self: Sized; - fn from_dom_form_data(global_this: &JSGlobalObject, form_data: &mut jsc::DOMFormData) -> Blob + /// Returns the error instead of throwing it if a file-backed entry can't be read. + fn from_dom_form_data( + global_this: &JSGlobalObject, + form_data: &mut jsc::DOMFormData, + ) -> bun_sys::Result where Self: Sized; fn content_type(&self) -> &[u8]; @@ -821,7 +825,10 @@ impl BlobExt for Blob { blob } - fn from_dom_form_data(global_this: &JSGlobalObject, form_data: &mut jsc::DOMFormData) -> Blob { + fn from_dom_form_data( + global_this: &JSGlobalObject, + form_data: &mut jsc::DOMFormData, + ) -> bun_sys::Result { // "----WebKitFormBoundary" (22 bytes) + 32 lowercase-hex chars of a fresh UUID. const BOUNDARY_PREFIX: &[u8; 22] = b"----WebKitFormBoundary"; let mut boundary_buf = [0u8; BOUNDARY_PREFIX.len() + 32]; @@ -836,8 +843,7 @@ impl BlobExt for Blob { let mut context = FormDataContext { joiner: bun_core::string_joiner::StringJoiner::default(), boundary, - failed: false, - global_this, + read_error: None, }; // Size the node list up front: a file entry pushes at most 13 slices, a // string entry 8, plus 3 for the closing boundary. @@ -909,12 +915,12 @@ impl BlobExt for Blob { (&raw mut context).cast::(), for_each_thunk, ); - if context.failed { + if let Some(err) = context.read_error { // Drop the joiner (Drop runs StringJoiner::deinit) so every // heap-owned slice already pushed — escaped names, non-ASCII // conversions, NodeFS read_file result buffers — is freed. drop(context.joiner); - return Blob::init_empty(global_this); + return Err(err); } context.joiner.push_static(b"--"); @@ -937,7 +943,7 @@ impl BlobExt for Blob { .set(BlobContentType::Owned(std::sync::Arc::from(ct))); blob.content_type_was_set.set(true); - blob + Ok(blob) } fn content_type(&self) -> &[u8] { @@ -3517,15 +3523,12 @@ where // FormDataContext // ────────────────────────────────────────────────────────────────────────── -/// Stack-local helper for `Blob::from_dom_form_data`. `boundary` borrows the -/// caller's `hex_buf` and `global_this` borrows the incoming `&JSGlobalObject`; -/// both strictly outlive this struct, so they are stored as plain references -/// rather than raw pointers. +/// Stack-local helper for `Blob::from_dom_form_data`. struct FormDataContext<'a> { joiner: StringJoiner<'a>, boundary: &'a [u8], // borrowed; outlives the joiner - failed: bool, - global_this: &'a JSGlobalObject, + /// Set by the first file-backed entry that can't be read. Later entries are skipped. + read_error: Option, } /// Which piece of a `multipart/form-data` entry a string is, selecting the @@ -3615,12 +3618,11 @@ impl FormDataContext<'_> { } fn on_entry(&mut self, name: EncodedSlice, entry: FormDataEntry<'_>) { - if self.failed { + if self.read_error.is_some() { return; } // Copy the borrowed refs out first (disjoint-field reads) so the // long-lived `&mut self.joiner` below doesn't conflict. - let global_this = self.global_this; let boundary = self.boundary; let joiner = &mut self.joiner; @@ -3685,11 +3687,7 @@ impl FormDataContext<'_> { rf_args.max_size = Some(blob.size.get()); let res = node_fs.read_file(&rf_args, crate::node::fs::Flavor::Sync); match res { - Err(err) => { - self.failed = true; - let js_err = err.to_js(global_this); - let _ = global_this.throw_value(js_err); - } + Err(err) => self.read_error = Some(err), Ok(mut result) => { joiner.push_cloned(result.slice()); // StringOrBuffer::Drop is a no-op for Buffer; release @@ -6076,6 +6074,20 @@ impl Any { } } + /// Returns the code units after a leading U+FEFF. `text()` and `json()` drop one, like a UTF-8 decode does. + fn units_after_bom(&self) -> Option<&[u16]> { + let Any::WTFStringImpl(string) = self else { + return None; + }; + if string.is_8bit() { + return None; + } + match string.utf16_slice() { + [0xFEFF, rest @ ..] => Some(rest), + _ => None, + } + } + fn to_internal_blob_if_possible(&mut self) { let Any::Blob(blob) = self else { return; @@ -6185,6 +6197,11 @@ impl Any { str } Any::WTFStringImpl(_) => { + if let Some(units) = self.units_after_bom() { + let json = EncodedSlice::utf16(units).to_json_object(cx.global()); + *self = Any::Blob(Blob::default()); + return json; + } let str = self.take_string(); if str.length() == 0 { return Ok(JSValue::NULL); @@ -6255,7 +6272,18 @@ impl Any { *self = Any::Blob(Blob::default()); Ok(owned) } - Any::WTFStringImpl(_) => self.take_string().into_js(cx.global()), + Any::WTFStringImpl(_) => { + if let Some(units) = self.units_after_bom() { + let mut text = Vec::new(); + if text.try_reserve_exact(units.len()).is_err() { + return Err(cx.global().throw_memory_allocation_failed()); + } + text.extend_from_slice(units); + *self = Any::Blob(Blob::default()); + return bun_string_jsc::owned_utf16_into_js(cx.global(), text); + } + self.take_string().into_js(cx.global()) + } } } diff --git a/src/runtime/webcore/Body.rs b/src/runtime/webcore/Body.rs index ccbee7c12ec4..e8980d3aec4b 100644 --- a/src/runtime/webcore/Body.rs +++ b/src/runtime/webcore/Body.rs @@ -21,6 +21,7 @@ use bun_core::String as BunString; use bun_core::{Utf8Bytes, WTFString, WTFStringImpl, WTFStringImplExt as _, WTFStringImplStruct}; use bun_jsc::JsCell; use bun_jsc::StringJsc as _; +use bun_jsc::SysErrorJsc as _; use bun_jsc::bun_string_jsc; /// Deref the `Value::WTFStringImpl` payload. @@ -971,9 +972,10 @@ impl Value { if let Some(form_data) = as_dom_form_data(value) { // SAFETY: shim returns a live JSC heap cell. - return Ok(Value::Blob(Blob::from_dom_form_data(global_this, unsafe { - &mut *form_data - }))); + return match Blob::from_dom_form_data(global_this, unsafe { &mut *form_data }) { + Ok(blob) => Ok(Value::Blob(blob)), + Err(err) => Err(err.throw(global_this)), + }; } if let Some(search_params) = as_url_search_params(value) { @@ -1625,6 +1627,17 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized { /// avoids deriving `&mut T` from `&T` at the call sites (UB). fn get_fetch_headers(&self) -> Option>; fn get_form_data_encoding(&self) -> JsResult>>; + /// Creates the header list if there is none yet, which copies the body's `Content-Type` into it. + fn ensure_headers(&self, global_this: &JSGlobalObject) -> JsResult<()>; + + /// Call before a reader takes the body: a used body or a stream no longer has the `Content-Type`. + fn copy_content_type_to_headers(&self, global_this: &JSGlobalObject) -> JsResult<()> { + if matches!(self.get_body_value(), Value::Blob(blob) if !blob.content_type_slice().is_empty()) + { + self.ensure_headers(global_this)?; + } + Ok(()) + } // ──────────────────────────────────────────────────────────────────── // Twin methods (identical for Request/Response). These were previously @@ -1769,6 +1782,7 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized { } } + self.copy_content_type_to_headers(global_object)?; let value = self.get_body_value(); let mut blob = value.use_as_any_blob_allow_non_utf8_string(); let result = JSPromise::wrap(global_object, |g| { @@ -1791,6 +1805,7 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized { return Ok(readable.value); } } + self.copy_content_type_to_headers(global_this)?; let stream = self .get_body_value() .to_readable_stream(&global_this.js_thread(context))?; @@ -1824,6 +1839,7 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized { // Step 2: null body → a new empty closed ReadableStream. // Steps 3-6: decode directly from the body's backing bytes. + self.copy_content_type_to_headers(global_this)?; let stream = self.get_body_value().to_text_readable_stream(&cx)?; if stream.is_null() { return ReadableStream::empty(global_this); @@ -1917,6 +1933,7 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized { } } + self.copy_content_type_to_headers(global_object)?; let value = self.get_body_value(); let mut blob = value.use_as_any_blob_allow_non_utf8_string(); let result = JSPromise::wrap(global_object, |g| { @@ -1972,6 +1989,7 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized { } } + self.copy_content_type_to_headers(global_object)?; // toArrayBuffer in AnyBlob checks for non-UTF8 strings let value = self.get_body_value(); let mut blob: AnyBlob = value.use_as_any_blob_allow_non_utf8_string(); @@ -2023,6 +2041,7 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized { } } + self.copy_content_type_to_headers(global_object)?; // toArrayBuffer in AnyBlob checks for non-UTF8 strings let value = self.get_body_value(); let mut blob: AnyBlob = value.use_as_any_blob_allow_non_utf8_string(); @@ -2079,6 +2098,7 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized { .reject()); }; + self.copy_content_type_to_headers(global_object)?; let value = self.get_body_value(); if let Value::Locked(_locked) = value { let owned_readable = self.get_body_readable_stream(); @@ -2180,6 +2200,7 @@ pub(crate) trait BodyMixin: BodyOwnerJs + Sized { } } + self.copy_content_type_to_headers(global_object)?; let value = self.get_body_value(); let blob_ptr = Blob::new(value.use_()); // SAFETY: `Blob::new` returns a freshly heap-allocated, ref-counted Blob. diff --git a/src/runtime/webcore/FormData.rs b/src/runtime/webcore/FormData.rs index c0d5a1eb8250..f12b139a1465 100644 --- a/src/runtime/webcore/FormData.rs +++ b/src/runtime/webcore/FormData.rs @@ -90,7 +90,7 @@ impl FormData { ) -> crate::Result { match encoding { Encoding::URLEncoded => { - let str = EncodedSlice::utf8(strings::without_utf8_bom(input)); + let str = EncodedSlice::utf8(input); // C++ may throw (e.g. string too long) — `create_from_url_query` // wraps the FFI in a validation scope and maps zero → JsError. DOMFormData::create_from_url_query(global, &str).map_err(|_| crate::Error::JSError) @@ -221,16 +221,8 @@ pub(crate) fn to_js_from_multipart_data( blob.detach(); wrap.threw = result.is_err(); } else { - let value = EncodedSlice::utf8( - // > Each part whose `Content-Disposition` header does not - // > contain a `filename` parameter must be parsed into an - // > entry whose value is the UTF-8 decoded without BOM - // > content of the part. This is done regardless of the - // > presence or the value of a `Content-Type` header and - // > regardless of the presence or the value of a - // > `charset` parameter. - strings::without_utf8_bom(value_str), - ); + // "UTF-8 decode without BOM" skips BOM handling, so a leading U+FEFF is data. + let value = EncodedSlice::utf8(value_str); wrap.threw = wrap.form.append(wrap.global, &key, &value).is_err(); } } @@ -307,7 +299,7 @@ pub(crate) fn for_each_multipart_entry( let mut filename: Option = None; let mut header_chunk = header; let mut is_file = false; - while !header_chunk.is_empty() && (filename.is_none() || name.len() == 0) { + while !header_chunk.is_empty() { let line_end = strings::index_of(header_chunk, b"\r\n") .ok_or(crate::Error::IsMissingHeaderLineEnd)?; let line = &header_chunk[..line_end]; diff --git a/src/runtime/webcore/Request.rs b/src/runtime/webcore/Request.rs index ca3ae8efdc8f..ef6fc1bb62ad 100644 --- a/src/runtime/webcore/Request.rs +++ b/src/runtime/webcore/Request.rs @@ -21,11 +21,11 @@ use bun_core::{String as BunString, strings}; use bun_http_jsc::fetch_enums_jsc::{ fetch_cache_mode_to_js, fetch_redirect_to_js, fetch_request_mode_to_js, }; -use bun_http_jsc::method_jsc::MethodJsc as _; +use bun_http_jsc::method_jsc::{self, MethodJsc as _}; use bun_http_types::FetchCacheMode::FetchCacheMode; use bun_http_types::FetchRedirect::FetchRedirect; use bun_http_types::FetchRequestMode::FetchRequestMode; -use bun_http_types::Method::Method; +use bun_http_types::Method::{Method, OwnedMethod}; use bun_jsc::AbortSignalRef; use bun_jsc::EncodedSliceJsc as _; use bun_jsc::StringJsc as _; @@ -96,10 +96,10 @@ pub(crate) struct Request { /// once before `Box::from_raw().drop()` (which would otherwise re-run it). body: ManuallyDrop, js_ref: JsCell, - pub method: Method, - pub(crate) flags: Flags, + pub method: OwnedMethod, pub(crate) request_context: AnyRequestContext, pub(crate) weak_ptr_data: WeakPtrData, + pub(crate) flags: Flags, // We must report a consistent value for this reported_estimated_size: Cell, } @@ -186,6 +186,14 @@ impl BodyMixin for Request { ) -> bun_jsc::JsResult>> { Request::get_form_data_encoding(self) } + #[inline] + fn ensure_headers(&self, global_this: &JSGlobalObject) -> bun_jsc::JsResult<()> { + if self.headers.get().is_none() { + self.ensure_fetch_headers(global_this)? + .set_implied_by_body(true); + } + Ok(()) + } } // ─── header accessors & simple getters ────────────────────────────────────── @@ -243,9 +251,10 @@ impl Request { &self, global_this: &JSGlobalObject, ) -> JsResult<&mut HeadersRef> { - if self.headers.get().is_some() { + if let Some(headers) = self.headers_mut() { // headers is already set - return Ok(self.headers_mut().as_mut().unwrap()); + headers.set_implied_by_body(false); + return Ok(headers); } if let Some(req) = self.request_context.get_request() { @@ -379,12 +388,9 @@ impl Request { .set_cookies(cookie_map.map(|c| std::ptr::from_ref::(c).cast_mut())); } - /// `BunRequest.prototype.clone` (the `Bun.serve` `routes:` subclass) goes - /// through `JSBunRequest::clone` -> here, not through [`Self::do_clone`], - /// so it needs the same fetch-spec step-1 usability check. + /// `BunRequest.prototype.clone` (`JSBunRequest::clone`) calls this, not [`Self::do_clone`]. #[bun_uws::uws_callback(export = "Request__clone")] pub(crate) fn ffi_clone(&self, global_this: &JSGlobalObject) -> Option> { - self.throw_if_body_unusable(global_this).ok()?; // `BunRequest.prototype.clone`, a C++ host function, calls this. let cx = global_this.js_thread_of_caller_no_frame(); self.clone(&cx).ok() @@ -418,7 +424,7 @@ impl Request { url: BunString, headers: Option, body: BodyHiveHandle, - method: Method, + method: OwnedMethod, ) -> Request { Request { url: JsCell::new(url), @@ -560,7 +566,7 @@ impl Request { )?; // Wire-form token (e.g. "M-SEARCH"), not the Rust Debug variant identifier. - writer.write_str(self.method.as_str())?; + write!(writer, "{}", bstr::BStr::new(self.method.as_bytes()))?; writer.write_str("\"")?; formatter .print_comma::<_, ENABLE_ANSI_COLORS>(writer) @@ -701,7 +707,10 @@ impl Request { } pub(crate) fn get_method(&self, global_this: &JSGlobalObject) -> JSValue { - self.method.to_js(global_this) + match &self.method { + OwnedMethod::Known(method) => method.to_js(global_this), + OwnedMethod::Token(token) => bun_core::EncodedSlice::latin1(token).to_js(global_this), + } } pub(crate) fn get_mode(&self, global_this: &JSGlobalObject) -> JSValue { @@ -993,7 +1002,7 @@ impl Request { signal: JsCell::new(None), body: ManuallyDrop::new(body), js_ref: JsCell::new(JsRef::init_weak(this_value)), - method: Method::GET, + method: Method::GET.into(), flags: Flags::default(), request_context: AnyRequestContext::NULL, weak_ptr_data: WeakPtrData::EMPTY, @@ -1028,12 +1037,21 @@ impl Request { } if arguments.is_empty() { - bail!(Err(cx.global().throw(format_args!( + bail!(Err(cx.global().throw_type_error(format_args!( "Failed to construct 'Request': 1 argument required, but only 0 present." )))); } else if arguments[0].is_empty_or_undefined_or_null() || !arguments[0].is_cell() { - bail!(Err(cx.global().throw(format_args!( - "Failed to construct 'Request': expected non-empty string or object, got undefined" + bail!(Err(cx.global().throw_type_error(format_args!( + "Failed to construct 'Request': expected non-empty string or object, got {}", + if arguments[0].is_null() { + "null" + } else if arguments[0].is_number() { + "number" + } else if arguments[0].is_boolean() { + "boolean" + } else { + "undefined" + } )))); } @@ -1058,7 +1076,7 @@ impl Request { fields.insert(Fields::Url); } } else if !url_or_object_type.is_object() { - bail!(Err(cx.global().throw(format_args!( + bail!(Err(cx.global().throw_type_error(format_args!( "Failed to construct 'Request': expected non-empty string or object" )))); } @@ -1080,13 +1098,15 @@ impl Request { let values_to_try = &values_to_try_[0..((!is_first_argument_a_url) as usize + (arguments.len() > 1 && arguments[1].is_object()) as usize)]; - for &value in values_to_try { + for (i, &value) in values_to_try.iter().enumerate() { let value_type = value.js_type(); - let explicit_check = values_to_try.len() == 2 - && value_type == bun_jsc::JSType::FinalObject - && values_to_try[1].js_type() == bun_jsc::JSType::DOMWrapper; + // `values_to_try` is `[init?, input?]`. + let is_input = !is_first_argument_a_url && i == values_to_try.len() - 1; + // A pristine Request or Response is copied without calling its getters. + let mut copied_internal_fields = false; if value_type == bun_jsc::JSType::DOMWrapper { if let Some(request) = value.as_direct::() { + copied_internal_fields = true; // SAFETY: as_direct returns a live *mut Request payload (m_ctx) let request = unsafe { &*request }; if values_to_try.len() == 1 { @@ -1105,7 +1125,7 @@ impl Request { } if !fields.contains(Fields::Method) { - req.method = request.method; + req.method = request.method.clone(); fields.insert(Fields::Method); } @@ -1137,8 +1157,11 @@ impl Request { if !fields.contains(Fields::Body) { match request.body_value() { - BodyValue::Null | BodyValue::Empty | BodyValue::Used => {} + BodyValue::Null => {} _ => { + if let Err(e) = request.throw_if_body_unusable(cx.global()) { + bail!(Err(e)); + } match request.clone_body_value_via_cached_stream(cx) { Ok(v) => { *req.body_value_mut() = v; @@ -1152,10 +1175,12 @@ impl Request { } if let Some(response) = value.as_direct::() { + copied_internal_fields = true; // SAFETY: `as_direct` returned a live `*mut Response` owned by the JS wrapper. let response = unsafe { &mut *response }; - if !fields.contains(Fields::Method) { - req.method = response.get_method(); + // A Response has no `method` property, so as `init` it can't set one. + if is_input && !fields.contains(Fields::Method) { + req.method = response.get_method().into(); fields.insert(Fields::Method); } @@ -1185,8 +1210,11 @@ impl Request { if !fields.contains(Fields::Body) { match response.get_body_value() { - BodyValue::Null | BodyValue::Empty | BodyValue::Used => {} + BodyValue::Null => {} _ => { + if let Err(e) = response.throw_if_body_unusable(cx.global()) { + bail!(Err(e)); + } match response.clone_body_value_via_cached_stream(cx) { Ok(v) => { *req.body_value_mut() = v; @@ -1227,11 +1255,6 @@ impl Request { Ok(None) => {} Err(e) => bail!(Err(e)), } - - // BodyValue::from_js() throws without returning Err; see Blob::from_dom_form_data - if cx.global().has_exception() { - bail!(Err(JsError::Thrown)); - } } if !fields.contains(Fields::Url) { @@ -1300,39 +1323,31 @@ impl Request { } } - if !fields.contains(Fields::Method) || !fields.contains(Fields::Headers) { - match crate::webcore::response::Init::init(cx.global(), value) { - Ok(Some(response_init)) => { - let header_check = !explicit_check - || (explicit_check - && match value.fast_get(cx.global(), bun_jsc::BuiltinName::Headers) - { - Ok(v) => v.is_some(), - Err(e) => bail!(Err(e)), - }); - if header_check { - if let Some(headers) = response_init.headers { - if !fields.contains(Fields::Headers) { - req.headers.set(Some(headers)); - fields.insert(Fields::Headers); - } else { - drop(headers); // headers.deref() - } + if !copied_internal_fields && !fields.contains(Fields::Headers) { + match value.fast_get(cx.global(), bun_jsc::BuiltinName::Headers) { + Ok(Some(headers_)) => { + match HeadersRef::from_init_value(cx.global(), headers_) { + Ok(Some(headers)) => { + req.headers.set(Some(headers)); + fields.insert(Fields::Headers); } + Ok(None) => {} + Err(e) => bail!(Err(e)), } + } + Ok(None) => {} + Err(e) => bail!(Err(e)), + } + } - let method_check = !explicit_check - || (explicit_check - && match value.fast_get(cx.global(), bun_jsc::BuiltinName::Method) { - Ok(v) => v.is_some(), - Err(e) => bail!(Err(e)), - }); - if method_check { - if !fields.contains(Fields::Method) { - req.method = response_init.method; - fields.insert(Fields::Method); - } + if !copied_internal_fields && !fields.contains(Fields::Method) { + match value.fast_get(cx.global(), bun_jsc::BuiltinName::Method) { + Ok(Some(method_)) => { + match method_jsc::request_method_from_js(cx.global(), method_) { + Ok(method) => req.method = method, + Err(e) => bail!(Err(e)), } + fields.insert(Fields::Method); } Ok(None) => {} Err(e) => bail!(Err(e)), @@ -1377,7 +1392,7 @@ impl Request { } if req.url.get().is_empty() { - bail!(Err(cx.global().throw(format_args!( + bail!(Err(cx.global().throw_type_error(format_args!( "Failed to construct 'Request': url is required." )))); } @@ -1454,7 +1469,6 @@ impl Request { global_this: &JSGlobalObject, callframe: &CallFrame, ) -> JsResult { - self.throw_if_body_unusable(global_this)?; let this_value = callframe.this(); let cloned = self.clone(&global_this.js_thread_of_caller(callframe))?; @@ -1471,6 +1485,8 @@ impl Request { cx: &bun_jsc::JsThread<'_>, preserve_url: bool, ) -> JsResult<()> { + // `clone()`, `new Request(request)` and `server.fetch(request)` all copy through here. + self.throw_if_body_unusable(cx.global())?; // allocator param dropped (global mimalloc) let _ = self.ensure_url(); let body_ = self.clone_body_value_via_cached_stream(cx)?; @@ -1484,6 +1500,9 @@ impl Request { } else { self.url.get().clone() }; + let method = self.method.clone(); + // The write below doesn't drop the old `req.method`, so it can't own a token. + debug_assert!(req.method.known().is_some()); // `ptr::write` is a raw bit-overwrite — no destructors run on the old // `*req`, so the Drop impl on `JsRef` doesn't fire on the caller's @@ -1504,7 +1523,7 @@ impl Request { signal: JsCell::new(None), body: ManuallyDrop::new(body), js_ref: JsCell::new(JsRef::empty()), - method: self.method, + method, flags: self.flags, request_context: AnyRequestContext::NULL, weak_ptr_data: WeakPtrData::EMPTY, @@ -1536,7 +1555,7 @@ impl Request { BodyHiveHandle::from_raw(NonNull::dangling().as_ptr()) }), js_ref: JsCell::new(JsRef::empty()), - method: Method::GET, + method: Method::GET.into(), flags: Flags::default(), request_context: AnyRequestContext::NULL, weak_ptr_data: WeakPtrData::EMPTY, @@ -1562,7 +1581,7 @@ impl Request { signal: JsCell::new(signal), body: ManuallyDrop::new(body), js_ref: JsCell::new(JsRef::empty()), - method, + method: method.into(), flags: Flags { https, ..Flags::default() diff --git a/src/runtime/webcore/Response.rs b/src/runtime/webcore/Response.rs index cfb5970bc8fd..a60d5641e00d 100644 --- a/src/runtime/webcore/Response.rs +++ b/src/runtime/webcore/Response.rs @@ -75,6 +75,22 @@ impl HeadersRef { Ok(FetchHeaders::create_from_js(global, value)?.map(|p| unsafe { Self::adopt(p) })) } + /// Converts a `RequestInit` or `ResponseInit` `headers` member. Returns `None` if it's empty. + pub(crate) fn from_init_value( + global: &JSGlobalObject, + value: JSValue, + ) -> JsResult> { + let Some(orig) = FetchHeaders::cast_as_init(value) else { + return Self::create_from_js(global, value); + }; + let orig = bun_opaque::opaque_deref_mut(orig.as_ptr()); + if orig.is_empty() { + return Ok(None); + } + // SAFETY: `clone_this` returns a +1 ref or null. + Ok(orig.clone_this(global)?.map(|p| unsafe { Self::adopt(p) })) + } + /// `FetchHeaders.cloneThis(global)` — deep copy on the C++ side. #[inline] pub(crate) fn clone_this(&self, global: &JSGlobalObject) -> JsResult> { @@ -210,6 +226,8 @@ pub(crate) struct Response { init: JsCell, url: JsCell, redirected: Cell, + /// fetch() decoded the body, so Bun.serve must not forward the `Content-Encoding` header. + body_decoded: Cell, /// The JS wrapper, fetch (so a discarded JS Response can still resolve /// its body) and HTMLRewriter each hold a ref. ref_count: Cell, @@ -234,6 +252,7 @@ impl Default for Response { init: JsCell::new(Init::default()), url: JsCell::new(BunString::EMPTY), redirected: Cell::new(false), + body_decoded: Cell::new(false), ref_count: Cell::new(1), weak_ptr_data: WeakPtrData::EMPTY, js_ref: JsCell::new(JsRef::empty()), @@ -303,6 +322,14 @@ impl BodyMixin for Response { ) -> bun_jsc::JsResult>> { Response::get_form_data_encoding(self) } + #[inline] + fn ensure_headers(&self, global_this: &JSGlobalObject) -> bun_jsc::JsResult<()> { + if self.init.get().headers.is_none() { + self.get_or_create_headers(global_this)? + .set_implied_by_body(true); + } + Ok(()) + } } impl Response { @@ -404,6 +431,16 @@ impl Response { self.init.with_mut(|init| init.headers.take()) } + #[inline] + pub(crate) fn body_decoded(&self) -> bool { + self.body_decoded.get() + } + + #[inline] + pub(crate) fn set_body_decoded(&self, value: bool) { + self.body_decoded.set(value); + } + #[inline] pub(crate) fn get_method(&self) -> Method { self.init.get().method @@ -549,11 +586,16 @@ impl Response { this: &Self, global_this: &JSGlobalObject, ) -> JsResult { - if this.init.get().status_code < 200 { - return Ok(global_this.common_strings().error()); + // https://fetch.spec.whatwg.org/#concept-response-type + let common_strings = global_this.common_strings(); + if this.init.get().status_code == 0 { + return Ok(common_strings.error()); } - - Ok(global_this.common_strings().default()) + // Only a fetched response has a URL. Bun has no origin, so "cors" and "opaque" never apply. + if !this.url.get().is_empty() { + return Ok(common_strings.basic()); + } + Ok(common_strings.default()) } pub(crate) fn get_status_text(this: &Self, global_this: &JSGlobalObject) -> JsResult { @@ -599,7 +641,9 @@ impl Response { } } - Ok(init.headers.as_mut().unwrap()) + let headers = init.headers.as_mut().unwrap(); + headers.set_implied_by_body(false); + Ok(headers) } pub(crate) fn get_headers(this: &Self, global_this: &JSGlobalObject) -> JsResult { @@ -797,6 +841,7 @@ impl Response { init: JsCell::new(init), url: JsCell::new(self.url.get().clone()), redirected: Cell::new(self.redirected.get()), + body_decoded: Cell::new(self.body_decoded.get()), ..Default::default() }) } @@ -871,35 +916,22 @@ impl Response { let json_value = args.next_eat().unwrap_or_default(); if !json_value.is_empty() { - // Validate top-level values that are not JSON serializable (Node.js compatibility) - if json_value.is_undefined() - || json_value.is_symbol() - || json_value.js_type() == bun_jsc::JSType::JSFunction - { - let err = global_this - .create_type_error_instance(format_args!("Value is not JSON serializable")); - return Err(global_this.throw_value(err)); - } - - // BigInt has a different error message to match Node.js exactly - if json_value.is_big_int() { - let err = global_this.create_type_error_instance(format_args!( - "Do not know how to serialize a BigInt" - )); - return Err(global_this.throw_value(err)); - } - // Use jsonStringifyFast which passes undefined for the space parameter, // triggering JSC's FastStringifier optimization. This is significantly faster // than jsonStringify which passes 0 for space and uses the slower Stringifier. let str = json_value.json_stringify_fast(global_this)?; - if !str.is_empty() { - debug_assert!(str.tag() == bun_core::Tag::WTFStringImpl); - let value = &response.body.get().value; - value.set(BodyValue::WTFStringImpl(str.leak_wtf_impl())); - value.with_mut(|v| v.to_blob_if_possible()); + // An empty string means `JSON.stringify` returned undefined. + if str.is_empty() { + return Err( + global_this.throw_type_error(format_args!("Value is not JSON serializable")) + ); } + + debug_assert!(str.tag() == bun_core::Tag::WTFStringImpl); + let value = &response.body.get().value; + value.set(BodyValue::WTFStringImpl(str.leak_wtf_impl())); + value.with_mut(|v| v.to_blob_if_possible()); } if let Some(arg_init) = args.next_eat() { @@ -991,15 +1023,20 @@ impl Response { let status = Self::validate_redirect_status_code(global_this, arg_init.to_int32())?; response.init.with_mut(|i| i.status_code = status); - } else if let Some(init) = Init::init(global_this, arg_init)? { - // cleanup is handled by Init's drop glue on `?` below + } else if let Some(init) = + Init::init_with_default_status(global_this, arg_init, 302)? + { + if init.status_code != 200 { + let status = i32::from(init.status_code); + Self::validate_redirect_status_code(global_this, status)?; + } response.init.set(init); - - let status = response.init.get().status_code; - if status != 200 { - let status = - Self::validate_redirect_status_code(global_this, i32::from(status))?; - response.init.with_mut(|i| i.status_code = status); + } else if arg_init.is_string() { + // "307" is 307. A string that isn't a redirect status is ignored. + if let status @ (301 | 302 | 303 | 307 | 308) = + arg_init.coerce_to_i32(global_this)? + { + response.init.with_mut(|i| i.status_code = status as u16); } } } @@ -1136,11 +1173,6 @@ impl Response { // error returns below release the extracted body payload. let body = scopeguard::guard(body, |b| b.reset()); - // extract() throws without returning Err; see Blob::from_dom_form_data - if global_this.has_exception() { - return Err(bun_jsc::JsError::Thrown); - } - // Perform the only remaining fallible op BEFORE heap-allocating: // doing it on stack locals lets `?` trigger the scopeguard and // `init`'s drop glue and avoids leaking the heap allocation entirely. @@ -1180,8 +1212,7 @@ impl Response { // We deliberately do NOT `impl Drop for Init` so struct-update // syntax (`Init { status_code: x, ..Default::default() }`) and partial moves -// (e.g. Request::construct_into reading `response_init.headers`) keep working; -// the fields' own drop glue releases `headers` and `status_text`. +// keep working; the fields' own drop glue releases `headers` and `status_text`. pub(crate) struct Init { pub(crate) headers: Option, pub(crate) status_code: u16, @@ -1220,9 +1251,18 @@ impl Init { pub(crate) fn init( global_this: &JSGlobalObject, response_init: JSValue, + ) -> JsResult> { + Self::init_with_default_status(global_this, response_init, 200) + } + + /// Returns `None` if `response_init` is not an object. + fn init_with_default_status( + global_this: &JSGlobalObject, + response_init: JSValue, + default_status: u16, ) -> JsResult> { let mut result = Init { - status_code: 200, + status_code: default_status, ..Default::default() }; @@ -1249,7 +1289,7 @@ impl Init { result.headers = headers.clone_this(global_this)?; } - result.method = req.method; + result.method = req.method.known().unwrap_or(Method::GET); return Ok(Some(result)); } @@ -1262,23 +1302,7 @@ impl Init { } if let Some(headers) = response_init.fast_get(global_this, BuiltinName::headers)? { - // `JSValue::as_::()` requires `JsClass`; - // FetchHeaders is a hand-bound opaque, so use its dedicated - // `cast()` (wraps `WebCore__FetchHeaders__cast_`). - if let Some(orig) = FetchHeaders::cast(headers) { - // `orig` is a live `WebCore::FetchHeaders*` borrowed from JS; - // `FetchHeaders` is an opaque ZST FFI handle (S008) — safe deref. - let orig = bun_opaque::opaque_deref_mut(orig.as_ptr()); - if !orig.is_empty() { - result.headers = orig.clone_this(global_this)?.map(|p| { - // SAFETY: `clone_this` returns a fresh +1-ref'd `FetchHeaders*`; - // ownership of that ref is transferred into the `HeadersRef`. - unsafe { HeadersRef::adopt(p) } - }); - } - } else { - result.headers = HeadersRef::create_from_js(global_this, headers)?; - } + result.headers = HeadersRef::from_init_value(global_this, headers)?; } if let Some(status_value) = response_init.fast_get(global_this, BuiltinName::status)? { diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index f31aedd4a5eb..a653be0af6c8 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -50,18 +50,20 @@ use crate::webcore::jsc::{ }; use bun_core::{String as BunString, Tag as BunStringTag}; use bun_http::http_request_body::StreamFraming; -use bun_http::{self as http, FetchRedirect, Headers, HeadersExt as _, MimeType}; +use bun_http::{self as http, FetchRedirect, Headers, HeadersExt as _}; use bun_http_jsc::method_jsc; -use bun_http_types::Method::Method; -use bun_jsc::{HTTPHeaderName, StringJsc as _, SysErrorJsc as _, URLJsc as _}; +use bun_http_types::Method::{Method, OwnedMethod}; +use bun_jsc::{HTTPHeaderName, StringJsc as _, URLJsc as _}; use bun_sys::FdExt as _; // `FromJsEnum for FetchRedirect` lives in bun_http_jsc; importing the impl crate // brings the trait impl into scope for `JSValue::get_optional_enum::`. use crate::node; use crate::node::types::PathLikeExt as _; use crate::node::types::{Encoding, PathOrFileDescriptor}; -use crate::webcore::blob::BlobExt as _; -use crate::webcore::body::{Action as BodyValueLockedAction, InternalBlob, Value as BodyValue}; +use crate::webcore::blob::{BlobContentType, BlobExt as _}; +use crate::webcore::body::{ + Action as BodyValueLockedAction, BodyMixin, InternalBlob, Value as BodyValue, +}; use crate::webcore::headers_ref::any_blob_content_type_opt; use crate::webcore::response::HeadersRef; use crate::webcore::s3::client as s3; @@ -75,7 +77,7 @@ use bun_jsc::AbortSignalRef; #[cfg(windows)] use bun_paths::resolve_path::PosixToWinNormalizer; use bun_picohttp as picohttp; -use bun_resolver::data_url::DataURL; +use bun_resolver::data_url::{DataURL, FetchDataURL}; use bun_s3_signing::{SignOptions, SignResult}; use bun_url::PercentEncoding; use bun_url::URL as ZigURL; @@ -162,31 +164,37 @@ impl HTTPRequestBodyExt for HTTPRequestBody { // dataURLResponse // ────────────────────────────────────────────────────────────────────────── -fn data_url_response(url: BunString, global_this: &JSGlobalObject) -> JSValue { - let blob = { - let url_utf8 = url.to_utf8(); - match DataURL::parse_without_check(url_utf8.slice()) - .ok() - .and_then(|data_url| { - let blob = Blob::init(data_url.decode_data().ok()?, global_this); - let mime_type = MimeType::MimeType::init(data_url.mime_type, true, None); - blob.content_type - .set(crate::webcore::blob::BlobContentType::from(mime_type)); - Some(blob) - }) { - Some(blob) => blob, - None => { - let err = - global_this.create_error_instance(format_args!("failed to fetch the data URL")); - return JSPromise::rejected_promise(global_this, err).to_js(); - } - } +/// `url` is the serialized URL without its fragment. +fn data_url_response(url: BunString, global_this: &JSGlobalObject) -> JsResult { + let Some(FetchDataURL { mime_type, body }) = DataURL::process_for_fetch(url.to_utf8().slice()) + else { + let err = global_this.to_type_error( + jsc::ErrorCode::INVALID_URL, + format_args!("failed to fetch the data URL"), + ); + return Ok(JSPromise::rejected_promise(global_this, err).to_js()); }; + // Set `Content-Type` now. The lazy copy from the body's Blob is lost once the body is consumed. + let mut headers = HeadersRef::create_empty(); + headers.put( + HTTPHeaderName::ContentType, + &BunString::borrow_utf8(&mime_type), + global_this, + )?; + + let blob = Blob::init(body, global_this); + // `Blob.type` is lowercase. + blob.content_type + .set(BlobContentType::Owned(std::sync::Arc::from( + mime_type.to_ascii_lowercase(), + ))); + let response = bun_core::heap::into_raw(Box::new(Response::init( response::Init { status_code: 200, status_text: BunString::create_atom(b"OK"), + headers: Some(headers), ..Default::default() }, Body::new(BodyValue::Blob(blob)), @@ -197,12 +205,39 @@ fn data_url_response(url: BunString, global_this: &JSGlobalObject) -> JSValue { // Ownership of the boxed Response is transferred to the JS GC via // `make_maybe_pooled` (which stores the raw `*mut Response` in the wrapper // and finalizes it). Dropping a `Box` here would be a UAF. - JSPromise::resolved_promise_value( + Ok(JSPromise::resolved_promise_value( global_this, // SAFETY: `response` is a freshly allocated heap `Response`; ownership // transfers to JSC. Response::make_maybe_pooled(global_this, response), - ) + )) +} + +/// `fetch()` rejects with a `TypeError` that keeps the system error's `code`, `syscall` and `path`. +fn system_type_error(err: &bun_sys::Error, global_this: &JSGlobalObject) -> JSValue { + jsc::SystemError::from(err.to_system_error()).to_type_error_instance(global_this) +} + +/// Returns the `TypeError` that `fetch("file:...")` rejects with if reading `file_blob` can only fail. +fn file_url_unreadable_error(file_blob: &Blob, global_this: &JSGlobalObject) -> Option { + // A file embedded in a standalone executable is a byte store and isn't on disk. + let blob::store::Data::File(file) = &file_blob.store()?.data else { + return None; + }; + let PathOrFileDescriptor::Path(path) = &file.pathlike else { + return None; + }; + + let mut path_buf = bun_paths::path_buffer_pool::get(); + // `stat`, not `open`: opening a FIFO blocks until it has a writer. + let err = match bun_sys::stat(path.slice_z(&mut path_buf)) { + Ok(stat) if bun_sys::S::ISDIR(stat.st_mode as _) => { + bun_sys::Error::from_code(bun_sys::E::EISDIR, bun_sys::Tag::read) + } + Ok(_) => return None, + Err(err) => err, + }; + Some(system_type_error(&err.with_path(path.slice()), global_this)) } // ────────────────────────────────────────────────────────────────────────── @@ -262,7 +297,7 @@ fn bun_fetch_preconnect( }; } - if !url.is_http() && !url.is_https() && !url.is_s3() { + if !url.is_http() && !url.is_https() { reclaim_href!(); return Err( global_object.throw_invalid_arguments(format_args!("URL must be HTTP or HTTPS")) @@ -551,7 +586,11 @@ fn fetch_impl( } } - break 'extract_url BunString::EMPTY; + let err = ctx.to_type_error( + jsc::ErrorCode::INVALID_URL, + format_args!("fetch() URL is invalid"), + ); + return Ok(JSPromise::rejected_promise(global_this, err).to_js()); }; if url_str.is_empty() { @@ -562,24 +601,26 @@ fn fetch_impl( return Ok(JSPromise::rejected_promise(global_this, err).to_js()); } - if url_str.starts_with_ascii(b"data:") { - return Ok(data_url_response(url_str, global_this)); + // A fetch never uses the fragment, and `Response.url` excludes it. + let mut href = bun_url::href_from_string_without_fragment(&url_str); + if href.starts_with_ascii(b"s3:") { + // `#` is part of an S3 key. + href = bun_url::href_from_string(&url_str); + } + if href.tag() == BunStringTag::Dead { + let err = ctx.to_type_error( + jsc::ErrorCode::INVALID_URL, + format_args!("fetch() URL is invalid"), + ); + return Ok(JSPromise::rejected_promise(global_this, err).to_js()); } - // `ZigURL::from_string` returns `OwnedURL` (owns href buffer); we - // immediately move that buffer into `url_proxy_buffer` and re-parse `url` to - // borrow it. - let owned_url = match ZigURL::from_string(&url_str) { - Ok(u) => u, - Err(_) => { - let err = ctx.to_type_error( - jsc::ErrorCode::INVALID_URL, - format_args!("fetch() URL is invalid"), - ); - return Ok(JSPromise::rejected_promise(global_this, err).to_js()); - } - }; - let mut url_proxy_buffer = owned_url.into_href().into_vec(); + if href.starts_with_ascii(b"data:") { + return data_url_response(href, global_this); + } + + // Move the href into `url_proxy_buffer` and re-parse `url` to borrow it. + let mut url_proxy_buffer = href.to_owned_slice(); let mut url = parse_url_detached!(&url_proxy_buffer[..]); if url.is_file() { url_type = URLType::File; @@ -590,26 +631,25 @@ fn fetch_impl( // **Start with the harmless ones.** // "method" - let mut method = 'extract_method: { + let mut method: OwnedMethod = 'extract_method: { if let Some(options) = options_object { if let Some(method_) = options.fast_get_truthy(global_this, jsc::BuiltinName::method)? { - break 'extract_method method_jsc::from_js(global_this, method_)?; + break 'extract_method method_jsc::request_method_from_js(global_this, method_)?; } } if let Some(req) = request_mut!() { - break 'extract_method Some(req.method); + break 'extract_method req.method.clone(); } if let Some(req) = request_init_object { if let Some(method_) = req.fast_get_truthy(global_this, jsc::BuiltinName::method)? { - break 'extract_method method_jsc::from_js(global_this, method_)?; + break 'extract_method method_jsc::request_method_from_js(global_this, method_)?; } } - break 'extract_method None; - } - .unwrap_or(Method::GET); + Method::GET.into() + }; // "decompress: boolean" disable_decompression = 'extract_disable_decompression: { @@ -775,6 +815,12 @@ fn fetch_impl( if let Some(path) = parse_unix(vm, global_this, socket_path)? { break 'extract_unix_socket_path path; } + // Ignoring it would send the request to the URL's host over TCP. + if !socket_path.is_string() && socket_path.to_boolean() { + return Err(global_this.throw_invalid_arguments(format_args!( + "fetch: 'unix' must be a string" + ))); + } } } } @@ -1130,6 +1176,7 @@ fn fetch_impl( } } + BodyMixin::copy_content_type_to_headers(&*req, global_this)?; break 'extract_body Some(HTTPRequestBody::AnyBlob( req.get_body_value().use_as_any_blob(), )); @@ -1150,11 +1197,6 @@ fn fetch_impl( } .unwrap_or_default(); - // HTTPRequestBody::from_js() throws without returning Err; see Blob::from_dom_form_data - if global_this.has_exception() { - return Err(jsc::JsError::Thrown); - } - // headers: Headers | undefined; headers = 'extract_headers: { // Holds the +1 from `create_from_js` until this block exits. @@ -1166,7 +1208,7 @@ fn fetch_impl( options.fast_get(global_this, jsc::BuiltinName::Headers)? { if !headers_value.is_undefined() { - if let Some(headers__) = FetchHeaders::cast(headers_value) { + if let Some(headers__) = FetchHeaders::cast_as_init(headers_value) { // `FetchHeaders` is an opaque ZST FFI handle (S008) — safe deref. if bun_opaque::opaque_deref_mut(headers__.as_ptr()).is_empty() { break 'brk None; @@ -1195,7 +1237,7 @@ fn fetch_impl( options.fast_get(global_this, jsc::BuiltinName::Headers)? { if !headers_value.is_undefined() { - if let Some(headers__) = FetchHeaders::cast(headers_value) { + if let Some(headers__) = FetchHeaders::cast_as_init(headers_value) { // `FetchHeaders` is an opaque ZST FFI handle (S008) — safe deref. if bun_opaque::opaque_deref_mut(headers__.as_ptr()).is_empty() { break 'brk None; @@ -1439,7 +1481,11 @@ fn fetch_impl( crate::webcore::node_types::PathLike::owned(temp_file_path.to_vec()), ); - break 'blob Blob::find_or_create_file_from_path(&mut pathlike, global_this, true); + let file_blob = Blob::find_or_create_file_from_path(&mut pathlike, global_this, true); + if let Some(err) = file_url_unreadable_error(&file_blob, global_this) { + return Ok(JSPromise::rejected_promise(global_this, err).to_js()); + } + break 'blob file_blob; }; let response = bun_core::heap::into_raw(Box::new(Response::init( @@ -1475,7 +1521,11 @@ fn fetch_impl( // WHATWG Fetch step 36 forbids a body for GET/HEAD; Bun additionally // rejects TRACE (RFC 9110 §9.3.8 "MUST NOT send content") since it does // not enforce forbidden methods. has_request_body() encodes exactly that. - if !ALLOW_GET_BODY && !method.has_request_body() && body.has_body() && !upgraded_connection { + if !ALLOW_GET_BODY + && method.known().is_some_and(|verb| !verb.has_request_body()) + && body.has_body() + && !upgraded_connection + { let err = global_this.to_type_error( jsc::ErrorCode::INVALID_ARG_VALUE, format_args!("fetch() request with GET/HEAD method cannot have body."), @@ -1493,7 +1543,7 @@ fn fetch_impl( } } - if headers.is_none() && body.has_body() && body.has_content_type_from_user() { + if headers.is_none() && body.has_content_type_from_user() { headers = Some(from_fetch_headers( global_this, None, @@ -1559,7 +1609,13 @@ fn fetch_impl( let opened_fd_res: bun_sys::Result = { let store = body.store().expect("needs_to_read_file implies store"); match &store.data.as_file().pathlike { - PathOrFileDescriptor::Fd(fd) => bun_sys::dup(*fd), + // On Windows `read_file` would wrap dup's HANDLE in a CRT fd that the close below never frees. + PathOrFileDescriptor::Fd(fd) => bun_sys::dup(*fd).and_then(|duped| { + duped.make_lib_uv_owned_for_syscall( + bun_sys::Tag::dup, + bun_sys::ErrorCase::CloseOnFail, + ) + }), PathOrFileDescriptor::Path(path) => { let zpath = path.slice_z(&mut open_path_buf); let flags = if cfg!(windows) { @@ -1574,7 +1630,7 @@ fn fetch_impl( let opened_fd = match opened_fd_res { Err(err) => { - let err_js = err.to_js(global_this); + let err_js = system_type_error(&err, global_this); let rejected_value = JSPromise::rejected_promise(global_this, err_js).to_js(); return Ok(rejected_value); } @@ -1601,12 +1657,9 @@ fn fetch_impl( Err(_) => break 'use_sendfile, }; - #[cfg(target_os = "macos")] - { - // macOS only supports regular files for sendfile() - if !bun_sys::S::ISREG(stat.st_mode as u32) { - break 'use_sendfile; - } + // A redirect sends the body again from its first byte, so the fd has to be seekable. + if !bun_sys::S::ISREG(stat.st_mode as u32) { + break 'use_sendfile; } // if it's < 32 KB, it's not worth it @@ -1627,26 +1680,30 @@ fn fetch_impl( content_size: original_size.min(stat_size) as usize, }; - if bun_sys::S::ISREG(stat.st_mode as u32) { - let stat_size_usize = stat_size as usize; - sf.offset = sf.offset.min(stat_size_usize); - sf.remain = sf - .remain - .max(sf.offset) - .min(stat_size_usize) - .saturating_sub(sf.offset); - // `remain` is now the exact byte count we will send (the slice - // window clamped to the file); that is the Content-Length. - sf.content_size = sf.remain; - } + let stat_size_usize = stat_size as usize; + sf.offset = sf.offset.min(stat_size_usize); + sf.remain = sf + .remain + .max(sf.offset) + .min(stat_size_usize) + .saturating_sub(sf.offset); + // `remain` is now the slice window clamped to the file, which is the Content-Length. + sf.content_size = sf.remain; body.detach(); + + // macOS and FreeBSD read a sendfile(2) length of 0 as "until end of file". + if sf.remain == 0 { + opened_fd.close(); + break 'prepare_body; + } + body = HTTPRequestBody::Sendfile(sf); break 'prepare_body; } } - // The sendfile path above moves `opened_fd` into `SendFile` (which + // The sendfile path above closes `opened_fd` or moves it into `SendFile` (which // owns its lifecycle and breaks out of `'prepare_body`). On this // read-file path we are the sole owner of the fresh fd: `read_file` // is handed it as an `Fd` and never takes ownership. Wrap it in an @@ -1675,8 +1732,8 @@ fn fetch_impl( match res { Err(err) => { - let rejected_value = - JSPromise::rejected_promise(global_this, err.to_js(global_this)).to_js(); + let err_js = system_type_error(&err, global_this); + let rejected_value = JSPromise::rejected_promise(global_this, err_js).to_js(); body.detach(); return Ok(rejected_value); } @@ -1766,7 +1823,7 @@ fn fetch_impl( // we cannot direct stream to s3 we need to use multi part upload // `defer body.ReadableStream.deinit()` → Drop on `body` scope exit. - if method != Method::PUT && method != Method::POST { + if !matches!(method.known(), Some(Method::PUT | Method::POST)) { return Ok(JSPromise::rejected_promise( global_this, global_this.create_error_instance(format_args!( @@ -1835,14 +1892,23 @@ fn fetch_impl( // url/url_proxy_buffer ownership moved into s3_stream above. return Ok(promise_value); } - if method == Method::POST { - method = Method::PUT; - } + let sign_method = match method.known() { + Some(Method::POST) => Method::PUT, + Some(verb) => verb, + None => { + return Ok(JSPromise::rejected_promise( + global_this, + s3::get_js_sign_error(bun_s3_signing::Error::InvalidMethod, global_this), + ) + .to_js()); + } + }; + method = sign_method.into(); let mut result = match credentials_with_options.credentials.sign_request::( &SignOptions { path: url.s3_path(), - method, + method: sign_method, ..Default::default() }, None, @@ -1959,10 +2025,11 @@ fn fetch_impl( } else { None }; + let mut request_headers = headers.take().unwrap_or_default(); let fetch_options = FetchOptions { - method, + method: method.to_ref(&mut request_headers), url: url_static, - headers: headers.take().unwrap_or_default(), + headers: request_headers, body, stream_framing, disable_keepalive, diff --git a/src/runtime/webcore/fetch/FetchSession.rs b/src/runtime/webcore/fetch/FetchSession.rs index c16e982b689b..f3efaca3d780 100644 --- a/src/runtime/webcore/fetch/FetchSession.rs +++ b/src/runtime/webcore/fetch/FetchSession.rs @@ -138,7 +138,7 @@ pub(crate) fn parse_proxy(global: &JSGlobalObject, proxy_arg: JSValue) -> JsResu let mut headers = None; if let Some(headers_value) = proxy_arg.get(global, "headers")? { if !headers_value.is_undefined_or_null() { - if let Some(fetch_headers) = FetchHeaders::cast(headers_value) { + if let Some(fetch_headers) = FetchHeaders::cast_as_init(headers_value) { let fetch_headers = bun_ptr::BackRef::from(fetch_headers); headers = Some(from_fetch_headers(global, Some(&*fetch_headers), None)?); } else if let Some(fetch_headers) = HeadersRef::create_from_js(global, headers_value)? { @@ -335,9 +335,7 @@ impl FetchSession { this.keep_alive = keep_alive.as_boolean(); } else if keep_alive.is_object() { if let Some(seconds) = positive_number(global, keep_alive, "idleTimeout")? { - // The pool timer has the same wheel limits as the request idle timeout. - this.pool.idle_timeout_seconds = - http::normalize_idle_timeout_seconds(seconds.ceil() as u64); + this.pool.idle_timeout_seconds = seconds.ceil() as u32; } if let Some(count) = keep_alive.get(global, "maxIdleSockets")? { const FIELD_NAME: &[u8] = b"keepAlive.maxIdleSockets"; diff --git a/src/runtime/webcore/fetch/FetchTasklet.rs b/src/runtime/webcore/fetch/FetchTasklet.rs index 2f64e8ccc8cc..657822c1ccba 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.rs +++ b/src/runtime/webcore/fetch/FetchTasklet.rs @@ -32,7 +32,9 @@ use crate::webcore::readable_stream::{ReadableStream, Strong as ReadableStreamSt use crate::webcore::response::HeadersRef; use crate::webcore::sink::JSSink; use crate::webcore::streams::{SourceHandle, StreamError, StreamResult, Writable}; -use crate::webcore::{AbortSignal, DrainResult, FetchHeaders, InternalBlob, Response, SinkHandle}; +use crate::webcore::{ + AbortSignal, BlobExt as _, DrainResult, FetchHeaders, InternalBlob, Response, SinkHandle, +}; use bun_jsc::AbortSignalRef; // `bun_event_loop::JsResult` (cycle-broken erased error) — used by @@ -234,16 +236,23 @@ impl HTTPRequestBody { stream.deinit(); } HTTPRequestBody::Sendfile(sendfile) => { - if sendfile.offset.max(sendfile.remain) > 0 { + if sendfile.fd.is_valid() { sendfile.fd.close(); + sendfile.fd = bun_sys::Fd::INVALID; } - sendfile.offset = 0; - sendfile.remain = 0; } } } pub(crate) fn from_js(cx: &bun_jsc::JsThread<'_>, value: JSValue) -> JsResult { + if let Some(form_data) = jsc::DOMFormData::from_js(value) { + return match Blob::from_dom_form_data(cx.global(), form_data) { + Ok(blob) => Ok(HTTPRequestBody::AnyBlob(AnyBlob::Blob(blob))), + Err(err) => Err(cx + .global() + .throw_value(super::system_type_error(&err, cx.global()))), + }; + } let mut body_value = BodyValue::from_js(cx.global(), value)?; if matches!(body_value, BodyValue::Used) || (matches!(&body_value, BodyValue::Locked(l) if !l.action.is_none() || l.is_disturbed2(cx.global()))) @@ -928,16 +937,42 @@ impl FetchTasklet { Ok(()) } + /// Removes the failure from `result` if body bytes that arrived before it haven't been delivered yet. + fn defer_failure_until_body_is_delivered(&mut self) -> Option { + let fail = self.result.fail?; + // Without a Response yet, the failure has to build it so every consumer sees the error. + if self.scheduled_response_buffer.list.is_empty() || !self.is_waiting_body { + return None; + } + // An abort ends the body right away. + if self.is_waiting_abort + || self.abort_reason.has() + || self.result.abort_reason().is_some() + || self.signal_store.aborted.load(Ordering::Relaxed) + { + return None; + } + self.result.fail = None; + self.result.has_more = true; + Some(fail) + } + pub(crate) fn on_progress_update(&mut self) -> JsResult<()> { jsc::mark_binding!(); bun_output::scoped_log!(FetchTasklet, "onProgressUpdate"); self.mutex.lock(); - self.has_schedule_callback.store(false, Ordering::Relaxed); - let is_done = !self.result.has_more; + // Only the HTTP thread's `callback` sets this flag, so the task `cleanup` enqueues below won't defer the failure again. + let posted_by_http_thread = self.has_schedule_callback.swap(false, Ordering::Relaxed); let vm = self.global_this.bun_vm(); // The response, the upload's pump and what they settle continue the script that fetched. let _context = vm.enter_context(self.context); + let deferred_failure = if posted_by_http_thread && vm.script_allowed() { + self.defer_failure_until_body_is_delivered() + } else { + None + }; + let is_done = !self.result.has_more; // teardown forbade script: we cannot touch JS if !vm.script_allowed() { // The certificate will never be checked; release the parked @@ -959,6 +994,15 @@ impl FetchTasklet { let global_this = self.global_this; // explicit cleanup at each return (a closure keeps borrowck happy) let cleanup = |this: &mut FetchTasklet| { + if let Some(fail) = deferred_failure { + // This run delivered the bytes. The next task fails the body and owns this thread's ref. + this.result.fail = Some(fail); + this.result.has_more = false; + // SAFETY: `vm.event_loop()` is the live JS-thread loop. + unsafe { + (*vm.event_loop()).enqueue_task(Task::init(std::ptr::from_mut(this))); + } + } this.mutex.unlock(); // if we are not done we wait until the next call if is_done { @@ -979,7 +1023,7 @@ impl FetchTasklet { if let Err(err) = self.start_request_stream() { // The VM is being stopped: leave like the `!script_allowed()` gate above does. self.mutex.unlock(); - if is_done { + if is_done || deferred_failure.is_some() { // SAFETY: `self` is the live heap tasklet; we hold a ref. FetchTasklet::deref(std::ptr::from_mut(self)); } @@ -1430,6 +1474,14 @@ impl FetchTasklet { _ => fail.name(), }; + let syscall = match fail { + http::Error::ConnectionRefused => BunString::static_("connect"), + http::Error::FailedToOpenSocket if self.result.connect_errno != 0 => { + BunString::static_("connect") + } + _ => BunString::EMPTY, + }; + let message = match fail { http::Error::ConnectionClosed => BunString::create_format(format_args!( "{code}: The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()", @@ -1655,6 +1707,7 @@ impl FetchTasklet { code: BunString::static_(code), message, path, + syscall, ..Default::default() }; @@ -1920,13 +1973,15 @@ impl FetchTasklet { }; let url = BunString::clone_utf8(metadata.url.slice()); let redirected = self.result.redirected; + let mut body_decoded = metadata.did_decompress; let body = if self.response_body_is_null(status_code) { + body_decoded = false; self.null_body_value() } else { self.is_waiting_body = self.result.has_more; self.to_body_value() }; - Response::init( + let response = Response::init( crate::webcore::response::Init { // SAFETY: create_from_pico_headers returns a fresh refcount=1 FetchHeaders*. headers: Some(unsafe { HeadersRef::adopt(headers) }), @@ -1937,7 +1992,9 @@ impl FetchTasklet { Body::new(body), url, redirected, - ) + ); + response.set_body_decoded(body_decoded); + response } /// Nothing will read the rest of the body: abort the transport, let go of the loop and of the @@ -2801,7 +2858,8 @@ impl FetchTasklet { } pub(crate) struct FetchOptions { - pub method: Method, + /// A token points into `headers.buf`. + pub method: http::MethodRef, pub(crate) headers: Headers, pub(crate) body: HTTPRequestBody, /// For a `ReadableStream` body; `transfer_encoding` points into `headers.buf`. diff --git a/src/runtime/webcore/streams.rs b/src/runtime/webcore/streams.rs index 1d4226d2f772..f7e39114699b 100644 --- a/src/runtime/webcore/streams.rs +++ b/src/runtime/webcore/streams.rs @@ -1999,7 +1999,7 @@ impl HTTPServerWritable { /// /// # Safety /// `this` must point at the live sink owned by the `RequestContext`. - pub(crate) unsafe fn abort(this: *mut Self) { + pub(crate) unsafe fn abort(this: *mut Self, reason: CommonAbortReason) { bun_core::scoped_log!(HTTPServerWritableLog, "onAborted()"); // SAFETY: caller contract — `this` is live, and every access here is scoped // so no borrow spans the signal close below, which may free `*this`. @@ -2015,7 +2015,14 @@ impl HTTPServerWritable { // no reference into the allocation may be live across the call. // SAFETY: as above; `source` is copied out before the close. let mut source = unsafe { (*this).source }; - source.abort(CommonAbortReason::ConnectionClosed); + source.abort(reason); + } + + /// Returns true if the sink ended the response, parked an `end()` or queued a flush. Each completes the head. + pub(crate) fn sends_head_itself(&self) -> bool { + self.ended_response + || self.requested_end + || (self.auto_flusher.registered.get() && !self.readable_slice().is_empty()) } fn unregister_auto_flusher(&mut self) { diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 14ee170f6bb6..11c4b97cf71d 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -5251,23 +5251,6 @@ pub mod c { dyld_get_image_header_raw(image_index).cast() } - /// Darwin `sendfile(fd, s, off, *len, *hdtr, flags)`. - /// NOTE: on `EINTR`/`EAGAIN` the kernel still writes the - /// bytes-sent count back through `*len` before returning -1 — callers MUST - /// advance their offset by `*len` even on error. This wrapper is raw (no - /// EINTR retry); the caller owns the offset bookkeeping. - #[cfg(target_os = "macos")] - pub unsafe fn sendfile( - fd: c_int, - s: c_int, - off: i64, - len: *mut i64, - hdtr: *mut c_void, - flags: c_int, - ) -> c_int { - // SAFETY: caller contract (`unsafe fn`) — all pointers forwarded verbatim. - unsafe { libc::sendfile(fd, s, off, len, hdtr.cast(), flags) } - } /// FreeBSD `sendfile(fd, s, off, nbytes, *hdtr, *sbytes, flags)`. #[cfg(target_os = "freebsd")] pub unsafe fn sendfile( diff --git a/src/url/lib.rs b/src/url/lib.rs index a9bf02239e7d..3a199d0e927d 100644 --- a/src/url/lib.rs +++ b/src/url/lib.rs @@ -70,20 +70,32 @@ pub mod whatwg { safe fn URL__pathname(url: &URL) -> String; safe fn URL__fragmentIdentifier(url: &URL) -> String; fn URL__deinit(url: *mut URL); - safe fn URL__getHref(input: &String) -> String; + safe fn URL__getHref(input: &String, exclude_fragment: bool) -> String; safe fn URL__getFileURLString(input: &String) -> String; safe fn URL__pathFromFileURL(input: &String) -> String; - safe fn URL__getHrefJoin(base: &String, relative: &String) -> String; + safe fn URL__getHrefJoin( + base: &String, + relative: &String, + exclude_fragment: bool, + ) -> String; fn URL__originLength(latin1_slice: *const u8, len: usize) -> usize; } /// Percent-encodes the URL, punycode-encodes the hostname, and returns the normalized /// href. If parsing fails, the returned String's tag is `Dead`. pub fn href_from_string(str: &String) -> String { - URL__getHref(str) + URL__getHref(str, false) + } + /// [`href_from_string`], serialized with the URL Standard's *exclude fragment* flag. + pub fn href_from_string_without_fragment(str: &String) -> String { + URL__getHref(str, true) } pub fn join(base: &String, relative: &String) -> String { - URL__getHrefJoin(base, relative) + URL__getHrefJoin(base, relative, false) + } + /// [`join`], serialized with the URL Standard's *exclude fragment* flag. + pub fn join_without_fragment(base: &String, relative: &String) -> String { + URL__getHrefJoin(base, relative, true) } pub fn file_url_from_string(str: &String) -> String { URL__getFileURLString(str) @@ -180,7 +192,8 @@ pub mod whatwg { // Re-export the free helpers at crate root so lower-tier callers can write // `bun_url::join(...)` / `bun_url::href_from_string(...)` (install, http, bake, js_parser). pub use whatwg::{ - file_url_from_string, href_from_string, join, origin_from_slice, path_from_file_url, + file_url_from_string, href_from_string, href_from_string_without_fragment, join, + join_without_fragment, origin_from_slice, path_from_file_url, }; /// Where the authority ends, which is where the search for the `@` of the userinfo stops. @@ -557,8 +570,9 @@ impl<'a> URL<'a> { bun_core::ip_address::is_ip_address(self.hostname) } + /// A URL without a port uses its scheme's default port. pub fn has_valid_port(&self) -> bool { - self.get_port().unwrap_or(0) > 0 + self.port.is_empty() || self.get_port().unwrap_or(0) > 0 } pub fn is_empty(&self) -> bool { diff --git a/src/uws/lib.rs b/src/uws/lib.rs index b8e778bbf194..f1159ffc2655 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -88,9 +88,9 @@ pub const DEDICATED_COMPRESSOR_256KB: i32 = 248; pub const DEDICATED_COMPRESSOR: i32 = 248; pub use bun_uws_sys::{ - LIBUS_LISTEN_DEFAULT, LIBUS_LISTEN_EXCLUSIVE_PORT, LIBUS_LISTEN_REUSE_ADDR, - LIBUS_LISTEN_REUSE_PORT, LIBUS_SOCKET_ALLOW_HALF_OPEN, LIBUS_SOCKET_IPV6_ONLY, - LIBUS_SOCKET_OPEN_PAUSED, + LIBUS_LISTEN_DEFAULT, LIBUS_LISTEN_DISALLOW_REUSE_PORT_FAILURE, LIBUS_LISTEN_EXCLUSIVE_PORT, + LIBUS_LISTEN_REUSE_ADDR, LIBUS_LISTEN_REUSE_PORT, LIBUS_SOCKET_ALLOW_HALF_OPEN, + LIBUS_SOCKET_IPV6_ONLY, LIBUS_SOCKET_OPEN_PAUSED, LIBUS_UDP_LINUX_RECVERR, }; // Re-export the `_sys` definitions so higher tiers see one type. `to_js` diff --git a/src/uws_sys/App.rs b/src/uws_sys/App.rs index 5a130245d53f..b7f554440e5f 100644 --- a/src/uws_sys/App.rs +++ b/src/uws_sys/App.rs @@ -146,6 +146,10 @@ impl App { c::uws_app_set_max_http_header_size(Self::SSL_FLAG, self.as_raw(), max_header_size) } + pub fn set_max_request_body_size(&mut self, max_request_body_size: u64) { + c::uws_app_set_max_request_body_size(Self::SSL_FLAG, self.as_raw(), max_request_body_size) + } + pub fn set_max_headers_count(&mut self, max_headers_count: u32) { c::uws_app_set_max_headers_count(Self::SSL_FLAG, self.as_raw(), max_headers_count) } @@ -284,44 +288,72 @@ impl App { pub fn listen_with_config( &mut self, - handler: c::uws_listen_handler, - user_data: *mut c_void, config: c::uws_app_listen_config_t, - ) { - // Callers supply the C-ABI shim directly. + ) -> Result<*mut ListenSocket, ListenError> { + let mut failure = ListenError::default(); // SAFETY: self is a valid app; config.host (if non-null) is NUL-terminated and outlives the call. - unsafe { + let socket = unsafe { c::uws_app_listen_with_config( Self::SSL_FLAG, std::ptr::from_mut::(self).cast::(), config.host, u16::try_from(config.port).expect("int cast"), config.options, - handler, - user_data, + &raw mut failure.error, + &raw mut failure.dns_error, ) + }; + if socket.is_null() { + Err(failure) + } else { + Ok(socket.cast::>()) } } pub fn listen_on_unix_socket( &mut self, - handler: extern "C" fn(*mut UwsListenSocket, *const c_char, i32, *mut c_void), - user_data: *mut c_void, domain_name: &ZStr, flags: i32, - ) { - // Callers supply the C-ABI shim directly. + ) -> Result<*mut ListenSocket, ListenError> { + let mut failure = ListenError::default(); // SAFETY: self is a valid app; domain_name is NUL-terminated. - unsafe { + let socket = unsafe { c::uws_app_listen_domain_with_options( Self::SSL_FLAG, std::ptr::from_mut::(self).cast::(), domain_name.as_ptr().cast(), domain_name.len(), flags, - handler, - user_data, + &raw mut failure.error, ) + }; + if socket.is_null() { + Err(failure) + } else { + Ok(socket.cast::>()) + } + } + + /// Listens on a descriptor the caller already bound. On failure the caller keeps `fd` and gets the errno. + pub fn listen_fd( + &mut self, + fd: crate::LIBUS_SOCKET_DESCRIPTOR, + backlog: c_int, + options: c_int, + ) -> Result<*mut ListenSocket, c_int> { + let mut error: c_int = 0; + let socket = c::uws_app_listen_fd( + Self::SSL_FLAG, + self.as_raw(), + fd, + backlog, + options, + &mut error, + ); + if socket.is_null() { + Err(error) + } else { + Ok(socket.cast::>()) } } @@ -455,6 +487,13 @@ impl App { // directly. } +/// `us_socket_group_listen`'s two out-params after it returned null. +#[derive(Clone, Copy, Default)] +pub struct ListenError { + pub error: c_int, + pub dns_error: c_int, +} + /// Opaque listen socket handle, parameterized by SSL to match `App`. #[repr(C)] pub struct ListenSocket { @@ -498,7 +537,6 @@ pub(crate) type uws_app_t = uws_app_s; pub mod c { use super::*; - pub(crate) type uws_listen_handler = Option; pub(crate) type uws_method_handler = Option; // The C++ shim hands the filter the uws_res_t*, which for HTTP server @@ -537,6 +575,11 @@ pub mod c { app: &mut uws_app_t, max_header_size: u64, ); + pub(crate) safe fn uws_app_set_max_request_body_size( + ssl: i32, + app: &mut uws_app_t, + max_request_body_size: u64, + ); pub(crate) safe fn uws_app_set_max_headers_count( ssl: i32, app: &mut uws_app_t, @@ -628,9 +671,9 @@ pub mod c { host: *const c_char, port: u16, options: i32, - handler: uws_listen_handler, - user_data: *mut c_void, - ); + error: *mut c_int, + dns_error: *mut c_int, + ) -> *mut UwsListenSocket; pub(crate) fn uws_num_subscribers( ssl: i32, app: *mut uws_app_t, @@ -667,9 +710,17 @@ pub mod c { domain: *const c_char, pathlen: usize, flags: i32, - handler: extern "C" fn(*mut UwsListenSocket, *const c_char, i32, *mut c_void), - user_data: *mut c_void, - ); + error: *mut c_int, + ) -> *mut UwsListenSocket; + + pub(crate) safe fn uws_app_listen_fd( + ssl_flag: c_int, + app: &mut uws_app_t, + fd: crate::LIBUS_SOCKET_DESCRIPTOR, + backlog: c_int, + options: c_int, + error: &mut c_int, + ) -> *mut UwsListenSocket; pub(crate) safe fn uws_app_clear_routes(ssl_flag: c_int, app: &mut uws_app_t); } diff --git a/src/uws_sys/Loop.rs b/src/uws_sys/Loop.rs index 90ab5336fe39..efde6424d4a6 100644 --- a/src/uws_sys/Loop.rs +++ b/src/uws_sys/Loop.rs @@ -533,6 +533,7 @@ mod c { pub(super) fn uws_get_loop_with_native(native: *mut c_void) -> *mut WindowsLoop; pub(super) fn uws_loop_date_header_timer_update(loop_: *mut Loop); pub(super) fn uws_loop_flush_pending_writes(loop_: *mut Loop); + pub fn us_internal_sweep_soon(loop_: *mut Loop); } } // Re-exported raw externs for cross-thread callers (e.g. bun_http's @@ -542,7 +543,7 @@ mod c { // event-loop thread parks inside it while worker threads call // `us_wakeup_loop` concurrently; routing either through a `&mut self` // receiver would create two live `&mut Loop` to the same singleton (UB). -pub use c::{us_loop_run, us_wakeup_loop}; +pub use c::{us_internal_sweep_soon, us_loop_run, us_wakeup_loop}; unsafe extern "C" { // safe: no args; frees this thread's lazily-created uws loop if it exists. diff --git a/src/uws_sys/Response.rs b/src/uws_sys/Response.rs index 8d39c1f79a57..883b672b7849 100644 --- a/src/uws_sys/Response.rs +++ b/src/uws_sys/Response.rs @@ -290,6 +290,10 @@ impl Response { c::uws_res_end_without_body(Self::ssl_flag(), self.as_raw(), close_connection) } + pub(crate) fn abandon(&mut self) { + c::uws_res_abandon(Self::ssl_flag(), self.as_raw()) + } + pub(crate) fn end_send_file(&mut self, write_offset: u64, close_connection: bool) { c::uws_res_end_sendfile( Self::ssl_flag(), @@ -371,10 +375,19 @@ impl Response { c::uws_res_mark_wrote_content_length_header(Self::ssl_flag(), self.as_raw()) } + /// Sets `HTTP_FIXED_LENGTH_FILE_BODY`. See `HttpResponseData.h`. + pub(crate) fn mark_fixed_length_file_body(&mut self) { + c::uws_res_mark_fixed_length_file_body(Self::ssl_flag(), self.as_raw()) + } + pub(crate) fn mark_wrote_date_header(&mut self) { c::uws_res_mark_wrote_date_header(Self::ssl_flag(), self.as_raw()) } + pub(crate) fn mark_wrote_connection_header(&mut self, close: bool) { + c::uws_res_mark_wrote_connection_header(Self::ssl_flag(), self.as_raw(), close) + } + pub(crate) fn write_mark(&mut self) { c::uws_res_write_mark(Self::ssl_flag(), self.as_raw()) } @@ -472,7 +485,8 @@ impl Response { #[inline] pub(crate) fn mark_needs_more(&mut self) { if !SSL { - c::us_socket_mark_needs_more_not_ssl(self.as_raw()) + // S008: `us_socket_t` is an `opaque_ffi!` ZST, so the deref is safe. + us_socket_t::opaque_mut(self.downcast_socket()).request_writable(); } } @@ -810,10 +824,32 @@ impl AnyResponse { any_dispatch!(self, |r| r.mark_wrote_content_length_header()) } + /// See `Response::mark_fixed_length_file_body`. An H2 or H3 stream keeps sending after the peer ends its side. + pub fn mark_fixed_length_file_body(self) { + match self { + AnyResponse::SSL(ptr) => TLSResponse::as_handle(ptr).mark_fixed_length_file_body(), + AnyResponse::TCP(ptr) => TCPResponse::as_handle(ptr).mark_fixed_length_file_body(), + AnyResponse::H3(_) | AnyResponse::H2(_) => {} + } + } + pub fn mark_wrote_date_header(self) { any_dispatch!(self, |r| r.mark_wrote_date_header()) } + /// Sets `HTTP_WROTE_CONNECTION_HEADER`, and `HTTP_CONNECTION_CLOSE` if that header has `close`. No-op on HTTP/2 and HTTP/3. + pub fn mark_wrote_connection_header(self, close: bool) { + match self { + AnyResponse::SSL(ptr) => { + TLSResponse::as_handle(ptr).mark_wrote_connection_header(close) + } + AnyResponse::TCP(ptr) => { + TCPResponse::as_handle(ptr).mark_wrote_connection_header(close) + } + AnyResponse::H3(_) | AnyResponse::H2(_) => {} + } + } + pub fn write_mark(self) { any_dispatch!(self, |r| r.write_mark()) } @@ -860,6 +896,21 @@ impl AnyResponse { any_dispatch!(self, |r| r.flush_headers(flush_immediately)) } + /// Ends the header section of a live HTTP/1 response whose body has sent nothing yet. + pub fn commit_head(self) { + // HTTP/2 and HTTP/3 send HEADERS with the first DATA frame. + if matches!(self, AnyResponse::H3(_) | AnyResponse::H2(_)) { + return; + } + let open_head = State::HTTP_STATUS_CALLED | State::HTTP_RESPONSE_PENDING; + // A `try_end` parked on backpressure sets only END_CALLED, and its head is out too. + let head_is_out = State::HTTP_WRITE_CALLED | State::HTTP_END_CALLED; + if self.state().bits() & (open_head | head_is_out).bits() != open_head.bits() { + return; + } + self.corked(|| self.flush_headers(false)); + } + pub fn is_corked(self) -> bool { any_dispatch!(self, |r| r.is_corked()) } @@ -868,6 +919,15 @@ impl AnyResponse { any_dispatch!(self, |r| r.uncork()) } + /// See `Response::send_corked`. H2 and H3 have no cork buffer. + pub fn send_corked(self) { + match self { + AnyResponse::SSL(ptr) => TLSResponse::as_handle(ptr).send_corked(), + AnyResponse::TCP(ptr) => TCPResponse::as_handle(ptr).send_corked(), + AnyResponse::H3(_) | AnyResponse::H2(_) => {} + } + } + pub fn get_buffered_amount(self) -> u64 { any_dispatch!(self, |r| r.get_buffered_amount()) } @@ -968,6 +1028,15 @@ impl AnyResponse { any_dispatch!(self, |r| r.end_without_body(close_connection)) } + /// Marks the response ended without writing anything. HTTP/1 only: node:http's `destroy()` is the one caller. + pub fn abandon(self) { + match self { + AnyResponse::SSL(ptr) => TLSResponse::as_handle(ptr).abandon(), + AnyResponse::TCP(ptr) => TCPResponse::as_handle(ptr).abandon(), + AnyResponse::H3(_) | AnyResponse::H2(_) => {} + } + } + /// HTTP/1 only: the handlers of an HTTP/2 or HTTP/3 stream belong to one response. pub fn clear_handlers_of(self, user_data: *mut U) { match self { @@ -1296,9 +1365,14 @@ pub mod c { // unsafe. unsafe extern "C" { pub(crate) safe fn uws_res_mark_wrote_content_length_header(ssl: i32, res: &mut uws_res); + pub(crate) safe fn uws_res_mark_fixed_length_file_body(ssl: i32, res: &mut uws_res); pub(crate) safe fn uws_res_mark_wrote_date_header(ssl: i32, res: &mut uws_res); + pub(crate) safe fn uws_res_mark_wrote_connection_header( + ssl: i32, + res: &mut uws_res, + close: bool, + ); pub(crate) safe fn uws_res_write_mark(ssl: i32, res: &mut uws_res); - pub(crate) safe fn us_socket_mark_needs_more_not_ssl(socket: &mut uws_res); pub(crate) safe fn uws_res_state(ssl: c_int, res: &uws_res) -> State; pub(crate) safe fn uws_res_is_connect_request(ssl: i32, res: &mut uws_res) -> bool; // Out-params are `&mut` (non-null, valid for write); the C shim only @@ -1369,6 +1443,7 @@ pub mod c { res: &mut uws_res, close_connection: bool, ); + pub(crate) safe fn uws_res_abandon(ssl: i32, res: &mut uws_res); pub(crate) safe fn uws_res_end_sendfile( ssl: i32, res: &mut uws_res, diff --git a/src/uws_sys/SocketGroup.rs b/src/uws_sys/SocketGroup.rs index b7f09b522fcf..dfcde429228f 100644 --- a/src/uws_sys/SocketGroup.rs +++ b/src/uws_sys/SocketGroup.rs @@ -149,6 +149,7 @@ impl SocketGroup { options: c_int, socket_ext_size: c_int, err: &mut c_int, + dns_err: &mut c_int, ) -> *mut ListenSocket { // SAFETY: forwarding to C; all pointers are valid or null as documented. unsafe { @@ -161,6 +162,7 @@ impl SocketGroup { options, socket_ext_size, err, + dns_err, ) } } @@ -214,6 +216,21 @@ impl SocketGroup { } } + /// Decodes a failed `listen*`'s `err`, which is an errno or a Windows error code. + pub fn listen_errno(err: c_int) -> Option { + if err == 0 { + return None; + } + #[cfg(windows)] + { + bun_errno::SystemErrno::init(err.unsigned_abs()) + } + #[cfg(not(windows))] + { + bun_errno::SystemErrno::init(i64::from(err)) + } + } + pub fn connect( &mut self, kind: SocketKind, @@ -322,6 +339,7 @@ unsafe extern "C" { options: c_int, socket_ext_size: c_int, err: *mut c_int, + dns_err: *mut c_int, ) -> *mut ListenSocket; fn us_socket_group_listen_unix( group: *mut SocketGroup, diff --git a/src/uws_sys/_libusockets.h b/src/uws_sys/_libusockets.h index acf75ae27f06..7686cb14b2dc 100644 --- a/src/uws_sys/_libusockets.h +++ b/src/uws_sys/_libusockets.h @@ -125,12 +125,6 @@ typedef struct { uws_websocket_close_handler close; } uws_socket_behavior_t; -typedef void (*uws_listen_handler)(struct us_listen_socket_t* listen_socket, - void* user_data); -typedef void (*uws_listen_domain_handler)( - struct us_listen_socket_t* listen_socket, const char* domain, int options, - void* user_data); - typedef void (*uws_method_handler)(uws_res_t* response, uws_req_t* request, void* user_data); typedef void (*uws_filter_handler)(uws_res_t* response, int, void* user_data); diff --git a/src/uws_sys/h3.rs b/src/uws_sys/h3.rs index caabd299d636..963e55cd4d91 100644 --- a/src/uws_sys/h3.rs +++ b/src/uws_sys/h3.rs @@ -418,6 +418,15 @@ impl App { pub fn close(&mut self) { c::uws_h3_app_close(self) } + /// Returns true if the app has no open UDP socket and no QUIC connection left. + pub fn is_released(&mut self) -> bool { + c::uws_h3_app_is_released(self) + } + /// `cb(user)` runs when [`Self::is_released`] becomes true. It must not enter the QUIC engine or destroy the app. + pub fn on_released(&mut self, cb: unsafe extern "C" fn(user: *mut c_void), user: *mut c_void) { + // SAFETY: live handle; cb/user are stored and invoked on this thread. + unsafe { c::uws_h3_app_on_released(self, cb, user) } + } pub fn clear_routes(&mut self) { c::uws_h3_app_clear_routes(self) } @@ -595,6 +604,12 @@ mod c { ) -> *mut App; pub(super) fn uws_h3_app_destroy(app: *mut App); pub(super) safe fn uws_h3_app_close(app: &mut App); + pub(super) safe fn uws_h3_app_is_released(app: &mut App) -> bool; + pub(super) fn uws_h3_app_on_released( + app: *mut App, + cb: unsafe extern "C" fn(user: *mut c_void), + user: *mut c_void, + ); pub(super) safe fn uws_h3_app_clear_routes(app: &mut App); pub(super) fn uws_h3_app_add_server_name( app: *mut App, diff --git a/src/uws_sys/lib.rs b/src/uws_sys/lib.rs index d2ec9aaefafc..41ac5096020d 100644 --- a/src/uws_sys/lib.rs +++ b/src/uws_sys/lib.rs @@ -29,6 +29,8 @@ pub const LIBUS_SOCKET_ALLOW_HALF_OPEN: core::ffi::c_int = 2; pub const LIBUS_LISTEN_REUSE_PORT: core::ffi::c_int = 4; pub const LIBUS_SOCKET_IPV6_ONLY: core::ffi::c_int = 8; pub const LIBUS_LISTEN_REUSE_ADDR: core::ffi::c_int = 16; +pub const LIBUS_LISTEN_DISALLOW_REUSE_PORT_FAILURE: core::ffi::c_int = 32; +pub const LIBUS_UDP_LINUX_RECVERR: core::ffi::c_int = 128; pub const LIBUS_SOCKET_OPEN_PAUSED: core::ffi::c_int = 256; /// BoringSSL `SSL_CTX` (alias so callers don't need a direct boringssl dep). diff --git a/src/uws_sys/libuwsockets.cpp b/src/uws_sys/libuwsockets.cpp index 8d1faf19ebaa..c374a839d6de 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -506,52 +506,37 @@ extern "C" } } - void uws_app_listen_with_config(int ssl, uws_app_t *app, const char *host, - uint16_t port, int32_t options, - uws_listen_handler handler, void *user_data) + struct us_listen_socket_t *uws_app_listen_with_config(int ssl, uws_app_t *app, const char *host, + uint16_t port, int32_t options, + int *error, int *dns_error) { - std::string hostname = host && host[0] ? std::string(host, strlen(host)) : ""; - if (ssl) + if (host && !host[0]) { - uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; - uwsApp->listen( - hostname, port, options, - [handler, user_data](struct us_listen_socket_t *listen_socket) - { - handler((struct us_listen_socket_t *)listen_socket, user_data); - }); + host = nullptr; } - else + if (ssl) { - uWS::App *uwsApp = (uWS::App *)app; - uwsApp->listen( - hostname, port, options, - [handler, user_data](struct us_listen_socket_t *listen_socket) - { - handler((struct us_listen_socket_t *)listen_socket, user_data); - }); + return ((uWS::SSLApp *)app)->listen(host, port, options, error, dns_error); } + return ((uWS::App *)app)->listen(host, port, options, error, dns_error); } - /* callback, path to unix domain socket */ - void uws_app_listen_domain_with_options(int ssl, uws_app_t *app, const char *domain, size_t pathlen, int options, uws_listen_domain_handler handler, void *user_data) + struct us_listen_socket_t *uws_app_listen_domain_with_options(int ssl, uws_app_t *app, const char *domain, size_t pathlen, int options, int *error) { if (ssl) { - uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; - uwsApp->listen( - options, [handler, domain, options, user_data](struct us_listen_socket_t *listen_socket) - { handler((struct us_listen_socket_t *)listen_socket, domain, options, user_data); }, - {domain, pathlen}); + return ((uWS::SSLApp *)app)->listen_unix({domain, pathlen}, options, error); } - else + return ((uWS::App *)app)->listen_unix({domain, pathlen}, options, error); + } + + struct us_listen_socket_t *uws_app_listen_fd(int ssl, uws_app_t *app, LIBUS_SOCKET_DESCRIPTOR fd, int backlog, int options, int *error) + { + if (ssl) { - uWS::App *uwsApp = (uWS::App *)app; - uwsApp->listen( - options, [handler, domain, options, user_data](struct us_listen_socket_t *listen_socket) - { handler((struct us_listen_socket_t *)listen_socket, domain, options, user_data); }, - {domain, pathlen}); + return ((uWS::SSLApp *)app)->listen_fd(fd, backlog, options, error); } + return ((uWS::App *)app)->listen_fd(fd, backlog, options, error); } void uws_app_set_max_http_header_size(int ssl, uws_app_t *app, uint64_t max_header_size) { @@ -563,6 +548,15 @@ extern "C" uwsApp->setMaxHTTPHeaderSize(max_header_size); } } + void uws_app_set_max_request_body_size(int ssl, uws_app_t *app, uint64_t max_request_body_size) { + if (ssl) { + uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; + uwsApp->setMaxRequestBodySize(max_request_body_size); + } else { + uWS::App *uwsApp = (uWS::App *)app; + uwsApp->setMaxRequestBodySize(max_request_body_size); + } + } void uws_app_set_max_headers_count(int ssl, uws_app_t *app, uint32_t max_headers_count) { if (ssl) { uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; @@ -912,6 +906,16 @@ extern "C" } } + void uws_res_mark_fixed_length_file_body(int ssl, uws_res_r res) { + if (ssl) { + uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; + uwsRes->getHttpResponseData()->state |= uWS::HttpResponseData::HTTP_FIXED_LENGTH_FILE_BODY; + } else { + uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; + uwsRes->getHttpResponseData()->state |= uWS::HttpResponseData::HTTP_FIXED_LENGTH_FILE_BODY; + } + } + void uws_res_mark_wrote_date_header(int ssl, uws_res_r res) { if (ssl) { uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; @@ -922,6 +926,18 @@ extern "C" } } + void uws_res_mark_wrote_connection_header(int ssl, uws_res_r res, bool close) { + if (ssl) { + uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; + uwsRes->getHttpResponseData()->state |= uWS::HttpResponseData::HTTP_WROTE_CONNECTION_HEADER; + if (close) uwsRes->getHttpResponseData()->state |= uWS::HttpResponseData::HTTP_CONNECTION_CLOSE; + } else { + uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; + uwsRes->getHttpResponseData()->state |= uWS::HttpResponseData::HTTP_WROTE_CONNECTION_HEADER; + if (close) uwsRes->getHttpResponseData()->state |= uWS::HttpResponseData::HTTP_CONNECTION_CLOSE; + } + } + void uws_res_write_mark(int ssl, uws_res_r res) { if (ssl) { uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; @@ -971,6 +987,10 @@ extern "C" uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; auto *data = uwsRes->getHttpResponseData(); data->offset = offset; + if (close_connection) + { + data->state |= uWS::HttpResponseData::HTTP_CONNECTION_CLOSE; + } data->state |= uWS::HttpResponseData::HTTP_END_CALLED; data->markDone(uwsRes); uwsRes->resetTimeout(); @@ -980,6 +1000,10 @@ extern "C" uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; auto *data = uwsRes->getHttpResponseData(); data->offset = offset; + if (close_connection) + { + data->state |= uWS::HttpResponseData::HTTP_CONNECTION_CLOSE; + } data->state |= uWS::HttpResponseData::HTTP_END_CALLED; data->markDone(uwsRes); uwsRes->resetTimeout(); @@ -1042,22 +1066,17 @@ extern "C" auto *data = uwsRes->getHttpResponseData(); /* Once write()/flushHeaders() (HTTP_WRITE_CALLED) or an earlier end * (HTTP_END_CALLED) terminated the header section, header bytes written - * here would land inside the body (node:http res.destroy() mid-response + * here would land inside the body (a node:http listener that throws mid-response * ends up here). Setting HTTP_CONNECTION_CLOSE is what makes the close - * gates tear the connection down; the header itself is only advisory, - * same as in internalEnd(). */ + * gates tear the connection down; terminateHeaders() writes the header. */ bool headers_open = !(data->state & (uWS::HttpResponseData::HTTP_WRITE_CALLED | uWS::HttpResponseData::HTTP_END_CALLED)); if (close_connection) { - if (headers_open && !(data->state & uWS::HttpResponseData::HTTP_CONNECTION_CLOSE)) - { - uwsRes->writeHeader("Connection", "close"); - } data->state |= uWS::HttpResponseData::HTTP_CONNECTION_CLOSE; } if (headers_open) { - uwsRes->AsyncSocket::write("\r\n", 2); + uwsRes->terminateHeaders(); } data->state |= uWS::HttpResponseData::HTTP_END_CALLED; data->markDone(uwsRes); @@ -1077,17 +1096,13 @@ extern "C" bool headers_open = !(data->state & (uWS::HttpResponseData::HTTP_WRITE_CALLED | uWS::HttpResponseData::HTTP_END_CALLED)); if (close_connection) { - if (headers_open && !(data->state & uWS::HttpResponseData::HTTP_CONNECTION_CLOSE)) - { - uwsRes->writeHeader("Connection", "close"); - } data->state |= uWS::HttpResponseData::HTTP_CONNECTION_CLOSE; } if (headers_open) { // Some HTTP clients require the complete "
\r\n\r\n" to be sent. // If not, they may throw a ConnectionError. - uwsRes->AsyncSocket::write("\r\n", 2); + uwsRes->terminateHeaders(); } data->state |= uWS::HttpResponseData::HTTP_END_CALLED; data->markDone(uwsRes); @@ -1096,6 +1111,27 @@ extern "C" } } + /* uws_res_end_without_body(res, true) without its writes, which would complete a head that node:http's destroy() gave up on. */ + void uws_res_abandon(int ssl, uws_res_r res) + { + if (ssl) + { + uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; + auto *data = uwsRes->getHttpResponseData(); + data->state |= uWS::HttpResponseData::HTTP_CONNECTION_CLOSE | uWS::HttpResponseData::HTTP_END_CALLED; + data->markDone(uwsRes); + uwsRes->resetTimeout(); + } + else + { + uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; + auto *data = uwsRes->getHttpResponseData(); + data->state |= uWS::HttpResponseData::HTTP_CONNECTION_CLOSE | uWS::HttpResponseData::HTTP_END_CALLED; + data->markDone(uwsRes); + uwsRes->resetTimeout(); + } + } + bool uws_res_write(int ssl, uws_res_r res, const char *data, size_t *length) nonnull_fn_decl; bool uws_res_write(int ssl, uws_res_r res, const char *data, size_t *length) @@ -1473,18 +1509,6 @@ size_t uws_req_get_header(uws_req_t *res, const char *lower_case_header, } } - void us_socket_mark_needs_more_not_ssl(uws_res_r res) - { - us_socket_r s = (us_socket_t *)res; - if(us_socket_is_closed(s)) return; - s->flags.last_write_failed = 1; - /* Same gate as us_internal_rearm_writable (socket.c): re-adding READABLE - * would undo a pause mid-backpressure and re-surface a consumed EOF on a - * half-open socket. */ - us_poll_change(&s->p, s->group->loop, - LIBUS_SOCKET_WRITABLE | ((s->flags.is_paused || s->read_eof) ? 0 : LIBUS_SOCKET_READABLE)); - } - __attribute__((callback (corker, ctx))) void uws_res_cork(int ssl, uws_res_r res, void *ctx, void (*corker)(void *ctx)) nonnull_fn_decl; @@ -1511,21 +1535,15 @@ __attribute__((callback (corker, ctx))) if (ssl) { uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; - uwsRes->writeMark(); - auto pair = uwsRes->getSendBuffer(2); - char *ptr = pair.first; - ptr[0] = '\r'; - ptr[1] = '\n'; + uwsRes->terminateHeaders(); + uwsRes->getHttpResponseData()->state |= uWS::HttpResponseData::HTTP_WRITE_CALLED; uwsRes->uncork(); } else { uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; - uwsRes->writeMark(); - auto pair = uwsRes->getSendBuffer(2); - char *ptr = pair.first; - ptr[0] = '\r'; - ptr[1] = '\n'; + uwsRes->terminateHeaders(); + uwsRes->getHttpResponseData()->state |= uWS::HttpResponseData::HTTP_WRITE_CALLED; uwsRes->uncork(); } } @@ -1628,16 +1646,6 @@ __attribute__((callback (corker, ctx))) } } - void us_socket_sendfile_needs_more(us_socket_r s) { - if(us_socket_is_closed(s)) return; - s->flags.last_write_failed = 1; - /* Same gate as us_internal_rearm_writable (socket.c): re-adding READABLE - * would undo a pause mid-backpressure and re-surface a consumed EOF on a - * half-open socket. */ - us_poll_change(&s->p, s->group->loop, - LIBUS_SOCKET_WRITABLE | ((s->flags.is_paused || s->read_eof) ? 0 : LIBUS_SOCKET_READABLE)); - } - LIBUS_SOCKET_DESCRIPTOR us_socket_get_fd(us_socket_r s) { return us_poll_fd(&s->p); } diff --git a/src/uws_sys/libuwsockets_h3.cpp b/src/uws_sys/libuwsockets_h3.cpp index fa06529014b0..bb29623c0f29 100644 --- a/src/uws_sys/libuwsockets_h3.cpp +++ b/src/uws_sys/libuwsockets_h3.cpp @@ -41,8 +41,6 @@ typedef void (*uws_h3_listen_handler)(us_quic_listen_socket_t*, void*); uws_h3_app_t* uws_h3_create_app(struct us_bun_socket_context_options_t options, unsigned int idle_timeout_s) { - static int once = (us_quic_global_init(), 1); - (void)once; uWS::SocketContextOptions sco; static_assert(sizeof(sco) == sizeof(options)); memcpy(&sco, &options, sizeof(sco)); @@ -51,6 +49,8 @@ uws_h3_app_t* uws_h3_create_app(struct us_bun_socket_context_options_t options, void uws_h3_app_destroy(uws_h3_app_t* app) { delete (H3App*)app; } void uws_h3_app_close(uws_h3_app_t* app) { ((H3App*)app)->close(); } +void uws_h3_app_on_released(uws_h3_app_t* app, void (*cb)(void*), void* user) { ((H3App*)app)->onReleased(cb, user); } +bool uws_h3_app_is_released(uws_h3_app_t* app) { return ((H3App*)app)->isReleased(); } void uws_h3_app_clear_routes(uws_h3_app_t* app) { ((H3App*)app)->clearRoutes(); } bool uws_h3_app_add_server_name(uws_h3_app_t* app, const char* hostname, @@ -116,10 +116,11 @@ void uws_h3_res_end_stream(uws_h3_res_t* res, bool close_connection) r->sendTerminatingChunk(close_connection); } +/* RESET_STREAM, not FIN: a FIN would make a truncated body look complete. */ void uws_h3_res_force_close(uws_h3_res_t* res) { ((Http3Response*)res)->clearOnWritableAndAborted(); - us_quic_stream_close((us_quic_stream_t*)res); + us_quic_stream_reset_internal_error((us_quic_stream_t*)res); } bool uws_h3_res_try_end(uws_h3_res_t* res, const char* bytes, size_t len, size_t total_len, bool close) diff --git a/src/uws_sys/quic.rs b/src/uws_sys/quic.rs index 53adc9c8ae67..9708d8c7d3e6 100644 --- a/src/uws_sys/quic.rs +++ b/src/uws_sys/quic.rs @@ -26,13 +26,3 @@ pub use self::stream::Stream; pub use self::header::Header; pub use self::header::Qpack; - -unsafe extern "C" { - // safe: no args; idempotent C-side initialization with no preconditions. - pub(crate) safe fn us_quic_global_init(); -} - -#[inline] -pub fn global_init() { - us_quic_global_init() -} diff --git a/src/uws_sys/quic/Context.rs b/src/uws_sys/quic/Context.rs index 880c6091783a..8f10a93d9828 100644 --- a/src/uws_sys/quic/Context.rs +++ b/src/uws_sys/quic/Context.rs @@ -6,6 +6,7 @@ use core::ffi::{CStr, c_char, c_int, c_uint, c_void}; use crate::Loop; use crate::quic::{PendingConnect, Socket, Stream}; +use crate::socket_context::BunSocketContextOptions; bun_opaque::opaque_ffi! { pub struct Context; } @@ -29,6 +30,7 @@ unsafe extern "C" { port: c_int, sni: *const c_char, reject_unauthorized: c_int, + tls: *const BunSocketContextOptions, out_qs: *mut *mut Socket, out_pending: *mut *mut PendingConnect, user: *mut c_void, @@ -110,11 +112,12 @@ impl Context { port: u16, sni: &CStr, reject_unauthorized: bool, + tls: Option<&BunSocketContextOptions>, user: *mut c_void, ) -> ConnectResult { let mut qs: *mut Socket = core::ptr::null_mut(); let mut pc: *mut PendingConnect = core::ptr::null_mut(); - // SAFETY: self is a live us_quic_socket_context_t; out-params are valid for write. + // SAFETY: self is a live us_quic_socket_context_t; out-params are valid for write; C copies what it needs from `tls` before it returns. let rc = unsafe { us_quic_socket_context_connect( self, @@ -122,6 +125,7 @@ impl Context { c_int::from(port), sni.as_ptr(), reject_unauthorized as c_int, + tls.map_or(core::ptr::null(), core::ptr::from_ref), &raw mut qs, &raw mut pc, user, diff --git a/src/uws_sys/quic/Stream.rs b/src/uws_sys/quic/Stream.rs index 969295d7de6a..6b0ae7cc7a68 100644 --- a/src/uws_sys/quic/Stream.rs +++ b/src/uws_sys/quic/Stream.rs @@ -21,14 +21,15 @@ unsafe extern "C" { safe fn us_quic_stream_close(s: &mut Stream); safe fn us_quic_stream_reset(s: &mut Stream); safe fn us_quic_stream_reset_code(s: &mut Stream) -> u64; + safe fn us_quic_stream_reset_received(s: &mut Stream) -> c_int; safe fn us_quic_stream_refused_by_goaway(s: &mut Stream) -> c_int; safe fn us_quic_stream_header_count(s: &mut Stream) -> c_uint; safe fn us_quic_stream_header(s: &mut Stream, i: c_uint) -> *const Header; safe fn us_quic_stream_headers_too_large(s: &mut Stream) -> c_int; safe fn us_quic_stream_ext(s: &mut Stream) -> *mut c_void; fn us_quic_stream_write(s: *mut Stream, data: *const u8, len: c_uint) -> c_int; - safe fn us_quic_stream_want_write(s: &mut Stream, want: c_int); - safe fn us_quic_stream_want_read(s: &mut Stream, want: c_int); + safe fn us_quic_stream_want_write(s: &mut Stream, want: c_int) -> c_int; + safe fn us_quic_stream_hold_window(s: &mut Stream, hold: c_int); fn us_quic_stream_send_headers( s: *mut Stream, h: *const Header, @@ -64,6 +65,11 @@ impl Stream { us_quic_stream_reset_code(self) } + /// Returns true if the peer sent RESET_STREAM. + pub fn reset_received(&mut self) -> bool { + us_quic_stream_reset_received(self) != 0 + } + /// The server did not process a request above the id in its GOAWAY (RFC 9114 §5.2). pub fn refused_by_goaway(&mut self) -> bool { us_quic_stream_refused_by_goaway(self) != 0 @@ -103,11 +109,12 @@ impl Stream { } pub fn want_write(&mut self, want: bool) { - us_quic_stream_want_write(self, want as c_int) + us_quic_stream_want_write(self, want as c_int); } - pub fn want_read(&mut self, want: bool) { - us_quic_stream_want_read(self, want as c_int) + /// Receive backpressure, see `lsquic_stream_hold_window()` in lsquic.h. + pub fn hold_window(&mut self, hold: bool) { + us_quic_stream_hold_window(self, hold as c_int) } pub fn send_headers(&mut self, headers: &[Header], end_stream: bool) -> c_int { diff --git a/src/uws_sys/socket.rs b/src/uws_sys/socket.rs index 2694ff304583..921e54ff8c14 100644 --- a/src/uws_sys/socket.rs +++ b/src/uws_sys/socket.rs @@ -728,7 +728,7 @@ impl NewSocketHandler { pub fn mark_needs_more_for_sendfile(&self) { const { assert!(!IS_SSL, "SSL sockets do not support sendfile yet") }; if let InternalSocket::Connected(s) = self.socket { - sock(s).send_file_needs_more(); + sock(s).request_writable(); } } diff --git a/src/uws_sys/us_socket_t.rs b/src/uws_sys/us_socket_t.rs index aba2f4d51dca..2bb719e5f5b0 100644 --- a/src/uws_sys/us_socket_t.rs +++ b/src/uws_sys/us_socket_t.rs @@ -445,8 +445,8 @@ impl us_socket_t { c::us_socket_flush(self); } - pub(crate) fn send_file_needs_more(&mut self) { - c::us_socket_sendfile_needs_more(self); + pub(crate) fn request_writable(&mut self) { + c::us_socket_request_writable(self); } /// SO_RCVBUF / SO_SNDBUF. `size == 0` reads the current value, non-zero sets it. Returns 0 @@ -589,7 +589,7 @@ mod c { ) -> i32; pub(super) safe fn us_socket_shutdown_read(s: &mut us_socket_t); pub(super) safe fn us_socket_is_shut_down(s: &us_socket_t) -> i32; - pub(super) safe fn us_socket_sendfile_needs_more(socket: &mut us_socket_t); + pub(super) safe fn us_socket_request_writable(s: &mut us_socket_t); pub(super) safe fn us_socket_get_fd(s: &us_socket_t) -> LIBUS_SOCKET_DESCRIPTOR; pub(super) safe fn us_socket_buffer_size( s: &mut us_socket_t, diff --git a/src/zlib/lib.rs b/src/zlib/lib.rs index eb2465dd5767..cbccf8862d50 100644 --- a/src/zlib/lib.rs +++ b/src/zlib/lib.rs @@ -157,7 +157,11 @@ mod ZlibAllocator { pub(crate) use malloc_items as alloc; } +/// ID1 and ID2, the first two bytes of a gzip member (RFC 1952 §2.3.1). +pub const GZIP_MAGIC: [u8; 2] = [0x1f, 0x8b]; + pub struct ZlibReaderArrayList<'a> { + input: &'a [u8], // We operate directly through `list_ptr` (a `&'a mut Vec`). pub(crate) list_ptr: &'a mut Vec, pub(crate) zlib: zStream_struct, @@ -166,6 +170,8 @@ pub struct ZlibReaderArrayList<'a> { /// Decompression-bomb guard: `read_all` errors instead of growing the /// output past this many bytes. Defaults to unbounded. pub(crate) max_output_size: usize, + /// RFC 1952 §2.2: a gzip file is a series of members. Set for gzip-only `window_bits`. + multi_member: bool, } impl<'a> Drop for ZlibReaderArrayList<'a> { @@ -208,10 +214,12 @@ impl<'a> ZlibReaderArrayList<'a> { options: Options, ) -> Result, ZlibError> { let mut zlib_reader = Box::new(Self { + input, list_ptr: list, zlib: bun_core::ffi::zeroed(), state: ZlibReaderArrayListState::Uninitialized, max_output_size: usize::MAX, + multi_member: (16..32).contains(&options.window_bits), }); let list_len = zlib_reader.list_ptr.len(); @@ -335,6 +343,18 @@ impl<'a> ZlibReaderArrayList<'a> { match rc { ReturnCode::StreamEnd => { + let rest = &self.input[self.input.len() - self.zlib.avail_in as usize..]; + if self.multi_member && rest.starts_with(&GZIP_MAGIC) { + // `inflateReset` zeroes `total_out`, which tracks `list_ptr`'s length. + let total_out = self.zlib.total_out; + // SAFETY: self.zlib was initialized via inflateInit2_. + if unsafe { inflateReset(&raw mut self.zlib) } != ReturnCode::Ok { + self.state = ZlibReaderArrayListState::Error; + return Err(ZlibError::ZlibError); + } + self.zlib.total_out = total_out; + continue; + } self.end(); return Ok(()); } @@ -982,6 +1002,25 @@ impl InflateDecoder { matches!(self.state, State::Inflating) } + /// `next` is the byte after a gzip member. 0x1f (ID1, RFC 1952 §2.3.1) starts another one, any other byte is junk that ends the content. + fn starts_next_member(&mut self, next: Option<&u8>) -> Result { + match next { + Some(0x1f) if self.multi_member => { + if self.reset() != ReturnCode::Ok { + self.state = State::Error; + return Err(ZlibError::ZlibError); + } + Ok(true) + } + Some(_) => { + // Decided once, or a later read of the junk that happens to start with 0x1f would be parsed as a member. + self.multi_member = false; + Ok(false) + } + None => Ok(false), + } + } + /// One `inflate()` call writing into `out`'s spare capacity. Same /// contract as [`DeflateEncoder::step`]. pub fn step( @@ -1020,19 +1059,8 @@ impl InflateDecoder { if matches!(self.state, State::Error) { return Ok(input_len); } - if matches!(self.state, State::End) { - // A prior call completed a gzip member at the chunk boundary. - // Only resume when the next byte is the gzip magic ID1 (RFC 1952 - // §2.3.1); any other trailing bytes are tolerated as garbage so - // stray CRLF/footer junk does not turn into a decode error. - if self.multi_member && input.first() == Some(&0x1f) { - if self.reset() != ReturnCode::Ok { - self.state = State::Error; - return Err(ZlibError::ZlibError); - } - } else { - return Ok(input_len); - } + if matches!(self.state, State::End) && !self.starts_next_member(input.first())? { + return Ok(input_len); } loop { if out.len() >= max_output { @@ -1063,15 +1091,7 @@ impl InflateDecoder { match rc { ReturnCode::StreamEnd => { self.state = State::End; - // Continue only when the next byte is the gzip magic ID1 - // (0x1f). Anything else is trailing garbage/padding and - // ends the stream, keeping prior tolerance for origins - // that append stray bytes after a valid member. - if self.multi_member && input.first() == Some(&0x1f) { - if self.reset() != ReturnCode::Ok { - self.state = State::Error; - return Err(ZlibError::ZlibError); - } + if self.starts_next_member(input.first())? { continue; } return Ok(input_len); diff --git a/test/bundler/bundler_compile.test.ts b/test/bundler/bundler_compile.test.ts index ac147a1d9dbd..4ad7218c6392 100644 --- a/test/bundler/bundler_compile.test.ts +++ b/test/bundler/bundler_compile.test.ts @@ -428,6 +428,8 @@ describe("bundler", () => { "/entry.ts": /* js */ ` import bar from './foo.file' with {type: "file"}; if ((await Bun.file(bar).text()).trim() !== "abcd") throw "fail"; + // The embedded file isn't on disk, and fetch() rejects a file: URL whose path doesn't exist. + if ((await (await fetch(Bun.pathToFileURL(bar))).text()).trim() !== "abcd") throw "fetch fail"; console.log("Hello, world!"); `, "/foo.file": /* js */ ` @@ -437,6 +439,43 @@ describe("bundler", () => { outfile: "dist/out", run: { stdout: "Hello, world!" }, }); + itBundled("compile/EmbeddedFileServeRoute", { + compile: true, + files: { + "/entry.ts": /* js */ ` + import { embeddedFiles, file, serve } from "bun"; + import asset from "./asset.txt" with { type: "file" }; + import "./icon.ico" with { type: "file" }; + + const routes: Record = { "/asset.txt": file(asset) }; + for (const blob of embeddedFiles) { + // "icon-a1b2c3d4.ico" -> "/embedded/icon.ico" + routes["/embedded/" + blob.name.replace(/-[a-z0-9]+\\./, ".")] = blob; + } + + using server = serve({ + port: 0, + routes, + fetch: () => new Response("fallback", { status: 404 }), + }); + for (const path of ["/asset.txt", "/embedded/asset.txt", "/embedded/icon.ico", "/missing"]) { + const res = await fetch(new URL(path, server.url)); + console.log(path, res.status, res.headers.get("Content-Type"), JSON.stringify(await res.text())); + } + `, + "/asset.txt": "embedded text", + "/icon.ico": "not really an icon", + }, + outfile: "dist/out", + run: { + stdout: ` + /asset.txt 200 text/plain;charset=utf-8 "embedded text" + /embedded/asset.txt 200 text/plain;charset=utf-8 "embedded text" + /embedded/icon.ico 200 image/x-icon "not really an icon" + /missing 404 text/plain;charset=utf-8 "fallback" + `, + }, + }); itBundled("compile/WorkerRelativePathNoExtension", { backend: "cli", compile: true, diff --git a/test/flaky-tests.txt b/test/flaky-tests.txt index 985810b30bf1..9a345913ba89 100644 --- a/test/flaky-tests.txt +++ b/test/flaky-tests.txt @@ -98,7 +98,7 @@ test/js/third_party/express/res.redirect.test.ts # 1 of 80 builds; parallel bat test/js/third_party/express/res.send.test.ts # 3 of 80 builds; mac; error: expected "ETag" header field test/js/third_party/express/res.sendFile.test.ts # 1 of 80 builds; parallel batch; should error for non-absolute path: expected body '\n' + (in the ... test/js/third_party/socket.io/socket.io-connection-state-recovery.test.ts # 1 of 80 builds; should not run middlewares upon recovery by default: JSON Parse error: Unable to parse... -test/js/web/fetch/abort-signal-leak.test.ts # 4 of 80 builds; parallel batch; 'abort' event on req.signal should not cause AbortSignal to never be GCed: test timed ... +test/js/web/fetch/abort-signal-leak.test.ts # 4 of 80 builds; parallel batch; req.signal with an 'abort' listener is collectable after the client aborts and the listener fires: test timed ... test/js/web/fetch/body.test.ts # 10 of 80 builds; rejects a fetch textStream() when the connection drops after an empty decode: expect(r... test/js/web/fetch/fetch-backpressure.test.ts # stalled no consumer drains the full body: test timed out (16 builds). The stall gate needs 512 KiB of socket buffers; #41014 lowers it to the client high-water mark test/js/web/fetch/fetch-gzip.test.ts # 2 of 80 builds; parallel batch; BUN_FEATURE_FLAG_NO_LIBDEFLATE=0: expect(received).toEqual(expected) (in the parallel ... diff --git a/test/harness.ts b/test/harness.ts index 3f5401ba44cb..53f2b5c750ee 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -2072,6 +2072,29 @@ https://buildkite.com/docs/pipelines/security/secrets/buildkite-secrets`; return value; } +/** + * Runs usockets' timeout sweep on this thread now instead of within 4 seconds, and resolves once it's done. + * `idleTimeout` 1 to 4 fires at the first sweep after the timer is armed, 5 to 8 at the second. + * Every socket on the thread moves one tick closer to its timeout, so don't call this from a concurrent test. + */ +export async function sweepSocketTimeouts() { + const pending = Promise.withResolvers(); + using canary = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + idleTimeout: 1, + fetch() { + pending.resolve(); + return new Promise(() => {}); + }, + }); + // The sweep closes this request. fetch() reports that from another thread, so the sweep is over by then. + const closed = fetch(canary.url).catch(pending.reject); + await pending.promise; + require("bun:internal-for-testing").runSocketTimeoutSweepSoon(); + await closed; +} + export function assertManifestsPopulated(absCachePath: string, registryUrl: string) { const { npm_manifest_test_helpers } = require("bun:internal-for-testing"); const { parseManifest } = npm_manifest_test_helpers; diff --git a/test/integration/bun-types/fixture/fetch.ts b/test/integration/bun-types/fixture/fetch.ts index 86a8abbf7444..d1e7bffbf54c 100644 --- a/test/integration/bun-types/fixture/fetch.ts +++ b/test/integration/bun-types/fixture/fetch.ts @@ -387,3 +387,13 @@ if (typeof process !== "undefined") { // @ts-expect-error crl takes PEM contents fetch("https://example.com", { tls: { crl: 1 } }); } + +// clone() returns the global Response / Request, with and without lib.dom +{ + const response: Response = new Response("hello").clone(); + response.textStream(); + response.headers.toJSON(); + + const request: Request = new Request("https://example.com").clone(); + request.textStream(); +} diff --git a/test/integration/bun-types/fixture/serve.ts b/test/integration/bun-types/fixture/serve.ts index 7eaee97aab5e..77bf50ab09c2 100644 --- a/test/integration/bun-types/fixture/serve.ts +++ b/test/integration/bun-types/fixture/serve.ts @@ -1,12 +1,34 @@ // This file is merely types only, you (probably) want to put the tests in ./serve-types.test.ts instead +import type { HTMLBundle } from "bun"; import { expectType } from "./utilities"; +// A method key takes every static route value except `false`. +declare const appHtml: HTMLBundle; +Bun.serve({ + routes: { + "/assets/*": { GET: { dir: "./public" } }, + "/file": { POST: Bun.file("./a.txt"), GET: new Response("ok") }, + "/app": { GET: appHtml }, + // @ts-expect-error + "/nope": { POST: false }, + }, +}); + Bun.serve({ routes: { "/:id/:test": req => { expectType(req.params).is<{ id: string; test: string }>(); }, + "/orgs/:orgId/repos/:repoId/settings": req => { + expectType(req.params).is<{ orgId: string; repoId: string }>(); + }, + "/files:batchGet/:id": req => { + expectType(req.params).is<{ id: string }>(); + }, + "/static/*": req => { + expectType(req.params).is<{}>(); + }, }, fetch: () => new Response("hello"), websocket: { @@ -85,6 +107,19 @@ const s4 = Bun.serve({ }, }); +// idleTimeout applies to a unix socket listener too +Bun.serve({ + unix: "/tmp/bun-types-fixture.sock", + idleTimeout: 0, + fetch: () => new Response("ok"), +}); + +Bun.serve({ + port: 0, + idleTimeout: 30, + fetch: () => new Response("ok"), +}); + Bun.serve({ tls: [ { key: "key", cert: "cert", ca: "ca", requestCert: true, crl: Bun.file("crl.pem") }, @@ -92,3 +127,16 @@ Bun.serve({ ], fetch: () => new Response(), }); + +Bun.serve({ + routes: { + "/on-disk.txt": Bun.file("on-disk.txt"), + "/in-memory.txt": new Blob(["in memory"], { type: "text/plain" }), + }, +}); + +const embeddedRoutes: Record = {}; +Bun.embeddedFiles.forEach((blob, i) => { + embeddedRoutes[`/embedded/${i}`] = blob; +}); +Bun.serve({ routes: embeddedRoutes }); diff --git a/test/internal/source-lints/jsresult-swallow.inventory.json b/test/internal/source-lints/jsresult-swallow.inventory.json index 8fbac0a16b35..8d3cd70f569a 100644 --- a/test/internal/source-lints/jsresult-swallow.inventory.json +++ b/test/internal/source-lints/jsresult-swallow.inventory.json @@ -6,7 +6,7 @@ "taken exception discarded": 1 }, "src/runtime/api/bun/h2_frame_parser.rs": { - "discarded result of a call that enters script": 4 + "discarded result of a call that enters script": 2 }, "src/runtime/ipc.rs": { "discarded result of a call that enters script": 4 diff --git a/test/js/bun/cookie/cookie-map.test.ts b/test/js/bun/cookie/cookie-map.test.ts index c285ead39f26..7177930abf34 100644 --- a/test/js/bun/cookie/cookie-map.test.ts +++ b/test/js/bun/cookie/cookie-map.test.ts @@ -203,6 +203,19 @@ describe("Bun.Cookie and Bun.CookieMap", () => { expect(map.toSetCookieHeaders()).toMatchInlineSnapshot(`[]`); }); + test("keeps a '%' that isn't followed by two hex digits in a cookie value", () => { + const map = new Bun.CookieMap("a=50%-off; b=caf%C3%A9; c=x%zz; d=100%25; e=abc%; f=x%2; g=a%25b%zz"); + expect(Object.fromEntries(map)).toEqual({ + a: "50%-off", + b: "café", + c: "x%zz", + d: "100%", + e: "abc%", + f: "x%2", + g: "a%b%zz", + }); + }); + test("can create CookieMap from object", () => { const map = new Bun.CookieMap({ name: "value", diff --git a/test/js/bun/http/body-leak-test-fixture.ts b/test/js/bun/http/body-leak-test-fixture.ts index 2a543f558e09..9536c4dc8da7 100644 --- a/test/js/bun/http/body-leak-test-fixture.ts +++ b/test/js/bun/http/body-leak-test-fixture.ts @@ -1,34 +1,32 @@ -const rss = process.memoryUsage.rss; +import { rss, tls } from "harness"; -async function memoryUsage() { +// Bun.gc(true) leaves returning freed pages to the OS to background scavengers, which run up to +// hundreds of ms later. Bun.shrink() scavenges synchronously as soon as the VM goes idle. +async function memoryUsage(): Promise { Bun.gc(true); - await Bun.sleep(50); + Bun.shrink(); + await new Promise(resolve => setImmediate(resolve)); return rss(); } +// Positive control for the test's leak detection. +const retainedBodies: Uint8Array[] = []; + const http2 = process.argv.includes("--http2"); const server = Bun.serve({ + hostname: "127.0.0.1", port: 0, idleTimeout: 0, - ...(http2 ? { tls: require("harness").tls, http2: true } : {}), + ...(http2 ? { tls, http2: true } : {}), async fetch(req: Request) { const url = req.url; if (url.endsWith("/report")) { - return new Response(JSON.stringify(await memoryUsage()), { - headers: { - "Content-Type": "application/json", - }, - }); + return Response.json(await memoryUsage()); } else if (url.endsWith("/heap-snapshot")) { - Bun.gc(true); - await Bun.sleep(10); + await memoryUsage(); require("v8").writeHeapSnapshot("/tmp/heap.heapsnapshot"); console.log("Wrote heap snapshot to /tmp/heap.heapsnapshot"); - return new Response(JSON.stringify(await memoryUsage()), { - headers: { - "Content-Type": "application/json", - }, - }); + return Response.json(await memoryUsage()); } if (url.endsWith("/json-buffering")) { await req.json(); @@ -39,32 +37,32 @@ const server = Bun.serve({ await req.text(); } else if (url.endsWith("/streaming")) { const reader = req.body!.getReader(); - while (reader) { - const { done, value } = await reader?.read(); + while (true) { + const { done } = await reader.read(); if (done) { break; } } } else if (url.endsWith("/incomplete-streaming")) { - const reader = req.body?.getReader(); - await reader?.read(); + await req.body!.getReader().read(); } else if (url.endsWith("/streaming-echo")) { return new Response(req.body, { headers: { "Content-Type": "application/octet-stream", }, }); + } else if (url.endsWith("/retain")) { + retainedBodies.push(await req.bytes()); } return new Response("Ok"); }, }); console.log(server.url.href); -process?.send?.(server.url.href); +process.send?.(server.url.href); if (!process.send) { - setInterval(() => { - Bun.gc(true); - const rssMB = (rss() / 1024 / 1024) | 0; + setInterval(async () => { + const rssMB = ((await memoryUsage()) / 1024 / 1024) | 0; console.log("RSS", rssMB, "MB"); console.log("Active requests", server.pendingRequests); diff --git a/test/js/bun/http/bun-serve-args.test.ts b/test/js/bun/http/bun-serve-args.test.ts index 22a1ccc01291..95dea309e380 100644 --- a/test/js/bun/http/bun-serve-args.test.ts +++ b/test/js/bun/http/bun-serve-args.test.ts @@ -1,6 +1,6 @@ import { serve } from "bun"; import { describe, expect, test } from "bun:test"; -import { isWindows, tls, tmpdirSync } from "../../../harness"; +import { bunEnv, bunExe, expectRssDeltaBelow, isLinux, isWindows, tempDir, tls, tmpdirSync } from "../../../harness"; const defaultHostname = "localhost"; @@ -446,6 +446,52 @@ describe("Bun.serve development options", () => { expect(server.development).toBe(false); server.stop(); }); + + describe.each(["NODE_ENV", "BUN_ENV"])("the default follows process.env.%s at the time of the call", key => { + async function run(atLaunch: string | undefined) { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const key = ${JSON.stringify(key)}; + const development = options => { + using server = Bun.serve({ port: 0, fetch: () => new Response("ok"), ...options }); + return server.development; + }; + const results = { atLaunch: development() }; + process.env[key] = "production"; + results.production = development(); + results.productionWithOption = development({ development: true }); + process.env[key] = "development"; + results.development = development(); + delete process.env[key]; + results.deleted = development(); + console.log(JSON.stringify(results));`, + ], + env: { ...bunEnv, NODE_ENV: undefined, BUN_ENV: undefined, [key]: atLaunch }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout: stdout && JSON.parse(stdout), stderr, exitCode }; + } + + test.concurrent("when it's unset at launch", async () => { + expect(await run(undefined)).toEqual({ + stdout: { atLaunch: true, production: false, productionWithOption: true, development: true, deleted: true }, + stderr: "", + exitCode: 0, + }); + }); + + test.concurrent("but a process launched in production stays in production", async () => { + expect(await run("production")).toEqual({ + stdout: { atLaunch: false, production: false, productionWithOption: true, development: false, deleted: false }, + stderr: "", + exitCode: 0, + }); + }); + }); }); describe("Bun.serve static routes", () => { @@ -492,6 +538,49 @@ describe("Bun.serve unix socket", () => { }); }); +describe("Bun.serve unix socket url", () => { + // A relative path's first segment is the URL's host, which forbids more bytes than a path does. + test.skipIf(isWindows)("percent-encodes what the URL parser can't keep", () => { + using dir = tempDir("unix-url", { "sub dir": {} }); + const cwd = process.cwd(); + process.chdir(String(dir)); + try { + for (const [path, href] of [ + ["C:\\test", "unix://C%3A%5Ctest"], + ["a#b?c.sock", "unix://a%23b%3Fc.sock"], + ["[a b]@c%d.sock", "unix://%5Ba%20b%5D%40c%25d.sock"], + ["a+b=c,d.sock", "unix://a+b=c,d.sock"], + ["sub dir/a:b@c.sock", "unix://sub%20dir/a:b@c.sock"], + [`${dir}/a b#c%d.sock`, `unix://${dir}/a%20b%23c%25d.sock`], + [`${dir}/a+b=c@d:e.sock`, `unix://${dir}/a+b=c@d:e.sock`], + ]) { + using server = serve({ unix: path, fetch: () => new Response("ok") }); + const url = server.url; + expect({ href: url.href, decoded: decodeURIComponent(url.hostname + url.pathname) }).toEqual({ + href, + decoded: path, + }); + } + } finally { + process.chdir(cwd); + } + }); + + // https://github.com/oven-sh/bun/issues/40182 + test.skipIf(!isWindows)("parses for a path with a drive letter and backslashes", () => { + using dir = tempDir("unix-url-drive", {}); + const path = String(dir).replaceAll("/", "\\") + "\\test.sock"; + using server = serve({ unix: path, fetch: () => new Response("ok") }); + expect(decodeURIComponent(server.url.hostname)).toBe(path); + }); + + test.skipIf(!isLinux)("percent-encodes an abstract socket name", () => { + const name = "bun url test/" + Math.random().toString(36).slice(2); + using server = serve({ unix: "\0" + name, fetch: () => new Response("ok") }); + expect(server.url.href).toBe(`abstract://${encodeURIComponent(name)}/`); + }); +}); + describe("Bun.serve hostname and port validation", () => { test("hostname with port 0 gets random port", () => { using server = serve({ @@ -865,6 +954,31 @@ describe("Bun.serve unix socket validation", () => { ).toThrow("Cannot disable http1 with a unix socket"); }); + test("unix option does not leak the string", async () => { + // Both calls throw before binding a socket. The first rejects hostname + unix. The second accepts + // the unix path and throws for the missing handler. + const code = /* js */ ` + const base = Buffer.alloc(512 * 1024, "x").toString(); + function expectThrow(fn, fragment) { + try { fn(); } catch (e) { if (e.message.includes(fragment)) return; throw e; } + throw new Error("expected Bun.serve() to throw: " + fragment); + } + function once(i) { + expectThrow(() => Bun.serve({ hostname: "127.0.0.1", unix: "/tmp/a" + i + base, fetch() {} }), "both hostname and unix"); + expectThrow(() => Bun.serve({ unix: "/tmp/b" + i + base }), "fetch"); + } + for (let i = 0; i < 20; i++) once(i); + Bun.gc(true); + const before = process.memoryUsage.rss(); + for (let i = 0; i < 200; i++) once(i); + Bun.gc(true); + console.log(JSON.stringify({ deltaMiB: (process.memoryUsage.rss() - before) / 1024 / 1024 })); + `; + + // A leak keeps 200 copies of the 512 KiB path per call site, so at least 100 MiB. + await expectRssDeltaBelow(["--smol", "-e", code], { release: 40, debug: 55 }); + }); + describe("invalid unix socket paths should throw", () => { const invalidPaths = [ { diff --git a/test/js/bun/http/bun-serve-file.test.ts b/test/js/bun/http/bun-serve-file.test.ts index 443263179c37..50ff4249d5f2 100644 --- a/test/js/bun/http/bun-serve-file.test.ts +++ b/test/js/bun/http/bun-serve-file.test.ts @@ -1,9 +1,24 @@ import type { Server } from "bun"; import { afterAll, beforeAll, describe, expect, it, mock, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isLinux, isWindows, rmScope, rss, tempDir, tempDirWithFiles } from "harness"; +import { bunEnv, bunExe, isASAN, isLinux, isWindows, rmScope, rss, tempDir, tempDirWithFiles, tls } from "harness"; import { mkfifo } from "mkfifo"; -import { closeSync, constants, openSync, readdirSync, unlinkSync, utimesSync, writeFileSync, writeSync } from "node:fs"; +import { + closeSync, + constants, + openSync, + readdirSync, + readFileSync, + readSync, + statSync, + truncateSync, + unlinkSync, + utimesSync, + writeFileSync, + writeSync, +} from "node:fs"; +import { connect as netConnect } from "node:net"; import { join } from "node:path"; +import { connect as tlsConnect } from "node:tls"; const LARGE_SIZE = 1024 * 1024 * 8; const files = { @@ -21,6 +36,16 @@ const files = { }; const ifRangeValidators = { "ETag": '"v1"', "Last-Modified": "Wed, 21 Oct 2015 07:28:00 GMT" }; +// toBe() elides the middle of multi-megabyte strings, so report the first character that differs. +function expectSameText(actual: string, expected: string) { + expect(actual.length).toBe(expected.length); + if (actual === expected) return; + let at = 0; + while (actual[at] === expected[at]) at++; + const around = (text: string) => `index ${at}: ${text.slice(Math.max(0, at - 10), at + 10)}`; + expect(around(actual)).toBe(around(expected)); +} + describe("Bun.file in serve routes", () => { let server: Server; let tempDir: string; @@ -35,6 +60,7 @@ describe("Bun.file in serve routes", () => { beforeAll(async () => { tempDir = tempDirWithFiles("bun-serve-file-test-", files); + const partialSlice = () => Bun.file(join(tempDir, "partial.txt")).slice(5, 10); const routes = { "/hello.txt": { GET: new Response(Bun.file(join(tempDir, "hello.txt"))), @@ -81,6 +107,41 @@ describe("Bun.file in serve routes", () => { "/partial-empty-slice-stream-handler": () => new Response(Bun.file(join(tempDir, "partial.txt")).slice(5, 5).stream()), "/partial-stream-handler": () => new Response(Bun.file(join(tempDir, "partial.txt")).stream()), + "/slice-handler": () => new Response(partialSlice()), + "/slice-handler-headers": () => new Response(partialSlice(), { headers: { "X-Custom": "1" } }), + "/slice-handler-headers-read": () => { + const res = new Response(partialSlice()); + res.headers.get("X-Custom"); + return res; + }, + "/slice-handler-body-read": () => { + const res = new Response(partialSlice()); + res.body; + return res; + }, + "/slice-handler-body-and-headers-read": () => { + const res = new Response(partialSlice()); + res.body; + res.headers.get("X-Custom"); + return res; + }, + "/slice-handler-copy": () => { + const res = new Response(partialSlice()); + return new Response(res.body, res); + }, + "/slice-handler-copy-clone": () => { + const res = new Response(partialSlice()); + return new Response(res.body, res).clone() as Response; + }, + "/slice-handler-request-copy": () => { + const req = new Request("http://example.com/", { method: "POST", body: partialSlice() }); + return new Response(req.body, req); + }, + "/slice-handler-request-copy-headers-read": () => { + const req = new Request("http://example.com/", { method: "POST", body: partialSlice() }); + req.headers.get("X-Custom"); + return new Response(req.body, req); + }, "/fd-not-supported.txt": (() => { // This would test file descriptors, but they're not supported yet return new Response(Bun.file(join(tempDir, "hello.txt"))); @@ -192,36 +253,13 @@ describe("Bun.file in serve routes", () => { expect(res.status).toBe(200); const bytes = await res.bytes(); expect(bytes).toEqual(new Uint8Array([0x00, 0x01, 0x02, 0x03, 0xff, 0xfe, 0xfd])); - expect(res.headers.get("Content-Type")).toMatch(/application\/octet-stream/); + expect(res.headers.get("Content-Type")).toBe("application/octet-stream"); }); it("serves large file", async () => { const res = await fetch(new URL(`/large.txt`, server.url)); expect(res.status).toBe(200); - const text = await res.text(); - expect(text).toHaveLength(LARGE_SIZE); - - if (files["large.txt"] !== text) { - console.log("Expected length:", files["large.txt"].length); - console.log("Actual length:", text.length); - console.log("First 100 chars expected:", files["large.txt"].slice(0, 100)); - console.log("First 100 chars actual:", text.slice(0, 100)); - console.log("Last 100 chars expected:", files["large.txt"].slice(-100)); - console.log("Last 100 chars actual:", text.slice(-100)); - - // Find first difference - for (let i = 0; i < Math.min(files["large.txt"].length, text.length); i++) { - if (files["large.txt"][i] !== text[i]) { - console.log(`First difference at index ${i}:`); - console.log(`Expected: "${files["large.txt"][i]}" (code: ${files["large.txt"].charCodeAt(i)})`); - console.log(`Actual: "${text[i]}" (code: ${text.charCodeAt(i)})`); - console.log(`Context around difference: "${files["large.txt"].slice(Math.max(0, i - 10), i + 10)}"`); - console.log(`Actual context: "${text.slice(Math.max(0, i - 10), i + 10)}"`); - break; - } - } - throw new Error("large.txt is not the same"); - } + expectSameText(await res.text(), files["large.txt"]); expect(res.headers.get("Content-Length")).toBe(LARGE_SIZE.toString()); @@ -258,7 +296,7 @@ describe("Bun.file in serve routes", () => { const res = await fetch(new URL(`/json.json`, server.url)); expect(res.status).toBe(200); expect(await res.json()).toEqual({ message: "test", number: 42 }); - expect(res.headers.get("Content-Type")).toMatch(/application\/json/); + expect(res.headers.get("Content-Type")).toBe("application/json;charset=utf-8"); }); it("serves nested file", async () => { @@ -280,7 +318,7 @@ describe("Bun.file in serve routes", () => { expect(res.status).toBe(200); expect(await res.text()).toBe(""); expect(res.headers.get("Content-Length")).toBe("13"); // "Hello, World!" length - expect(res.headers.get("Content-Type")).toMatch(/text\/plain/); + expect(res.headers.get("Content-Type")).toBe("text/plain;charset=utf-8"); }); it("supports GET requests", async () => { @@ -288,6 +326,72 @@ describe("Bun.file in serve routes", () => { expect(res.status).toBe(200); expect(await res.text()).toBe("Hello, World!"); }); + + it("HEAD of a file from the fetch handler sends GET's headers and no body", async () => { + using handlerServer = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: () => new Response(Bun.file(join(tempDir, "partial.txt"))), + }); + + // A raw socket shows any body bytes after the head. + const { promise: closed, resolve, reject } = Promise.withResolvers(); + let wire = ""; + await Bun.connect({ + hostname: "127.0.0.1", + port: handlerServer.port, + socket: { + open: socket => void socket.write("HEAD / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"), + data: (_, chunk) => void (wire += chunk.toString("latin1")), + close: () => resolve(), + error: (_, err) => reject(err), + }, + }); + await closed; + const [head, ...afterHead] = wire.split("\r\n\r\n"); + const lines = head.toLowerCase().split("\r\n"); + + const ranged = await fetch(handlerServer.url, { method: "HEAD", headers: { Range: "bytes=2-5" } }); + const unsatisfiable = await fetch(handlerServer.url, { method: "HEAD", headers: { Range: "bytes=99-" } }); + + expect({ + status: lines[0], + contentType: lines.filter(line => line.startsWith("content-type:")), + contentLength: lines.filter(line => line.startsWith("content-length:")), + afterHead, + ranged: { + status: ranged.status, + contentLength: ranged.headers.get("Content-Length"), + contentRange: ranged.headers.get("Content-Range"), + }, + unsatisfiable: { status: unsatisfiable.status, contentRange: unsatisfiable.headers.get("Content-Range") }, + }).toEqual({ + status: "http/1.1 200 ok", + contentType: ["content-type: text/plain;charset=utf-8"], + contentLength: ["content-length: 16"], + afterHead: [""], + ranged: { status: 206, contentLength: "4", contentRange: "bytes 2-5/16" }, + unsatisfiable: { status: 416, contentRange: "bytes */16" }, + }); + }); + + it("sends the slice's length as Content-Length for a slice of an fd-backed file", async () => { + const fd = openSync(join(tempDir, "partial.txt"), "r"); + try { + using handlerServer = Bun.serve({ + port: 0, + fetch: () => new Response(Bun.file(fd).slice(5, 10)), + }); + const probe = async (method: string) => { + const res = await fetch(handlerServer.url, { method }); + return { status: res.status, contentLength: res.headers.get("Content-Length"), body: await res.text() }; + }; + expect(await probe("HEAD")).toEqual({ status: 200, contentLength: "5", body: "" }); + expect(await probe("GET")).toEqual({ status: 200, contentLength: "5", body: "56789" }); + } finally { + closeSync(fd); + } + }); }); describe.concurrent("Custom headers and status", () => { @@ -305,6 +409,20 @@ describe("Bun.file in serve routes", () => { expect(res.statusText).toBe("Created"); expect(await res.text()).toBe("Hello, World!"); }); + + it("sends a disposition type with the automatic Content-Disposition filename", async () => { + using handlerServer = Bun.serve({ + port: 0, + fetch: req => new Response(Bun.file(join(tempDir, new URL(req.url).pathname))), + }); + const seen: Record = {}; + for (const name of ["binary.bin", "hello.txt"]) { + const res = await fetch(new URL(name, handlerServer.url)); + await res.arrayBuffer(); + seen[name] = res.headers.get("Content-Disposition"); + } + expect(seen).toEqual({ "binary.bin": 'inline; filename="binary.bin"', "hello.txt": null }); + }); }); describe("Error handling", () => { @@ -382,6 +500,8 @@ describe("Bun.file in serve routes", () => { }); expect(res.status).toBe(200); + expect(res.headers.get("Content-Length")).toBe("13"); + expect(await res.text()).toBe(method === "GET" ? "Hello, World!" : ""); }); }); @@ -405,18 +525,25 @@ describe("Bun.file in serve routes", () => { }); it("ignores If-Modified-Since for non-GET/HEAD requests", async () => { - const res1 = await fetch(new URL(`/hello.txt`, server.url)); + // `/hello.txt` only accepts GET and HEAD, so a POST would hit the fallback handler. + // `/hello-blob.txt` accepts every method. + const res1 = await fetch(new URL(`/hello-blob.txt`, server.url)); const lastModified = res1.headers.get("Last-Modified"); + expect(lastModified).not.toBeEmpty(); + const ifModifiedSince = new Date(Date.parse(lastModified!) + 10000).toISOString(); - const res2 = await fetch(new URL(`/hello.txt`, server.url), { - method: "POST", - headers: { - "If-Modified-Since": new Date(Date.parse(lastModified!) + 10000).toISOString(), - }, + const get = await fetch(new URL(`/hello-blob.txt`, server.url), { + headers: { "If-Modified-Since": ifModifiedSince }, }); + expect(get.status).toBe(304); + expect(await get.text()).toBe(""); - // Should not return 304 for POST - expect(res2.status).not.toBe(304); + const post = await fetch(new URL(`/hello-blob.txt`, server.url), { + method: "POST", + headers: { "If-Modified-Since": ifModifiedSince }, + }); + expect(post.status).toBe(200); + expect(await post.text()).toBe("Hello, World!"); }); describe.each(["/hello.txt", "/hello-blob.txt"])("If-None-Match on %s", path => { @@ -447,7 +574,7 @@ describe("Bun.file in serve routes", () => { }, }); expect(res.status).toBe(200); - if (method === "GET") expect(await res.text()).toBe("Hello, World!"); + expect(await res.text()).toBe(method === "GET" ? "Hello, World!" : ""); }); it("still 304s for If-None-Match: * when If-Modified-Since is also present", async () => { @@ -492,6 +619,7 @@ describe("Bun.file in serve routes", () => { // RFC 9110 §13.2.2 steps 1–2: If-Match / If-Unmodified-Since evaluate // first and short-circuit with 412 before If-None-Match / If-Modified-Since. + // fetch() never reads a HEAD body, so the "" expected for HEAD can't catch body bytes on the wire. describe.each(["GET", "HEAD"])("If-Match / If-Unmodified-Since (%s)", method => { it("If-Match: non-matching tag on a file route with ETag → 412", async () => { const res = await fetch(new URL(`/with-etag.txt`, server.url), { @@ -508,7 +636,7 @@ describe("Bun.file in serve routes", () => { headers: { "If-Match": '"custom-etag"' }, }); expect(res.status).toBe(200); - if (method === "GET") expect(await res.text()).toBe("Hello, World!"); + expect(await res.text()).toBe(method === "GET" ? "Hello, World!" : ""); }); it("If-Match: * on a file route without a stored ETag → 200", async () => { @@ -517,6 +645,7 @@ describe("Bun.file in serve routes", () => { headers: { "If-Match": "*" }, }); expect(res.status).toBe(200); + expect(await res.text()).toBe(method === "GET" ? "Hello, World!" : ""); }); it("If-Match: tag list on a file route without a stored ETag → 412", async () => { @@ -534,6 +663,7 @@ describe("Bun.file in serve routes", () => { headers: { "If-Match": 'W/"custom-etag"' }, }); expect(res.status).toBe(412); + expect(await res.text()).toBe(""); }); it("If-Unmodified-Since earlier than mtime → 412", async () => { @@ -553,6 +683,7 @@ describe("Bun.file in serve routes", () => { headers: { "If-Unmodified-Since": lm! }, }); expect(res.status).toBe(200); + expect(await res.text()).toBe(method === "GET" ? "Hello, World!" : ""); }); it("If-Match failure + If-None-Match match → 412 (not 304)", async () => { @@ -561,6 +692,7 @@ describe("Bun.file in serve routes", () => { headers: { "If-Match": '"zz"', "If-None-Match": '"custom-etag"' }, }); expect(res.status).toBe(412); + expect(await res.text()).toBe(""); }); it("If-Match failure + Range → 412 (no Content-Range)", async () => { @@ -579,6 +711,7 @@ describe("Bun.file in serve routes", () => { headers: { "If-Match": '"custom-etag"', "If-Unmodified-Since": "Mon, 01 Jan 2001 00:00:00 GMT" }, }); expect(res.status).toBe(200); + expect(await res.text()).toBe(method === "GET" ? "Hello, World!" : ""); }); }); @@ -598,7 +731,7 @@ describe("Bun.file in serve routes", () => { }); describe("Stress testing", () => { - test.each(["hello.txt", "large.txt"])( + test.each(["hello.txt", "large.txt"] as const)( "concurrent requests for %s", async filename => { const batchSize = 16; @@ -612,13 +745,8 @@ describe("Bun.file in serve routes", () => { }), ); - const results = await Promise.all(promises); - - // Verify all responses are identical - const expected = results[0]; - for (const result of results) { - expect(result?.length).toBe(expected.length); - expect(result).toBe(expected); + for (const result of await Promise.all(promises)) { + expectSameText(result, files[filename]); } } @@ -757,6 +885,36 @@ describe("Bun.file in serve routes", () => { }); }); + it("a handler's slice is a 206 with Content-Range until the handler passes or reads headers", async () => { + const serve = async (pathname: string) => { + const res = await fetch(new URL(pathname, server.url)); + return { status: res.status, contentRange: res.headers.get("Content-Range"), body: await res.text() }; + }; + const partial = { status: 206, contentRange: "bytes 5-9/*", body: "56789" }; + const whole = { status: 200, contentRange: null, body: "56789" }; + expect({ + "no headers": await serve("/slice-handler"), + "body read": await serve("/slice-handler-body-read"), + "copy": await serve("/slice-handler-copy"), + "clone of a copy": await serve("/slice-handler-copy-clone"), + "copy of a Request": await serve("/slice-handler-request-copy"), + "headers": await serve("/slice-handler-headers"), + "headers read": await serve("/slice-handler-headers-read"), + "body and headers read": await serve("/slice-handler-body-and-headers-read"), + "copy of a Request, headers read": await serve("/slice-handler-request-copy-headers-read"), + }).toEqual({ + "no headers": partial, + "body read": partial, + "copy": partial, + "clone of a copy": partial, + "copy of a Request": partial, + "headers": whole, + "headers read": whole, + "body and headers read": whole, + "copy of a Request, headers read": whole, + }); + }); + // The slice is shorter than the file, so the byte budget runs out before // the reader reports EOF: the response completes inline while a deferred // completion still hops through the event loop. Repeated requests must @@ -807,6 +965,86 @@ describe("Bun.file in serve routes", () => { } }); + it("HEAD of a missing file or a directory from the fetch handler fails like GET", async () => { + const fetchFile = (req: Request) => + new Response(Bun.file(new URL(req.url).pathname === "/dir" ? tempDir : join(tempDir, "nope.html"))); + using withoutError = Bun.serve({ port: 0, fetch: fetchFile }); + using withError = Bun.serve({ + port: 0, + fetch: fetchFile, + error: e => new Response(new Blob([`err ${e.code}`], { type: "text/x-error" }), { status: 500 }), + }); + const probe = async (server: Server, pathname: string, method: string) => { + const res = await fetch(new URL(pathname, server.url), { method }); + return [res.status, res.headers.get("Content-Length"), res.headers.get("Content-Type"), await res.text()]; + }; + const results: Record = {}; + for (const method of ["GET", "HEAD"]) { + for (const pathname of ["/missing", "/dir"]) { + results[`${method} ${pathname}`] = [ + await probe(withoutError, pathname, method), + await probe(withError, pathname, method), + ]; + } + } + expect(results).toEqual({ + "GET /missing": [ + [404, "0", null, ""], + [500, "10", "text/x-error", "err ENOENT"], + ], + "GET /dir": [ + [404, "0", null, ""], + [500, "10", "text/x-error", "err EISDIR"], + ], + "HEAD /missing": [ + [404, "0", null, ""], + [500, "10", "text/x-error", ""], + ], + "HEAD /dir": [ + [404, "0", null, ""], + [500, "10", "text/x-error", ""], + ], + }); + }); + + it("error()'s Response doesn't get the failed file's Content-Type or filename", async () => { + const replies = { + stream: () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("streamed")); + controller.close(); + }, + }), + { status: 500 }, + ), + bodiless: () => new Response(null, { status: 404 }), + }; + const probe = async (server: Server, pathname: string, method: string) => { + const res = await fetch(new URL(pathname, server.url), { method }); + await res.text(); + return [res.status, res.headers.get("Content-Type"), res.headers.get("Content-Disposition")]; + }; + for (const [name, reply] of Object.entries(replies)) { + using afterFailedFile = Bun.serve({ + port: 0, + fetch: req => + new Response(Bun.file(new URL(req.url).pathname === "/dir" ? tempDir : join(tempDir, "nope.html"))), + error: reply, + }); + const expected = [name === "stream" ? 500 : 404, null, null]; + for (const method of ["GET", "HEAD"]) { + expect({ + name, + method, + missing: await probe(afterFailedFile, "/missing", method), + dir: await probe(afterFailedFile, "/dir", method), + }).toEqual({ name, method, missing: expected, dir: expected }); + } + } + }); + it("preserves custom status for empty files", async () => { const res = await fetch(new URL(`/empty-400.txt`, server.url)); expect(res.status).toBe(400); @@ -839,7 +1077,7 @@ describe("Bun.file in serve routes", () => { const res = await fetch(new URL(`/hello.txt`, server.url), { method: "HEAD" }); expect(res.status).toBe(200); expect(res.headers.get("Content-Length")).toBe("13"); - expect(res.headers.get("Content-Type")).toMatch(/text\/plain/); + expect(res.headers.get("Content-Type")).toBe("text/plain;charset=utf-8"); expect(res.headers.get("Last-Modified")).not.toBeNull(); expect(await res.text()).toBe(""); }); @@ -853,7 +1091,12 @@ describe("Bun.file in serve routes", () => { // Abort immediately controller.abort(); - await expect(promise).rejects.toThrow(/abort/i); + const error = await promise.then( + () => null, + e => e, + ); + expect(error).toBeInstanceOf(DOMException); + expect(error.name).toBe("AbortError"); }); }); @@ -880,17 +1123,17 @@ describe("Bun.file in serve routes", () => { describe.concurrent("Content-Type detection", () => { it("detects text/plain for .txt files", async () => { const res = await fetch(new URL(`/hello.txt`, server.url)); - expect(res.headers.get("Content-Type")).toMatch(/text\/plain/); + expect(res.headers.get("Content-Type")).toBe("text/plain;charset=utf-8"); }); it("detects application/json for .json files", async () => { const res = await fetch(new URL(`/json.json`, server.url)); - expect(res.headers.get("Content-Type")).toMatch(/application\/json/); + expect(res.headers.get("Content-Type")).toBe("application/json;charset=utf-8"); }); it("detects application/octet-stream for binary files", async () => { const res = await fetch(new URL(`/binary.bin`, server.url)); - expect(res.headers.get("Content-Type")).toMatch(/application\/octet-stream/); + expect(res.headers.get("Content-Type")).toBe("application/octet-stream"); }); }); @@ -951,8 +1194,11 @@ describe("Bun.file in serve routes", () => { it("ignores Range for non-GET/HEAD methods", async () => { // RFC 9110 §14.2: Range is only defined for GET. const res = await fetch(new URL(path, server.url), { method: "POST", headers: { Range: "bytes=0-3" } }); - expect(res.status).not.toBe(206); - expect(res.headers.get("content-range")).toBeNull(); + expect({ + status: res.status, + contentRange: res.headers.get("content-range"), + body: await res.text(), + }).toEqual({ status: 200, contentRange: null, body }); }); }); @@ -1106,9 +1352,7 @@ describe("Bun.file in serve routes", () => { const res = await fetch(new URL("/slice-escape", server.url), { headers: { Range: "bytes=200-220" } }); const bytes = new Uint8Array(await res.arrayBuffer()); // Range must be ignored for sliced blobs: serve the 100-byte slice, never bytes 200-220. - expect(bytes.length).toBe(100); - expect(bytes[0]).toBe(0); - expect(bytes[99]).toBe(99); + expect(bytes).toEqual(Uint8Array.from({ length: 100 }, (_, i) => i)); expect(res.headers.get("content-range")).not.toContain("/256"); }); }); @@ -1375,6 +1619,68 @@ test.skipIf(isWindows)("Response(Bun.file(FIFO)) frames the body as chunked, not } }); +// Sends one request on its own connection. `Connection: close` makes the server hang up when it's done, +// so `body` is every byte after the head, whatever Content-Length says. +function wireResponse(port: number, method: "GET" | "HEAD", path: string, { secure = false, headers = "" } = {}) { + const { promise, resolve, reject } = Promise.withResolvers<{ + status: number; + head: string; + contentRange: string | null; + contentLength: string | null; + body: string; + }>(); + const address = { host: "127.0.0.1", port }; + const socket = secure ? tlsConnect({ ...address, ca: tls.cert }) : netConnect(address); + let raw = ""; + socket.setEncoding("latin1"); + socket.on("error", reject); + socket.on("data", chunk => (raw += chunk)); + socket.on("close", () => { + const headEnd = raw.indexOf("\r\n\r\n"); + if (headEnd === -1) return reject(new Error(`no response head in ${JSON.stringify(raw)}`)); + const head = raw.slice(0, headEnd); + resolve({ + status: Number(head.split(" ")[1]), + head, + contentRange: /^content-range:\s*(.*)$/im.exec(head)?.[1] ?? null, + contentLength: /^content-length:\s*(\d+)/im.exec(head)?.[1] ?? null, + body: raw.slice(headEnd + 4), + }); + }); + socket.write(`${method} ${path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n${headers}\r\n`); + return promise; +} + +// fetch() rejects a response whose header has a control byte, so read the header off the wire. +test("omits the automatic Content-Disposition for a file name that a quoted-string can't carry", async () => { + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: req => + new Response(new File(["x"], new URL(req.url).searchParams.get("name")!, { type: "application/octet-stream" })), + }); + const sent = (basename: string) => `inline; filename="${basename}"`; + const expected: Record = { + "plain.bin": sent("plain.bin"), + "tab\tname.bin": sent("tab\tname.bin"), + "!#[]~.bin": sent("!#[]~.bin"), + "café.bin": sent("café.bin"), + 'quo"te.bin': null, + "ctl\x01name.bin": null, + "del\x7fname.bin": null, + // `\` is a path separator on Windows, so it never reaches the basename. + "report\\": isWindows ? sent("report") : null, + "back\\slash.bin": isWindows ? sent("slash.bin") : null, + }; + const seen: Record = {}; + for (const name of Object.keys(expected)) { + const { head } = await wireResponse(server.port!, "GET", `/?name=${encodeURIComponent(name)}`); + const value = /^content-disposition: (.*)$/im.exec(head)?.[1]; + seen[name] = value === undefined ? null : Buffer.from(value, "latin1").toString(); + } + expect(seen).toEqual(expected); +}); + // A file route serves the window of the Bun.file() slice it was built from, // given either the slice or its unread stream (which is turned back into the // slice). FileRoute used to clamp the window to the file size without taking @@ -1406,36 +1712,9 @@ test("file routes frame a slice that reaches or starts past EOF by the bytes the fetch: () => new Response("fallback", { status: 404 }), }); - // One GET on its own connection; `Connection: close` makes the server hang - // up once it considers the response finished, so `body` is every byte that - // followed the head, however many the head declared. async function wire(path: string) { - const { promise, resolve } = Promise.withResolvers(); - let captured = ""; - await Bun.connect({ - hostname: "127.0.0.1", - port: server.port!, - socket: { - open(socket) { - socket.write(`GET ${path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n`); - }, - data(_socket, chunk) { - captured += Buffer.from(chunk).toString("latin1"); - }, - close() { - resolve(captured); - }, - error() { - resolve(captured); - }, - }, - }); - const raw = await promise; - const headEnd = raw.indexOf("\r\n\r\n"); - return { - contentLength: /^content-length:\s*(\d+)/im.exec(raw.slice(0, headEnd))?.[1] ?? null, - body: raw.slice(headEnd + 4), - }; + const { contentLength, body } = await wireResponse(server.port!, "GET", path); + return { contentLength, body }; } const results: Record = {}; @@ -1454,6 +1733,153 @@ test("file routes frame a slice that reaches or starts past EOF by the bytes the }); }); +test("a Bun.file() route drops Transfer-Encoding and Content-Length from the Response's headers", async () => { + using dir = tempDir("serve-file-route-framing-headers", { "a.txt": "file-body" }); + const file = Bun.file(join(String(dir), "a.txt")); + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + routes: { + "/transfer-encoding": new Response(file, { headers: { "Transfer-Encoding": "chunked" } }), + "/content-length": new Response(file, { headers: { "Content-Length": "3" } }), + "/both": new Response(file, { + headers: { "Transfer-Encoding": "chunked", "Content-Length": "3", "X-Kept": "yes" }, + }), + }, + }); + const framing = async (method: "GET" | "HEAD", path: string) => { + const { head, body } = await wireResponse(server.port!, method, path); + const headers = head + .toLowerCase() + .split("\r\n") + .filter(line => /^(content-length|transfer-encoding|x-kept):/.test(line)); + return { headers: headers.sort(), body }; + }; + expect({ + transferEncoding: await framing("GET", "/transfer-encoding"), + contentLength: await framing("GET", "/content-length"), + both: await framing("GET", "/both"), + head: await framing("HEAD", "/both"), + }).toEqual({ + transferEncoding: { headers: ["content-length: 9"], body: "file-body" }, + contentLength: { headers: ["content-length: 9"], body: "file-body" }, + both: { headers: ["content-length: 9", "x-kept: yes"], body: "file-body" }, + head: { headers: ["content-length: 9", "x-kept: yes"], body: "" }, + }); +}); + +test("a fetch handler frames a Bun.file() slice by the bytes it serves", async () => { + using dir = tempDir("serve-handler-slice-framing", { + "partial.txt": "0123456789ABCDEF", + "shrinks.txt": "0123456789ABCDEF", + }); + const path = join(String(dir), "partial.txt"); + + // Stored as 11 bytes from byte 5. The file then shrinks to 11 bytes, so only 6 are left from byte 5. + const shrinks = Bun.file(join(String(dir), "shrinks.txt")); + expect(shrinks.size).toBe(16); + const sliceBeforeShrink = shrinks.slice(5); + writeFileSync(join(String(dir), "shrinks.txt"), "0123456789A"); + + const fd = openSync(path, "r"); + try { + const windows: Record Response> = { + "slice(5, 21)": () => new Response(Bun.file(path).slice(5, 21)), + "slice(5, 21) with a header": () => new Response(Bun.file(path).slice(5, 21), { headers: { "X-Custom": "1" } }), + "slice(5, 21).stream()": () => new Response(Bun.file(path).slice(5, 21).stream()), + "slice(16, 32)": () => new Response(Bun.file(path).slice(16, 32)), + "slice(5, 20)": () => new Response(Bun.file(path).slice(5, 20)), + "slice(0, 16)": () => new Response(Bun.file(path).slice(0, 16)), + "slice(5) of a file that shrank": () => new Response(sliceBeforeShrink), + "fd slice(5, 21)": () => new Response(Bun.file(fd).slice(5, 21)), + }; + const names = Object.keys(windows); + const handler = (req: Request) => windows[names[Number(new URL(req.url).pathname.slice(1))]](); + await using server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: handler }); + await using secureServer = Bun.serve({ port: 0, hostname: "127.0.0.1", tls, fetch: handler }); + + const framing: Record = {}; + const statuses: Record = {}; + for (const [i, name] of names.entries()) { + const get = await wireResponse(server.port!, "GET", `/${i}`); + const secure = await wireResponse(secureServer.port!, "GET", `/${i}`, { secure: true }); + const head = await wireResponse(server.port!, "HEAD", `/${i}`); + framing[name] = { + get: { contentLength: get.contentLength, body: get.body }, + secure: { contentLength: secure.contentLength, body: secure.body }, + head: { contentLength: head.contentLength, body: head.body }, + }; + statuses[name] = [get, secure, head].map(({ status, contentRange }) => `${status} ${contentRange}`); + } + const exactly = (body: string) => ({ + get: { contentLength: String(body.length), body }, + secure: { contentLength: String(body.length), body }, + head: { contentLength: String(body.length), body: "" }, + }); + expect(framing).toEqual({ + "slice(5, 21)": exactly("56789ABCDEF"), + "slice(5, 21) with a header": exactly("56789ABCDEF"), + "slice(5, 21).stream()": exactly("56789ABCDEF"), + "slice(16, 32)": exactly(""), + "slice(5, 20)": exactly("56789ABCDEF"), + "slice(0, 16)": exactly("0123456789ABCDEF"), + "slice(5) of a file that shrank": exactly("56789A"), + "fd slice(5, 21)": exactly("56789ABCDEF"), + }); + const all = (status: T) => [status, status, status]; + expect(statuses).toEqual({ + "slice(5, 21)": all("206 bytes 5-15/*"), + "slice(5, 21) with a header": all("200 null"), + "slice(5, 21).stream()": all("206 bytes 5-15/*"), + "slice(16, 32)": all(expect.stringMatching(/^206 /)), + "slice(5, 20)": all("206 bytes 5-15/*"), + "slice(0, 16)": all("200 null"), + "slice(5) of a file that shrank": all("206 bytes 5-10/*"), + "fd slice(5, 21)": all("200 null"), + }); + } finally { + closeSync(fd); + } +}); + +test("reads a Bun.file(fd) body at its offset, not at the descriptor's position", async () => { + using dir = tempDir("serve-fd-position", { "partial.txt": "0123456789ABCDEF" }); + const fd = openSync(join(String(dir), "partial.txt"), "r"); + try { + const readAtPosition = (length: number) => { + const buffer = Buffer.alloc(length); + return buffer.toString("latin1", 0, readSync(fd, buffer, 0, length, null)); + }; + expect(readAtPosition(3)).toBe("012"); + + const handler = (req: Request) => + new Response(req.url.endsWith("/slice") ? Bun.file(fd).slice(0, 4) : Bun.file(fd)); + await using server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: handler }); + await using secureServer = Bun.serve({ port: 0, hostname: "127.0.0.1", tls, fetch: handler }); + const get = async (port: number, path: string, options?: { secure?: boolean; headers?: string }) => { + const { status, contentLength, body } = await wireResponse(port, "GET", path, options); + return `${status} ${contentLength} ${body}`; + }; + expect({ + whole: await get(server.port!, "/"), + again: await get(server.port!, "/"), + secure: await get(secureServer.port!, "/", { secure: true }), + slice: await get(server.port!, "/slice"), + range: await get(server.port!, "/", { headers: "Range: bytes=0-3\r\n" }), + nextByteAtPosition: readAtPosition(1), + }).toEqual({ + whole: "200 16 0123456789ABCDEF", + again: "200 16 0123456789ABCDEF", + secure: "200 16 0123456789ABCDEF", + slice: "200 4 0123", + range: "206 4 0123", + nextByteAtPosition: "3", + }); + } finally { + closeSync(fd); + } +}); + // kqueue doesn't wake a FIFO reader when an empty pipe's last writer closes. test.skipIf(!isLinux)("Response(Bun.file(fifo)) ends the body when the EOF arrives after the data", async () => { using dir = tempDir("serve-fifo-eof", {}); @@ -1472,6 +1898,135 @@ test.skipIf(!isLinux)("Response(Bun.file(fifo)) ends the body when the EOF arriv expect({ first: Buffer.from(first.value!).toString(), done: last.done }).toEqual({ first: "hello", done: true }); }); +describe.concurrent("Bun.file() body that ends short of its Content-Length", () => { + // Resolves once the server closes the connection. `onBody` runs on the first body bytes. + function getUntilClosed(server: Server, secure: boolean, pathname: string, onBody = () => {}) { + const { promise, resolve, reject } = Promise.withResolvers<{ head: string; bodyLength: number; body: string }>(); + const address = { host: "127.0.0.1", port: server.port! }; + const socket = secure ? tlsConnect({ ...address, ca: tls.cert }) : netConnect(address); + let received = ""; + let head = ""; + let bodyLength = 0; + let body = ""; + socket.on("error", reject); + socket.on("close", () => resolve({ head, bodyLength, body })); + socket.on("data", chunk => { + if (!head) { + received += chunk.toString("latin1"); + const headEnd = received.indexOf("\r\n\r\n"); + if (headEnd < 0) return; + head = received.slice(0, headEnd).toLowerCase(); + chunk = Buffer.from(received.slice(headEnd + 4), "latin1"); + } + if (chunk.length === 0) return; + if (bodyLength === 0) onBody(); + bodyLength += chunk.length; + if (body.length < 64) body += chunk.toString("latin1", 0, 64); + }); + socket.write(`GET ${pathname} HTTP/1.1\r\nHost: x\r\n\r\n`); + return promise; + } + + // Sparse, and far bigger than the loopback socket buffers, so the server can't send it all before it shrinks. + const SIZE = 256 * 1024 * 1024; + + test.each([ + ["fetch handler", "/big.bin", false], + ["fetch handler", "/big.bin", true], + ["file route", "/route", false], + ["directory route", "/dir/big.bin", true], + ] as const)("closes the connection when the file shrinks mid-body (%s %s, tls: %p)", async (_, pathname, secure) => { + using dir = tempDir("serve-file-shrinks", { "big.bin": "" }); + const path = join(String(dir), "big.bin"); + truncateSync(path, SIZE); + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + idleTimeout: 0, + tls: secure ? tls : undefined, + routes: { "/route": new Response(Bun.file(path)), "/dir/*": { dir: String(dir) } }, + fetch: () => new Response(Bun.file(path)), + }); + let pendingWhenShrunk = -1; + const { head, bodyLength } = await getUntilClosed(server, secure, pathname, () => { + pendingWhenShrunk = server.pendingRequests; + truncateSync(path, 4096); + }); + expect({ pendingWhenShrunk, contentLength: /^content-length: (\d+)$/m.exec(head)?.[1] }).toEqual({ + pendingWhenShrunk: 1, + contentLength: String(SIZE), + }); + expect(bodyLength).toBeLessThan(SIZE); + }); + + test.each([false, true])("keeps the connection open after a complete body (tls: %p)", async secure => { + using dir = tempDir("serve-file-complete", { + "small.bin": Buffer.alloc(1000, "a"), + "large.bin": Buffer.alloc(2 * 1024 * 1024, "b"), + }); + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + tls: secure ? tls : undefined, + fetch: req => new Response(Bun.file(join(String(dir), new URL(req.url).pathname))), + }); + const address = { host: "127.0.0.1", port: server.port! }; + const socket = secure ? tlsConnect({ ...address, ca: tls.cert }) : netConnect(address); + try { + const chunks: Buffer[] = []; + let closed = false; + let wake = Promise.withResolvers(); + socket.on("error", error => wake.reject(error)); + socket.on("close", () => ((closed = true), wake.resolve())); + socket.on("data", chunk => (chunks.push(chunk), wake.resolve())); + const get = async (pathname: string) => { + socket.write(`GET ${pathname} HTTP/1.1\r\nHost: x\r\n\r\n`); + let received = Buffer.alloc(0); + while (true) { + received = Buffer.concat([received, ...chunks.splice(0)]); + const headEnd = received.indexOf("\r\n\r\n"); + if (headEnd >= 0) { + const head = received.toString("latin1", 0, headEnd).toLowerCase(); + const contentLength = Number(/^content-length: (\d+)$/m.exec(head)?.[1]); + if (received.length >= headEnd + 4 + contentLength) + return { contentLength, bodyLength: received.length - headEnd - 4, close: head.includes("connection:") }; + } + if (closed) throw new Error(`the server closed the connection during ${pathname}`); + await wake.promise; + wake = Promise.withResolvers(); + } + }; + const complete = (length: number) => ({ contentLength: length, bodyLength: length, close: false }); + expect([await get("/small.bin"), await get("/large.bin"), await get("/small.bin")]).toEqual([ + complete(1000), + complete(2 * 1024 * 1024), + complete(1000), + ]); + } finally { + socket.destroy(); + } + }); + + // fstat() reports a whole page for a sysfs attribute, and read() returns a few bytes. + test.skipIf(!isLinux)("sends a file whose stat size overstates it, then closes the connection", async () => { + const attribute = "/sys/devices/system/cpu/online"; + const content = readFileSync(attribute, "latin1"); + const { size } = statSync(attribute); + expect(size).toBeGreaterThan(content.length); + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + idleTimeout: 0, + fetch: () => new Response(Bun.file(attribute)), + }); + const { head, body } = await getUntilClosed(server, false, "/"); + expect({ headers: head.split("\r\n").filter(line => /^(content-length|connection):/.test(line)), body }).toEqual({ + headers: [`content-length: ${size}`, "connection: close"], + body: content, + }); + }); +}); + // A request that declares a body arms the request-body (onData) callback on // the uWS response before the fetch handler runs. uWS keeps a single shared // userdata slot per response, so when the handler returns a file response @@ -1554,6 +2109,7 @@ process.exit(0); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stdout.trim()).toBe("file-response-started\nstill-serving"); + expect(stderr).toBe(""); expect(exitCode).toBe(0); }, 30_000); @@ -1683,14 +2239,212 @@ test("file route serves a burst of concurrent requests after reloads", async () expect(a).toBe("a-new"); }); +// https://github.com/oven-sh/bun/issues/4292 +test.concurrent.each([true, false])( + "Response(Bun.file()) that can't be opened responds 404 or 403 (development: %p)", + async development => { + using dir = tempDir("serve-file-open-error", { "ok.txt": "ok", "noperm.txt": "secret", "sub/.keep": "" }); + const paths = ["/ok.txt", "/missing.txt", "/ok.txt/child"]; + // open() on a directory fails with a different errno on Windows. + if (!isWindows) paths.push("/sub"); + // Root and Windows ignore the mode bits. + const canDeny = !isWindows && process.getuid!() !== 0; + if (canDeny) paths.push("/noperm.txt"); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + require("node:fs").chmodSync("noperm.txt", 0); + const fetchFile = req => new Response(Bun.file("." + new URL(req.url).pathname)); + const codes = []; + const servers = { + none: Bun.serve({ port: 0, development: ${development}, fetch: fetchFile }), + undefined: Bun.serve({ port: 0, development: ${development}, fetch: fetchFile, error: e => void codes.push(e.code) }), + response: Bun.serve({ port: 0, development: ${development}, fetch: fetchFile, error: e => new Response(e.code, { status: 410 }) }), + }; + for (const [name, server] of Object.entries(servers)) { + for (const path of ${JSON.stringify(paths)}) { + const res = await fetch(new URL(path, server.url)); + console.log(name, path, res.status, res.statusText, res.headers.get("content-length"), await res.text()); + } + server.stop(true); + } + console.log(codes.join()); + `, + ], + 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 defaults = (name: string) => [ + `${name} /ok.txt 200 OK 2 ok`, + `${name} /missing.txt 404 Not Found 0 `, + `${name} /ok.txt/child 404 Not Found 0 `, + ...(isWindows ? [] : [`${name} /sub 404 Not Found 0 `]), + ...(canDeny ? [`${name} /noperm.txt 403 Forbidden 0 `] : []), + ]; + const codes = ["ENOENT", "ENOTDIR", ...(isWindows ? [] : ["EISDIR"]), ...(canDeny ? ["EACCES"] : [])]; + expect({ stdout: stdout.split("\n"), stderr }).toEqual({ + stdout: [ + ...defaults("none"), + ...defaults("undefined"), + "response /ok.txt 200 OK 2 ok", + ...codes.map((code, i) => `response ${paths[i + 1]} 410 Gone ${code.length} ${code}`), + codes.join(), + "", + ], + stderr: "", + }); + expect(exitCode).toBe(0); + }, +); + +test.concurrent.each([ + ["throws", `() => { throw new Error("boom") }`, "boom"], + ["rejects", `() => Promise.reject(new Error("boom"))`, "boom"], + ["returns another missing file", `() => new Response(Bun.file("./404.html"))`, "404.html"], +])("Response(Bun.file(missing)) responds 500 when error() %s", async (_, error, reported) => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + using server = Bun.serve({ + port: 0, + development: false, + fetch: () => new Response(Bun.file("./missing.txt")), + error: ${error}, + }); + console.log((await fetch(server.url)).status); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("500\n"); + expect(stderr).toContain(reported); + expect(exitCode).toBe(1); +}); + +// Installs a seccomp filter that fails sendfile(2) with SENDFILE_ERRNO, except for a call that asks for SENDFILE_ALLOW_COUNT bytes. +const sendfileRefusedFixture = ` +const { dlopen, ptr } = require("bun:ffi"); +const { SENDFILE_ERRNO, SENDFILE_ALLOW_COUNT, FILE } = process.env; +let prctl; +for (const name of ["libc.so.6", "libc.musl-x86_64.so.1", "libc.musl-aarch64.so.1"]) { + try { + prctl = dlopen(name, { prctl: { args: ["i32", "u64", "u64", "u64", "u64"], returns: "i32" } }).symbols.prctl; + break; + } catch {} +} +const LD_W_ABS = 0x20, JEQ_K = 0x15, RET_K = 0x06, RET_ALLOW = 0x7fff0000, RET_ERRNO = 0x00050000; +const instructions = [ + [LD_W_ABS, 0, 0, 0], // seccomp_data.nr + [JEQ_K, 0, 3, process.arch === "x64" ? 40 : 71], // __NR_sendfile + [LD_W_ABS, 0, 0, 40], // low half of seccomp_data.args[3], the count + [JEQ_K, 1, 0, Number(SENDFILE_ALLOW_COUNT)], + [RET_K, 0, 0, RET_ERRNO | Number(SENDFILE_ERRNO)], + [RET_K, 0, 0, RET_ALLOW], +]; +const filter = new DataView(new ArrayBuffer(instructions.length * 8)); +instructions.forEach(([code, jt, jf, k], i) => { + filter.setUint16(i * 8, code, true); + filter.setUint8(i * 8 + 2, jt); + filter.setUint8(i * 8 + 3, jf); + filter.setUint32(i * 8 + 4, k, true); +}); +const program = new DataView(new ArrayBuffer(16)); +program.setUint16(0, instructions.length, true); +program.setBigUint64(8, BigInt(ptr(filter)), true); +const PR_SET_NO_NEW_PRIVS = 38, PR_SET_SECCOMP = 22, SECCOMP_MODE_FILTER = 2n; +if (!prctl || prctl(PR_SET_NO_NEW_PRIVS, 1n, 0n, 0n, 0n) || prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, BigInt(ptr(program)), 0n, 0n)) + process.exit(3); +if (!FILE) process.exit(0); + +using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: req => (req.url.endsWith("/probe") ? new Response("ok") : new Response(Bun.file(FILE))), +}); +const { promise, resolve, reject } = Promise.withResolvers(); +const chunks = []; +const client = require("node:net").connect(server.port, "127.0.0.1"); +client.pause(); +client.on("data", chunk => chunks.push(chunk)); +client.on("close", resolve); +client.on("error", reject); +client.write("GET / HTTP/1.1\\r\\nHost: x\\r\\nConnection: close\\r\\n\\r\\n"); +// The server handles this request after the one above, so its first sendfile(2) call has returned. +await (await fetch(new URL("/probe", server.url))).text(); +client.resume(); +await promise; +const wire = Buffer.concat(chunks); +const headEnd = wire.indexOf("\\r\\n\\r\\n"); +const body = wire.subarray(headEnd + 4); +console.log(JSON.stringify({ + status: wire.toString("latin1", 0, wire.indexOf("\\r\\n")), + contentLength: /^content-length: (\\d+)$/im.exec(wire.toString("latin1", 0, headEnd))?.[1], + bodyLength: body.length, + bodyMatches: body.equals(require("node:fs").readFileSync(FILE)), +})); +`; + +const canFilterSyscalls = + isLinux && + (await Bun.spawn({ + cmd: [bunExe(), "-e", sendfileRefusedFixture], + env: { ...bunEnv, SENDFILE_ERRNO: "22", SENDFILE_ALLOW_COUNT: "0" }, + stdio: ["ignore", "ignore", "ignore"], + }).exited) === 0; + +describe.skipIf(!canFilterSyscalls)("falls back to read() and write() when sendfile(2) is refused", () => { + test.concurrent.each([ + ["with EINVAL", 22, 2 * 1024 * 1024, false], + ["with ENOSYS", 38, 2 * 1024 * 1024, false], + ["with EOPNOTSUPP", 95, 2 * 1024 * 1024, false], + ["with EPERM", 1, 2 * 1024 * 1024, false], + // Bigger than the loopback socket buffers, so the first call can't send the whole file to the paused client. + ["after the first call sent part of the file", 22, 16 * 1024 * 1024, true], + ])("%s", async (_, errno, size, allowFirstCall) => { + const content = Buffer.alloc(size); + for (let i = 0; i < size; i += 4096) content.writeUInt32LE(i, i); + using dir = tempDir("serve-sendfile-refused", { "large.bin": content }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", sendfileRefusedFixture], + env: { + ...bunEnv, + FILE: join(String(dir), "large.bin"), + SENDFILE_ERRNO: String(errno), + SENDFILE_ALLOW_COUNT: String(allowFirstCall ? size : 0), + }, + 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({ + status: "HTTP/1.1 200 OK", + contentLength: String(size), + bodyLength: size, + bodyMatches: true, + }); + expect(exitCode).toBe(0); + }); +}); + // Linux file responses >= 1MB go through sendfile(2), which writes straight to // the socket fd. When the response header block only partially reaches the // kernel (its tail parked in the userspace socket buffer), the first sendfile // round must wait for that tail to flush; otherwise file bytes overtake it and // the client sees file bytes interleaved before the end of the headers. The -// big x-pad header makes the partial header send happen on most iterations, -// and the separate client process supplies the concurrent reads that open -// kernel buffer space between the header write and the sendfile call. +// big x-pad header is far larger than the kernel send buffer, so every header +// write is partial. The separate client processes supply the concurrent reads +// that open kernel buffer space between the header write and the sendfile call. test.skipIf(!isLinux)( "sendfile does not overtake a buffered response header tail", async () => { @@ -1700,19 +2454,20 @@ test.skipIf(!isLinux)( // server thread, which is sticky per process; fresh client processes re-roll // it, so several short-lived clients catch what one long-lived client can // miss. - const ITERATIONS_PER_CLIENT = 16; + const ITERATIONS_PER_CLIENT = 8; const CLIENTS_PER_WAVE = 3; const WAVES = 3; using dir = tempDir("serve-sendfile-tail", { "client-fixture.ts": ` - // One GET per fresh connection. Reads with plain blocking recv(2) via - // FFI: when response bytes land, the kernel wakes the parked task and - // sends the window-update ACK from kernel context within microseconds, - // which is what opens kernel send-buffer space right after the server's - // partial header write. The file is all 0xEE; headers are ASCII, so any - // 0xEE before the header terminator means sendfile bytes jumped ahead of - // the header tail. + // One GET per fresh connection, read with plain blocking recv(2) via + // FFI. SO_RCVBUF pins the receive window far below the header block, so + // the server's header write always stalls with most of the block parked + // in userspace. Each recv() of a full window sends a window-update ACK, + // which opens kernel send-buffer space. On many connections one of them + // lands between the partial header write and the first sendfile call. + // The file is all 0xEE and the header block is ASCII, so any 0xEE before + // the header terminator means sendfile bytes jumped ahead of the tail. import { dlopen, ptr } from "bun:ffi"; const port = Number(process.env.PORT); @@ -1747,9 +2502,19 @@ test.skipIf(!isLinux)( // struct timeval { i64 sec; i64 usec } for SO_RCVTIMEO (SOL_SOCKET=1, 20) const timeo = new Uint8Array(16); new DataView(timeo.buffer).setBigInt64(0, 10n, true); + // SO_RCVBUF (SOL_SOCKET=1, 8). Setting it also turns off receive buffer + // autotuning, so the window doesn't depend on the host's tcp_rmem. + const rcvbuf = new Int32Array([16 * 1024]); const request = Buffer.from("GET /file HTTP/1.1\\r\\nHost: x\\r\\n\\r\\n"); - const recvBuf = new Uint8Array(256 * 1024); + const terminator = Buffer.from("\\r\\n\\r\\n"); + const expectedPad = Buffer.alloc(padSize, "p"); + const expectedBody = Buffer.alloc(fileSize, 0xee); + // No other header contains a run of pad bytes this long. + const padMarker = expectedPad.subarray(0, 64); + // Fits the pad, the file and the other headers. Receiving straight into + // it keeps the per-recv work small and the window-update ACKs frequent. + const wire = Buffer.alloc(padSize + fileSize + 64 * 1024); function iteration(): { kind: string; detail?: string } { const fd = libc.socket(2, 1, 0); // AF_INET, SOCK_STREAM @@ -1758,6 +2523,9 @@ test.skipIf(!isLinux)( if (libc.setsockopt(fd, 1, 20, ptr(timeo), 16) !== 0) { return { kind: "setsockopt-failed" }; } + if (libc.setsockopt(fd, 1, 8, ptr(rcvbuf), 4) !== 0) { + return { kind: "setsockopt-failed" }; + } if (libc.connect(fd, ptr(addr), 16) !== 0) { return { kind: "connect-failed" }; } @@ -1765,68 +2533,80 @@ test.skipIf(!isLinux)( return { kind: "send-failed" }; } - // Read until header terminator + fileSize body bytes. The terminator - // is found incrementally (in corrupt runs it shows up after file - // bytes, but the byte count still adds up). The keep-alive socket - // never closes on its own, so an exact byte target is required. - const terminator = Buffer.from("\\r\\n\\r\\n"); - const chunks: Buffer[] = []; + // Read until header terminator + fileSize body bytes. The keep-alive + // socket never closes on its own, so an exact byte target is + // required. Check header bytes as they arrive and stop at the first + // file byte, or the exact-length accounting would wait out the + // receive timeout on the scrambled stream. let total = 0; + let padStart = -1; + let verified = 0; // wire offset up to which the x-pad value has been compared let hdrEnd = -1; let expected = padSize + fileSize; // lower bound until hdrEnd is known - let earlyFileByte = -1; - while (total < expected) { - const n = Number(libc.recv(fd, ptr(recvBuf), recvBuf.length, 0)); + let corrupt = ""; + while (total < expected && total < wire.length) { + const n = Number(libc.recv(fd, ptr(wire, total), Math.min(256 * 1024, wire.length - total), 0)); if (n <= 0) break; // EOF, error, or SO_RCVTIMEO after a 10s stall - const chunk = Buffer.from(recvBuf.subarray(0, n)); - if (hdrEnd === -1) { - // A file byte before the header terminator already proves the - // corruption; stop here or the exact-length accounting below - // would wait out the receive timeout on the scrambled stream. - const fileByte = chunk.indexOf(0xee); - // Search for the terminator across the previous chunk boundary. - const prev = chunks.length > 0 ? chunks[chunks.length - 1] : Buffer.alloc(0); - const tail = prev.subarray(prev.length - Math.min(prev.length, 3)); - const window = Buffer.concat([tail, chunk]); - const found = window.indexOf(terminator); + total += n; + if (hdrEnd !== -1) continue; + + if (padStart === -1) { + padStart = wire.subarray(0, total).indexOf(padMarker); + if (padStart === -1) { + if (total < 32 * 1024) continue; + return { kind: "no-pad", detail: "no x-pad value in the first " + total + " bytes" }; + } + verified = padStart; + } + const padEnd = padStart + padSize; + if (verified < padEnd) { + const upto = Math.min(total, padEnd); + const want = expectedPad.subarray(verified - padStart, upto - padStart); + if (!wire.subarray(verified, upto).equals(want)) { + const at = wire.subarray(0, upto).indexOf(0xee, verified); + corrupt = + at === -1 + ? "x-pad bytes differ in [" + verified + ", " + upto + ")" + : "file byte at " + at + " inside the x-pad value"; + break; + } + verified = upto; + } + if (verified === padEnd) { + const seen = wire.subarray(0, total); + const fileByte = seen.indexOf(0xee, padEnd); + const found = seen.indexOf(terminator, padEnd); // Legit only when the body start (terminator end) is at or // before the first file byte. - if (found !== -1 && (fileByte === -1 || found + 4 - tail.length <= fileByte)) { - hdrEnd = total - tail.length + found; + if (found !== -1 && (fileByte === -1 || found + 4 <= fileByte)) { + hdrEnd = found; expected = hdrEnd + 4 + fileSize; } else if (fileByte !== -1) { - earlyFileByte = total + fileByte; - chunks.push(chunk); - total += n; + corrupt = "file byte at " + fileByte + " before the header terminator"; break; } } - chunks.push(chunk); - total += n; } - if (earlyFileByte !== -1) { - return { - kind: "corrupt", - detail: "file byte at " + earlyFileByte + " before the header terminator, read " + total, - }; - } - const buf = Buffer.concat(chunks); + if (corrupt) return { kind: "corrupt", detail: corrupt + ", read " + total }; if (hdrEnd === -1) return { kind: "no-header-end", detail: "got " + total }; - const firstFile = buf.indexOf(0xee); - if (firstFile !== -1 && firstFile < hdrEnd) { - return { - kind: "corrupt", - detail: "file byte at " + firstFile + " before header end at " + hdrEnd + ", total " + total, - }; + // Everything in the header block outside the pad. + const head = wire.toString("latin1", 0, padStart) + wire.toString("latin1", padStart + padSize, hdrEnd); + if (head.includes("\\xee")) { + return { kind: "corrupt", detail: "file byte before the x-pad value" }; + } + if (!head.startsWith("HTTP/1.1 200 OK\\r\\n")) { + return { kind: "bad-status", detail: head.slice(0, 64) }; + } + const contentLength = /^content-length: (\\d+)$/im.exec(head)?.[1]; + if (contentLength !== String(fileSize)) { + return { kind: "bad-content-length", detail: String(contentLength) }; } - const body = buf.subarray(hdrEnd + 4); + const body = wire.subarray(hdrEnd + 4, total); if (body.length !== fileSize) { return { kind: "bad-body-length", detail: body.length + " of " + fileSize + ", total " + total }; } - for (let o = 0; o < body.length; o += 65536) { - if (body[o] !== 0xee) return { kind: "bad-body-byte", detail: "offset " + o + " is " + body[o] }; - } + if (!body.equals(expectedBody)) return { kind: "bad-body-byte" }; return { kind: "ok" }; } finally { libc.close(fd); @@ -1839,39 +2619,40 @@ test.skipIf(!isLinux)( const r = iteration(); if (r.kind === "ok") ok++; else failures.push(r); - if (r.kind === "corrupt") break; // one corruption decides the verdict + if (r.kind !== "ok") break; // the first failure decides the verdict } console.log(JSON.stringify({ ok, failures })); `, }); await Bun.write(join(String(dir), "file.bin"), Buffer.alloc(FILE_SIZE, 0xee)); - const pad = Buffer.alloc(PAD_SIZE, "p").toString(); + // Built once. A debug build takes about 70ms to validate a 16MB header value. + const headers = new Headers({ "x-pad": Buffer.alloc(PAD_SIZE, "p").toString() }); await using server = Bun.serve({ port: 0, idleTimeout: 0, fetch() { - return new Response(Bun.file(join(String(dir), "file.bin")), { - headers: { "x-pad": pad }, - }); + return new Response(Bun.file(join(String(dir), "file.bin")), { headers }); }, }); - const problems: unknown[] = []; - let cleanClients = 0; - for (let wave = 0; wave < WAVES && problems.length === 0; wave++) { + const clean = { stdout: JSON.stringify({ ok: ITERATIONS_PER_CLIENT, failures: [] }), stderr: "", exitCode: 0 }; + for (let wave = 0; wave < WAVES; wave++) { + await using clients = new AsyncDisposableStack(); const procs = Array.from({ length: CLIENTS_PER_WAVE }, () => - Bun.spawn({ - cmd: [bunExe(), join(String(dir), "client-fixture.ts")], - env: { - ...bunEnv, - PORT: String(server.port), - PAD_SIZE: String(PAD_SIZE), - FILE_SIZE: String(FILE_SIZE), - ITERATIONS: String(ITERATIONS_PER_CLIENT), - }, - stderr: "pipe", - }), + clients.use( + Bun.spawn({ + cmd: [bunExe(), join(String(dir), "client-fixture.ts")], + env: { + ...bunEnv, + PORT: String(server.port), + PAD_SIZE: String(PAD_SIZE), + FILE_SIZE: String(FILE_SIZE), + ITERATIONS: String(ITERATIONS_PER_CLIENT), + }, + stderr: "pipe", + }), + ), ); const results = await Promise.all( procs.map(async proc => { @@ -1879,28 +2660,53 @@ test.skipIf(!isLinux)( return { stdout: stdout.trim(), stderr, exitCode }; }), ); - for (const { stdout, stderr, exitCode } of results) { - // Combined assert first, so a crashed client reports its exit code - // and raw output instead of a JSON parse error. stderr rides along - // for diagnostics but is not required to be empty. - expect({ exitCode, stdout, stderr }).toMatchObject({ - exitCode: 0, - stdout: expect.stringContaining("{"), - }); - const verdict = JSON.parse(stdout); - if (verdict.failures.length > 0 || verdict.ok !== ITERATIONS_PER_CLIENT) { - problems.push(verdict); - } else { - cleanClients++; - } - } + expect(results).toEqual(Array(CLIENTS_PER_WAVE).fill(clean)); } - expect(problems).toEqual([]); - expect(cleanClients).toBe(WAVES * CLIENTS_PER_WAVE); }, - 90_000, + 30_000, ); +test("sends a Bun.file() body in response to TRACE", async () => { + using dir = tempDir("serve-file-trace", { "payload.txt": "0123456789ABCDEF" }); + await using server = Bun.serve({ + port: 0, + fetch: () => new Response(Bun.file(join(String(dir), "payload.txt"))), + }); + + // A raw socket, so the result doesn't depend on how fetch() reads a TRACE response. + const { promise, resolve, reject } = Promise.withResolvers(); + let received = ""; + await Bun.connect({ + hostname: "127.0.0.1", + port: server.port!, + socket: { + open: socket => void socket.write("TRACE / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"), + data: (_socket, chunk) => void (received += chunk.toString("latin1")), + close: () => resolve(received), + error: (_socket, err) => reject(err), + }, + }); + const [head, body] = (await promise).split("\r\n\r\n"); + expect(head.toLowerCase()).toContain("content-length: 16"); + expect(body).toBe("0123456789ABCDEF"); +}); + +test.each([ + [205, "0", ""], + [307, "5", "hello"], + [308, "5", "hello"], +])("a %d Bun.file() route sends a Content-Length on a keep-alive connection", async (status, contentLength, body) => { + using dir = tempDir("serve-file-route-status", { "f.txt": "hello" }); + await using server = Bun.serve({ + port: 0, + routes: { "/r": new Response(Bun.file(join(String(dir), "f.txt")), { status, headers: { Location: "/" } }) }, + }); + + const res = await fetch(`${server.url}r`, { redirect: "manual" }); + expect({ status: res.status, contentLength: res.headers.get("content-length") }).toEqual({ status, contentLength }); + expect(await res.text()).toBe(body); +}); + // Unix-socket listeners reach the same Linux sendfile path as TCP (the // response handle is transport-agnostic), so pin body integrity for a >=1MB // file over AF_UNIX too. diff --git a/test/js/bun/http/bun-serve-headers.test.ts b/test/js/bun/http/bun-serve-headers.test.ts index a43c901701f0..1d0a137aba5b 100644 --- a/test/js/bun/http/bun-serve-headers.test.ts +++ b/test/js/bun/http/bun-serve-headers.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, describe, expect, test } from "bun:test"; +import { tempDir } from "harness"; import { once } from "node:events"; +import { request } from "node:http"; import * as net from "node:net"; +import { join } from "node:path"; +import { brotliCompressSync, deflateSync, gzipSync, zstdCompressSync } from "node:zlib"; // https://github.com/oven-sh/bun/issues/9180 test("weird headers", async () => { @@ -265,3 +269,303 @@ describe("response Connection: close closes the socket", () => { } }); }); + +// RFC 9112 9.6. A pooling client that doesn't see "close" reuses the socket and loses its next request. +describe.concurrent("sends Connection: close in the last response on a connection", () => { + const dir = tempDir("serve-connection-close", { + "small.txt": "bye", + // Above the sendfile() threshold on Linux. + "big.bin": Buffer.alloc(1536 * 1024, 0x61), + }); + afterAll(() => dir[Symbol.dispose]()); + const smallFile = join(String(dir), "small.txt"); + const bigFile = join(String(dir), "big.bin"); + + // Sends `payload`, reads until the server closes, and returns the status line with the Connection and Date lines. + async function exchange(port: number, payload: string, afterWrite?: () => Promise) { + const socket = net.connect(port, "127.0.0.1"); + try { + let raw = ""; + socket.on("data", chunk => (raw += chunk.toString("latin1"))); + const closed = once(socket, "close"); + await once(socket, "connect"); + socket.write(payload); + await afterWrite?.(); + await closed; + const [status, ...lines] = raw.split("\r\n\r\n")[0].split("\r\n"); + return { + status, + connection: lines.filter(line => /^connection:/i.test(line)).map(line => line.toLowerCase()), + dates: lines.filter(line => /^date:/i.test(line)).length, + }; + } finally { + socket.destroy(); + } + } + + const stream = () => + new ReadableStream({ + async pull(controller) { + await 1; + controller.enqueue(new TextEncoder().encode("bye")); + controller.close(); + }, + }); + const routes = { + "/static": new Response("bye"), + "/static-close": new Response("bye", { headers: { connection: "close" } }), + "/static-304": new Response(null, { status: 304 }), + "/file-route": new Response(Bun.file(smallFile)), + "/file-route-big": new Response(Bun.file(bigFile)), + }; + const handlers: Record Response> = { + "/string": () => new Response("bye"), + "/204": () => new Response(null, { status: 204 }), + "/304": () => new Response(null, { status: 304 }), + "/stream": () => new Response(stream()), + "/file": () => new Response(Bun.file(smallFile)), + "/file-big": () => new Response(Bun.file(bigFile)), + "/close": () => new Response("bye", { headers: { connection: "close" } }), + }; + const serve = () => + Bun.serve({ + port: 0, + hostname: "127.0.0.1", + development: false, + routes, + fetch: req => handlers[new URL(req.url).pathname]?.() ?? new Response("not found", { status: 404 }), + }); + + const paths = [...Object.keys(routes), ...Object.keys(handlers), "/404"]; + test.each([ + [ + "a Connection: close request", + (method: string, path: string) => `${method} ${path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n`, + ], + ["an HTTP/1.0 request", (method: string, path: string) => `${method} ${path} HTTP/1.0\r\nHost: x\r\n\r\n`], + ])("for %s", async (_name, payload) => { + using server = serve(); + const results: Record = {}; + for (const path of paths) { + for (const method of ["GET", "HEAD"]) { + const { connection, dates } = await exchange(server.port, payload(method, path)); + results[`${method} ${path}`] = { connection, dates }; + } + } + expect(results).toEqual( + Object.fromEntries( + paths.flatMap(path => + ["GET", "HEAD"].map(method => [`${method} ${path}`, { connection: ["connection: close"], dates: 1 }]), + ), + ), + ); + }); + + test("doesn't override the handler's Connection header", async () => { + using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: () => new Response("bye", { headers: { connection: "keep-alive" } }), + }); + expect(await exchange(server.port, "GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n")).toEqual({ + status: "HTTP/1.1 200 OK", + connection: ["connection: keep-alive"], + dates: 1, + }); + }); + + test("doesn't send it on a keep-alive connection", async () => { + using server = serve(); + const socket = net.connect(server.port, "127.0.0.1"); + try { + let raw = ""; + const { promise: done, resolve, reject } = Promise.withResolvers(); + socket.on("data", chunk => { + raw += chunk.toString("latin1"); + if (raw.endsWith("not found")) resolve(); + }); + socket.on("error", reject); + socket.on("close", () => reject(new Error("the server closed the connection"))); + socket.write( + ["/static", "/string", "/204", "/404"].map(path => `GET ${path} HTTP/1.1\r\nHost: x\r\n\r\n`).join(""), + ); + await done; + expect(raw.match(/HTTP\/1\.1 \d+|\nconnection:.*/gi)).toEqual([ + "HTTP/1.1 200", + "HTTP/1.1 200", + "HTTP/1.1 204", + "HTTP/1.1 404", + ]); + } finally { + socket.destroy(); + } + }); + + test("before a graceful stop() closes the connection", async () => { + const started = Promise.withResolvers(); + const release = Promise.withResolvers(); + using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch() { + started.resolve(); + await release.promise; + return new Response("bye"); + }, + }); + let stopped: Promise | undefined; + const result = await exchange(server.port, "GET / HTTP/1.1\r\nHost: x\r\n\r\n", async () => { + await started.promise; + stopped = server.stop(); + release.resolve(); + }); + await stopped; + expect(result).toEqual({ status: "HTTP/1.1 200 OK", connection: ["connection: close"], dates: 1 }); + }); + + test.each([ + ["the handler's Connection: close", "GET /close HTTP/1.1\r\nHost: x\r\n\r\n", "HTTP/1.1 200"], + ["a 413", "POST /string HTTP/1.1\r\nHost: x\r\nContent-Length: 11\r\n\r\nhello world", "HTTP/1.1 413"], + ["a 501", "FOO /string HTTP/1.1\r\nHost: x\r\n\r\n", "HTTP/1.1 501"], + ])("sends no 400 for a malformed request behind %s", async (_name, request, status) => { + using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + maxRequestBodySize: 10, + fetch: req => handlers[new URL(req.url).pathname](), + }); + const socket = net.connect(server.port, "127.0.0.1"); + try { + let raw = ""; + socket.on("data", chunk => (raw += chunk.toString("latin1"))); + const closed = once(socket, "close"); + socket.write(request + "not a request\r\n\r\n"); + await closed; + expect(raw.match(/HTTP\/1\.1 \d+/g)).toEqual([status]); + } finally { + socket.destroy(); + } + }); + + // https://github.com/oven-sh/bun/issues/43853 + test("node:http's default agent doesn't reuse the closed socket", async () => { + using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(req) { + await req.text(); + return new Response("ok"); + }, + }); + const results: (number | string)[] = []; + for (let i = 0; i < 4; i++) { + const { promise, resolve } = Promise.withResolvers(); + const req = request( + { host: "127.0.0.1", port: server.port, path: "/", method: "POST", headers: { connection: "close" } }, + res => { + res.resume(); + res.on("end", () => resolve(res.statusCode!)); + }, + ); + req.on("error", error => resolve((error as NodeJS.ErrnoException).code!)); + req.end("x"); + results.push(await promise); + } + expect(results).toEqual([200, 200, 200, 200]); + }); +}); + +describe("Content-Encoding of a fetch() Response returned from the handler", () => { + const payload = Buffer.alloc(2700, "The quick brown fox jumps over the lazy dog. ").toString(); + const gzipped = gzipSync(payload); + + function serveEncoded(encoding: string, body: Uint8Array, contentType = "text/plain") { + return Bun.serve({ + port: 0, + fetch: () => new Response(body, { headers: { "content-encoding": encoding, "content-type": contentType } }), + }); + } + + // `decompress: false` shows the header and the bytes as they are on the wire. + async function readWire(server: Bun.Server, method = "GET") { + const res = await fetch(`http://127.0.0.1:${server.port}/`, { method, decompress: false }); + return { + encoding: res.headers.get("content-encoding"), + length: res.headers.get("content-length"), + body: Buffer.from(await res.arrayBuffer()).toString("latin1"), + }; + } + + test.each([ + ["gzip", gzipped], + ["deflate", deflateSync(payload)], + ["br", brotliCompressSync(payload)], + ["zstd", zstdCompressSync(payload)], + ])("is dropped when fetch() decoded %s", async (encoding, body) => { + using upstream = serveEncoded(encoding, body); + let seenByHandler: string | null = null; + using proxy = Bun.serve({ + port: 0, + async fetch() { + const res = await fetch(`http://127.0.0.1:${upstream.port}/`); + seenByHandler = res.headers.get("content-encoding"); + return res; + }, + }); + expect(await readWire(proxy)).toEqual({ encoding: null, length: String(payload.length), body: payload }); + expect(seenByHandler).toBe(encoding); + }); + + test("is dropped from a clone", async () => { + using upstream = serveEncoded("gzip", gzipped); + using proxy = Bun.serve({ + port: 0, + fetch: async () => (await fetch(`http://127.0.0.1:${upstream.port}/`)).clone(), + }); + expect(await readWire(proxy)).toEqual({ encoding: null, length: String(payload.length), body: payload }); + }); + + test("is dropped from a static route", async () => { + using upstream = serveEncoded("gzip", gzipped); + const response = await fetch(`http://127.0.0.1:${upstream.port}/`); + // A static route only accepts a fully buffered body. + await response.clone().arrayBuffer(); + using proxy = Bun.serve({ port: 0, routes: { "/": response } }); + expect(await readWire(proxy)).toEqual({ encoding: null, length: String(payload.length), body: payload }); + }); + + test("is dropped after HTMLRewriter.transform()", async () => { + using upstream = serveEncoded("gzip", gzipSync("hi"), "text/html"); + using proxy = Bun.serve({ + port: 0, + fetch: async () => + new HTMLRewriter() + .on("a", { element: el => void el.setAttribute("href", "/y") }) + .transform(await fetch(`http://127.0.0.1:${upstream.port}/`)), + }); + const { encoding, body } = await readWire(proxy); + expect({ encoding, body }).toEqual({ encoding: null, body: 'hi' }); + }); + + test("is kept with `decompress: false`", async () => { + using upstream = serveEncoded("gzip", gzipped); + using proxy = Bun.serve({ + port: 0, + fetch: () => fetch(`http://127.0.0.1:${upstream.port}/`, { decompress: false }), + }); + expect(await readWire(proxy)).toEqual({ + encoding: "gzip", + length: String(gzipped.length), + body: gzipped.toString("latin1"), + }); + }); + + test("is kept for HEAD, which has no body to decode", async () => { + using upstream = serveEncoded("gzip", gzipped); + using proxy = Bun.serve({ + port: 0, + fetch: req => fetch(`http://127.0.0.1:${upstream.port}/`, { method: req.method }), + }); + expect(await readWire(proxy, "HEAD")).toEqual({ encoding: "gzip", length: String(gzipped.length), body: "" }); + }); +}); diff --git a/test/js/bun/http/bun-serve-html-405.test.ts b/test/js/bun/http/bun-serve-html-405.test.ts index 6b9faa95a92a..a2d952639226 100644 --- a/test/js/bun/http/bun-serve-html-405.test.ts +++ b/test/js/bun/http/bun-serve-html-405.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isASAN, isWindows, tempDir } from "harness"; import { join } from "path"; @@ -96,11 +96,8 @@ test.skipIf(!isASAN || isWindows)( 30_000, ); -// Graceful stop() leaves the keep-alive socket in the uws app's socket group. -// A NewApp::destroy at lastChanceToFinalize would unlink that group from the -// loop and orphan the socket, so the synchronous deinit path above is gated -// on TERMINATED (set only by an abrupt stop or an app.close() that already -// drained the group). +// A socket still linked into the uws app when NewApp::destroy runs at +// lastChanceToFinalize would be orphaned, which LSan reports as a direct leak. test.skipIf(!isASAN || isWindows)( "gracefully-stopped Bun.serve does not orphan a keep-alive socket at VM teardown", async () => { @@ -128,3 +125,56 @@ test.skipIf(!isASAN || isWindows)( }, 30_000, ); + +// `leak:uws_create_app` in leaksan.supp hides the server, so these children run without that entry. +// They start the server after a tick to keep JSModuleLoader::evaluateNonVirtual (also suppressed) +// off the allocation stacks, and pin it on globalThis so only the teardown can finalize it. +describe.skipIf(!isASAN || isWindows)("VM teardown frees", () => { + test.concurrent.each([ + [ + "Bun.serve after stop()", + ` + await Bun.sleep(0); + const server = Bun.serve({ port: 0, fetch: () => new Response("ok") }); + globalThis.keep = server; + await (await fetch("http://127.0.0.1:" + server.port + "/")).text(); + await server.stop(); + `, + ], + [ + "node:http after close()", + ` + import { createServer } from "node:http"; + await Bun.sleep(0); + const server = createServer((req, res) => res.end("ok")); + globalThis.keep = server; + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + await (await fetch("http://127.0.0.1:" + server.address().port + "/")).text(); + await new Promise(resolve => server.close(resolve)); + `, + ], + ])( + "%s", + async (_, script) => { + const suppressions = (await Bun.file(join(import.meta.dirname, "../../../leaksan.supp")).text()) + .split("\n") + .filter(line => line.trim() !== "leak:uws_create_app") + .join("\n"); + using dir = tempDir("serve-teardown-leak", { "leaksan.supp": suppressions }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + 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=${join(String(dir), "leaksan.supp")}`, + }, + 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: "", exitCode: 0 }); + }, + 30_000, + ); +}); diff --git a/test/js/bun/http/bun-serve-html.test.ts b/test/js/bun/http/bun-serve-html.test.ts index 928f19dc0891..0c9ab1cc0441 100644 --- a/test/js/bun/http/bun-serve-html.test.ts +++ b/test/js/bun/http/bun-serve-html.test.ts @@ -1,6 +1,7 @@ import type { Server, Subprocess } from "bun"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isDebug, tempDir, tempDirWithFiles } from "harness"; +import { writeFileSync } from "fs"; +import { bunEnv, bunExe, forEachLine, isDebug, isLinux, tempDir, tempDirWithFiles } from "harness"; import { join } from "path"; function replaceHash(html: string) { @@ -1014,7 +1015,7 @@ describe("html route whose file a plugin resolves to a different file", () => { stdout: JSON.stringify({ devServer: [failurePage, failurePage], ...builtWithout[target] }), exitCode: 0, }); - // Printed by the dev server (and by the development build when it fails too). Production says nothing. + // Printed by the dev server, and by the other two builds when they fail too. expect(stderr).toMatch( /error: Bundling "[^"]*index\.html" did not produce an html page for it\. A plugin may have resolved it to another file or loaded it as something other than html\./, ); @@ -1098,6 +1099,256 @@ test.concurrent("server.reload() while an html route's first bundle is still in }); }); +// "/index.html" is also the bundler's output path for the page. +test.concurrent.each([ + { development: false, route: "/" }, + { development: false, route: "/index.html" }, + { development: { hmr: false }, route: "/" }, + { development: { hmr: false }, route: "/index.html" }, +])("server.reload() keeps serving the chunks of a built html route (%j)", async ({ development, route }) => { + using dir = tempDir("bun-serve-html-reload-chunks", { + "index.html": ``, + "style.css": `body { color: red; }`, + "app.ts": `console.log("app v1");`, + "serve.ts": /*ts*/ ` + import html from "./index.html"; + import { writeFileSync } from "node:fs"; + + const route = ${JSON.stringify(route)}; + const options = { + port: 0, + hostname: "127.0.0.1", + development: ${JSON.stringify(development)}, + routes: { [route]: html }, + fetch: () => new Response("fallback", { status: 404 }), + }; + using server = Bun.serve(options); + const get = async path => { + const res = await fetch(new URL(path, server.url)); + return { status: res.status, body: await res.text() }; + }; + const chunks = async () => { + const page = (await get(route)).body; + return [/src="([^"]+\\.js)"/.exec(page)[1], /href="([^"]+\\.css)"/.exec(page)[1]]; + }; + + const [js, css] = await chunks(); + server.reload(options); + const afterReload = [(await get(js)).status, (await get(css)).status]; + + // The page still builds again after a reload, and the chunks of the previous build stay. + writeFileSync("app.ts", 'console.log("app v2");'); + server.reload(options); + const [rebuilt] = await chunks(); + console.log(JSON.stringify({ + afterReload, + rebuilt: (await get(rebuilt)).body.includes("app v2"), + afterRebuild: (await get(js)).status, + })); + `, + }); + const { stdout, stderr, exitCode } = await runServeFixture(dir); + expect({ stdout, exitCode }, stderr).toEqual({ + stdout: JSON.stringify({ afterReload: [200, 200], rebuilt: true, afterRebuild: 200 }), + exitCode: 0, + }); +}); + +describe("server.reload() that adds the first html route", () => { + const page = { + "index.html": `t`, + "app.ts": `console.log("app");`, + }; + + // `body` runs with `html`, the `api` routes and `probe(server)` in scope, and prints its result as JSON. + async function run(body: string) { + using dir = tempDir("bun-serve-html-reload-first-html-route", { + ...page, + "serve.ts": /*ts*/ ` + import html from "./index.html"; + + const api = { "/api": () => new Response("api") }; + + async function probe(server) { + const page = await (await fetch(server.url)).text(); + const script = page.match(/src="([^"]+\\.js)"/)?.[1] ?? ""; + const url = new URL("/_bun/hmr", server.url); + url.protocol = "ws:"; + const ws = new WebSocket(url); + const { promise: hmrSocket, resolve } = Promise.withResolvers(); + ws.onopen = () => resolve(true); + ws.onerror = ws.onclose = () => resolve(false); + const result = { + script: script.replace(/[0-9a-z]{8,}/, "HASH"), + hmrSocket: await hmrSocket, + api: await (await fetch(new URL("/api", server.url))).text(), + }; + ws.onclose = null; + ws.close(); + return result; + } + + ${body} + `, + }); + const { stdout, stderr, exitCode } = await runServeFixture(dir); + return { result: stdout === "" ? null : JSON.parse(stdout), exitCode, stderr }; + } + const devServer = { script: "/_bun/client/index-HASH.js", hmrSocket: true, api: "api" }; + const bundled = { script: "/chunk-HASH.js", hmrSocket: false, api: "api" }; + + test.concurrent("starts the dev server", async () => { + const { stderr, ...rest } = await run(` + using server = Bun.serve({ port: 0, development: true, routes: api }); + server.reload({ routes: { ...api, "/": html } }); + const reloaded = await probe(server); + server.reload({ routes: { ...api, "/": html } }); + console.log(JSON.stringify({ reloaded, reloadedAgain: await probe(server) })); + `); + expect(rest, stderr).toEqual({ result: { reloaded: devServer, reloadedAgain: devServer }, exitCode: 0 }); + }); + + // reload() can't change the mode the server was started in. + test.concurrent.each([ + ["true", "false", devServer], + ["{ hmr: false }", "true", bundled], + ])("follows Bun.serve's development: %s, not the reload's development: %s", async (started, reloaded, expected) => { + const { stderr, ...rest } = await run(` + using server = Bun.serve({ port: 0, development: ${started}, routes: api }); + server.reload({ development: ${reloaded}, routes: { ...api, "/": html } }); + console.log(JSON.stringify(await probe(server))); + `); + expect(rest, stderr).toEqual({ result: expected, exitCode: 0 }); + }); + + // The dev server only speaks HTTP/1.1, and by the time of the reload the HTTP/2 app already serves connections. + test.concurrent("doesn't start the dev server on a server with http2: true", async () => { + const { stderr, ...rest } = await run(` + using server = Bun.serve({ port: 0, development: true, http2: true, routes: api }); + server.reload({ routes: { ...api, "/": html } }); + console.log(JSON.stringify(await probe(server))); + `); + expect(rest, stderr).toEqual({ result: bundled, exitCode: 0 }); + }); + + test.concurrent.skipIf(!isLinux)("throws and keeps the old routes when the dev server can't start", async () => { + using dir = tempDir("bun-serve-html-reload-dev-server-fails", { + ...page, + "serve.ts": /*ts*/ ` + import { closeSync, openSync } from "node:fs"; + import html from "./index.html"; + + using server = Bun.serve({ + port: 0, + development: true, + routes: { "/api": () => new Response("api") }, + fetch: () => new Response("old fallback"), + }); + const text = async path => await (await fetch(new URL(path, server.url))).text(); + const options = { routes: { "/": html }, fetch: () => new Response("new fallback") }; + + // Without a free file descriptor the dev server can't create its file watcher. + const held = []; + for (;;) { + try { + held.push(openSync("/dev/null", "r")); + } catch { + break; + } + } + let threw = null; + try { + server.reload(options); + } catch (e) { + threw = e.message; + } finally { + held.forEach(closeSync); + } + const failed = { threw, api: await text("/api"), page: await text("/") }; + + server.reload(options); + const script = (await text("/")).match(/src="([^"]+\\.js)"/)?.[1] ?? ""; + const reloaded = { api: await text("/api"), script: script.replace(/[0-9a-z]{8,}/, "HASH") }; + console.log(JSON.stringify({ failed, reloaded })); + `, + }); + await using proc = Bun.spawn({ + cmd: ["/bin/sh", "-c", 'ulimit -n 256 && exec "$@"', "sh", bunExe(), "serve.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({ result: stdout === "" ? null : JSON.parse(stdout), exitCode }, stderr).toEqual({ + result: { + failed: { + threw: "EMFILE while initializing file watcher for development server", + api: "api", + page: "old fallback", + }, + reloaded: { api: "new fallback", script: "/_bun/client/index-HASH.js" }, + }, + exitCode: 0, + }); + }); + + test.concurrent("starts the dev server when bun --hot runs Bun.serve() again", async () => { + const serveSource = (withHtml: boolean) => /*ts*/ ` + import html from "./index.html"; + + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + development: true, + routes: ${withHtml} ? { "/": html } : {}, + fetch: () => new Response("fallback"), + }); + console.log(JSON.stringify({ withHtml: ${withHtml}, port: server.port })); + `; + using dir = tempDir("bun-serve-html-hot-first-html-route", { ...page, "serve.ts": serveSource(false) }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--hot", "--no-clear-screen", "serve.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", + }); + const lines = forEachLine(proc.stdout); + async function portOfEvaluation(withHtml: boolean): Promise { + for (;;) { + const { value, done } = await lines.next(); + if (done) throw new Error("serve.ts exited"); + const start = value.indexOf("{"); + if (start === -1) continue; + const evaluation = JSON.parse(value.slice(start)); + if (evaluation.withHtml === withHtml) return evaluation.port; + } + } + + const port = await portOfEvaluation(false); + const before = await (await fetch(`http://127.0.0.1:${port}/`)).text(); + + writeFileSync(join(String(dir), "serve.ts"), serveSource(true)); + const portAfter = await portOfEvaluation(true); + + const html = await (await fetch(`http://127.0.0.1:${port}/`)).text(); + const ws = new WebSocket(`ws://127.0.0.1:${port}/_bun/hmr`); + const { promise: hmrSocket, resolve } = Promise.withResolvers(); + ws.onopen = () => resolve(true); + ws.onerror = ws.onclose = () => resolve(false); + expect({ + before, + samePort: portAfter === port, + script: html.match(/src="([^"]+\.js)"/)?.[1].replace(/[0-9a-z]{8,}/, "HASH"), + hmrSocket: await hmrSocket, + }).toEqual({ before: "fallback", samePort: true, script: "/_bun/client/index-HASH.js", hmrSocket: true }); + ws.onclose = null; + ws.close(); + }); +}); + // The dev server's HMR socket and the user's `websocket` handlers are separate // uWS websocket behaviors on the same app, each with its own context. test.concurrent("HMR clients never reach the user's websocket handlers, before or after server.reload()", async () => { @@ -1390,6 +1641,82 @@ test.concurrent("a route keeps failing when its broken module was imported throu expect({ stdout, exitCode }, stderr).toEqual({ stdout: "500 200 500", exitCode: 0 }); }); +test.concurrent( + "a production route whose build failed answers every request with 500 and prints the error once", + async () => { + using dir = tempDir("bun-serve-html-build-failed", { + "index.html": ``, + "serve.ts": /*ts*/ ` + import net from "node:net"; + import html from "./index.html"; + + using server = Bun.serve({ port: 0, development: false, idleTimeout: 0, routes: { "/": html } }); + // This request waits for the build. The response has "Connection: close", so the server must close the socket. + const first = await new Promise((resolve, reject) => { + let response = ""; + const socket = net.connect(server.port, "127.0.0.1", () => socket.write("GET / HTTP/1.1\\r\\nHost: x\\r\\n\\r\\n")); + socket.on("data", chunk => (response += chunk)); + socket.on("error", reject); + socket.on("close", () => resolve(response.split("\\r\\n")[0])); + }); + const later = []; + for (const method of ["GET", "HEAD"]) { + const response = await fetch(server.url, { method }); + later.push([response.status, response.headers.get("content-length"), await response.text()]); + } + console.log(JSON.stringify({ first, later })); + `, + }); + const { stdout, stderr, exitCode } = await runServeFixture(dir); + expect(stderr.split(`Could not resolve: "./missing.js"`)).toHaveLength(2); + expect({ stdout, exitCode }, stderr).toEqual({ + stdout: JSON.stringify({ + first: "HTTP/1.1 500 Build Failed", + later: [ + [500, "0", ""], + [500, "0", ""], + ], + }), + exitCode: 0, + }); + }, +); + +test.concurrent("the dev server answers 500 after a plugin's setup() threw", async () => { + using dir = tempDir("bun-serve-html-plugin-setup-threw", { + "bunfig.toml": `[serve.static]\nplugins = ["./plugin.ts"]\n`, + "index.html": `t`, + "plugin.ts": /*ts*/ ` + export default { + name: "throws-in-setup", + setup() { + throw new Error("setup failed on purpose"); + }, + }; + `, + "serve.ts": /*ts*/ ` + import html from "./index.html"; + + using server = Bun.serve({ port: 0, development: true, routes: { "/": html } }); + const page = async () => { + const response = await fetch(server.url); + return [response.status, await response.text()]; + }; + // The first request waits for the plugin to load. The second one arrives after it failed. + console.log(JSON.stringify([await page(), await page()])); + `, + }); + const { stdout, stderr, exitCode } = await runServeFixture(dir); + expect(stderr).toContain("setup failed on purpose"); + expect({ stdout, exitCode }, stderr).toEqual({ + stdout: JSON.stringify([ + [500, ""], + [500, "Plugin Error"], + ]), + 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 @@ -2010,3 +2337,25 @@ describe("production headers and import.meta.env", () => { expect(results).toEqual(cases.map(([, , expected]) => expected)); }); }); + +// https://github.com/oven-sh/bun/issues/40479 +test.concurrent.each(["true", "false"])( + "bunfig [define] applies to an html route's scripts with development: %s", + async development => { + using dir = tempDir("bun-serve-html-define", { + "bunfig.toml": `[define]\nTOP_LEVEL = '"top"'\nBOTH = '"top"'\n\n[serve.static.define]\nBOTH = '"serve"'\n`, + "index.html": ``, + "app.ts": `console.log("values:", TOP_LEVEL, BOTH);`, + "serve.ts": /*ts*/ ` + import html from "./index.html"; + + using server = Bun.serve({ port: 0, development: ${development}, routes: { "/": html } }); + const page = await fetch(server.url).then(res => res.text()); + const script = await fetch(new URL(page.match(/]*src="([^"]+)"/)[1], server.url)).then(res => res.text()); + console.log(script.match(/"values:".*?\\)/)[0].replaceAll(" ", "")); + `, + }); + const { stdout, stderr, exitCode } = await runServeFixture(dir); + expect({ stdout, exitCode }, stderr).toEqual({ stdout: `"values:","top","serve")`, exitCode: 0 }); + }, +); diff --git a/test/js/bun/http/bun-serve-routes.test.ts b/test/js/bun/http/bun-serve-routes.test.ts index b704de9915a1..8ae480ae014b 100644 --- a/test/js/bun/http/bun-serve-routes.test.ts +++ b/test/js/bun/http/bun-serve-routes.test.ts @@ -1,7 +1,8 @@ import type { BunRequest, ServeOptions, Server } from "bun"; import { afterAll, beforeAll, describe, expect, it, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, tempDir, tls } from "harness"; import net from "node:net"; +import { join } from "node:path"; describe("path parameters", () => { let server: Server; @@ -92,6 +93,23 @@ describe("path parameters", () => { expect(response).toContain("HTTP/1.1 200"); expect(JSON.parse(response.slice(response.indexOf("\r\n\r\n") + 4))).toEqual({ id: expected, method: "GET" }); }); + + it.each([ + ["50%-off", "50%-off"], + ["a%zzb", "a%zzb"], + ["abc%", "abc%"], + ["x%2", "x%2"], + ["a%%b", "a%%b"], + ["100%25", "100%"], + ["caf%C3%A9", "café"], + ])("decodes the route parameter %s without dropping characters", async (raw, expected) => { + const res = await fetch(new URL(`/users/${raw}`, server.url).href); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + id: expected, + method: "GET", + }); + }); }); describe("HTTP methods", () => { @@ -327,6 +345,36 @@ describe("static responses", () => { }); }); +it("static and file routes send no Content-Type for a Blob with an empty type", async () => { + using dir = tempDir("serve-routes-empty-type", { "data.txt": "hello world" }); + const blob = () => new Blob(["hello world"], { type: "text/x" }); + const file = (type?: string) => Bun.file(`${dir}/data.txt`, type ? { type } : undefined); + const routes = { + "/static/slice": new Response(blob().slice(0, 5)), + "/static/typed": new Response(blob()), + "/file/slice": new Response(file("text/x").slice(0, 5)), + "/file/slice-without-response": file("text/x").slice(0, 5), + "/file/typed": new Response(file("text/x")), + "/file/slice-of-sniffed-type": new Response(file().slice(0, 5)), + }; + await using server = Bun.serve({ port: 0, routes }); + + const seen: Record = {}; + for (const pathname of Object.keys(routes)) { + const res = await fetch(new URL(pathname, server.url)); + await res.arrayBuffer(); + seen[pathname] = res.headers.get("content-type"); + } + expect(seen).toEqual({ + "/static/slice": null, + "/static/typed": "text/x", + "/file/slice": null, + "/file/slice-without-response": null, + "/file/typed": "text/x", + "/file/slice-of-sniffed-type": "text/plain;charset=utf-8", + }); +}); + describe("route precedence", () => { let server: Server; @@ -721,6 +769,46 @@ describe("many route params", () => { }); }); +describe("a ':' that doesn't start a path segment", () => { + it("is not a parameter in req.params", async () => { + await using server = Bun.serve({ + port: 0, + fetch: () => new Response("fallback"), + routes: { + "/files:batchGet/:id": req => Response.json(req.params), + "/:org/repos:list/:repo": { GET: req => Response.json(req.params) }, + "/users:search": req => Response.json(req.params), + }, + }); + const get = (path: string) => fetch(new URL(path, server.url)).then(res => res.text()); + + expect({ + "/files:batchGet/123": await get("/files:batchGet/123"), + "/oven-sh/repos:list/bun": await get("/oven-sh/repos:list/bun"), + "/users:search": await get("/users:search"), + "/files/123": await get("/files/123"), + }).toEqual({ + "/files:batchGet/123": JSON.stringify({ id: "123" }), + "/oven-sh/repos:list/bun": JSON.stringify({ org: "oven-sh", repo: "bun" }), + "/users:search": "{}", + "/files/123": "fallback", + }); + }); + + it("is not validated as a parameter name", async () => { + await using server = Bun.serve({ + port: 0, + routes: { + "/v1:2/:id": req => Response.json(req.params), + "/a:x/:x": req => Response.json(req.params), + }, + }); + + expect(await fetch(new URL("/v1:2/5", server.url)).then(res => res.json())).toEqual({ id: "5" }); + expect(await fetch(new URL("/a:x/1", server.url)).then(res => res.json())).toEqual({ x: "1" }); + }); +}); + it("throws a validation error when a route parameter name starts with a number", () => { expect(() => { Bun.serve({ @@ -1124,3 +1212,212 @@ it("request.clone() copies a param whose name is an array index", async () => { const res = await fetch(`${server.url}users/42`); expect(await res.json()).toEqual({ keys: ["0", "id"], byIndex: "zero" }); }); + +describe.concurrent('a "/*" route for some methods falls back to the any-method routes, then to fetch', () => { + const protocols = ["http1.1", "http2", "http3"] as const; + + // Responses have no body, so HEAD and TRACE answers compare like the others. + const answer = (name: string) => (req: Request) => + new Response(null, { headers: { "x-from": `${name} ${req.method}` } }); + const fixed = (name: string) => new Response(null, { headers: { "x-from": name } }); + + type Table = { routes: (dir: string) => any; fetch?: false; rows: Record }; + const tables: Record = { + "a GET function": { + routes: () => ({ "/*": { GET: answer("star") } }), + rows: { + "GET /x": "200 star GET", + "HEAD /x": "200 star HEAD", + "POST /x": "200 fetch POST", + "PROPFIND /x": "200 fetch PROPFIND", + "ACL /x": "200 fetch ACL", + }, + }, + "a static GET": { + routes: () => ({ "/*": { GET: fixed("star") } }), + rows: { + "GET /x": "200 star", + "HEAD /x": "200 star", + "POST /x": "200 fetch POST", + "PROPFIND /x": "200 fetch PROPFIND", + }, + }, + "a static GET and a TRACE function": { + routes: () => ({ "/*": { GET: fixed("star"), TRACE: answer("trace") } }), + rows: { + "TRACE /x": "200 trace TRACE", + "POST /x": "200 fetch POST", + "PROPFIND /x": "200 fetch PROPFIND", + "ACL /x": "200 fetch ACL", + }, + }, + "a GET function next to any-method routes": { + routes: dir => ({ + "/*": { GET: answer("star") }, + "/any": answer("any"), + "/static": fixed("static"), + "/file": new Response(Bun.file(join(dir, "file.txt"))), + "/u/:id": answer("param"), + }), + rows: { + "POST /any": "200 any POST", + "PROPFIND /any": "200 any PROPFIND", + "POST /static": "200 static", + "POST /file": "200 9 bytes", + "POST /u/1": "200 param POST", + "PROPFIND /u/1": "200 param PROPFIND", + "POST /nope": "200 fetch POST", + "PROPFIND /nope": "200 fetch PROPFIND", + }, + }, + "a GET file route whose file is missing": { + routes: dir => ({ "/*": { GET: new Response(Bun.file(join(dir, "missing.txt"))) }, "/any": answer("any") }), + rows: { + "GET /x": "200 fetch GET", + "POST /x": "200 fetch POST", + "GET /any": "200 any GET", + }, + }, + "a GET function and the server has no fetch": { + routes: () => ({ "/*": { GET: answer("star") }, "/any": answer("any") }), + fetch: false, + rows: { + "GET /x": "200 star GET", + "POST /x": "404 0 bytes", + "PROPFIND /x": "404 0 bytes", + "POST /any": "200 any POST", + "PROPFIND /any": "200 any PROPFIND", + }, + }, + }; + + test.each(Object.keys(tables))('when "/*" has %s', async name => { + using dir = tempDir("serve-routes-star-default", { "file.txt": "file body" }); + const table = tables[name]; + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + tls, + http2: true, + http3: true, + routes: table.routes(String(dir)), + ...(table.fetch === false ? {} : { fetch: answer("fetch") }), + }); + + const results: Record> = {}; + for (const row of Object.keys(table.rows)) { + const [method, path] = row.split(" "); + results[row] = {}; + for (const protocol of protocols) { + const res = await fetch(`https://127.0.0.1:${server.port}${path}`, { + method, + protocol, + // fetch() over HTTP/3 doesn't accept a custom CA. + tls: protocol === "http3" ? { rejectUnauthorized: false } : { ca: tls.cert }, + }); + // Only the file route and the 404 set no x-from header. + const from = res.headers.get("x-from") ?? `${(await res.bytes()).length} bytes`; + results[row][protocol] = `${res.status} ${from}`; + } + } + expect(results).toEqual( + Object.fromEntries( + Object.entries(table.rows).map(([row, expected]) => [ + row, + Object.fromEntries(protocols.map(protocol => [protocol, expected])), + ]), + ), + ); + }); +}); + +describe.concurrent("an HTTP/1.1 request that no route handles", () => { + // Resolves with everything the server sent before it closed the connection, without the Date header. + async function send(port: number, bytes: string) { + const { promise, resolve, reject } = Promise.withResolvers(); + let received = ""; + const socket = net.connect(port, "127.0.0.1", () => socket.write(bytes)); + socket.on("data", chunk => (received += chunk.toString("latin1"))); + socket.on("error", reject); + socket.on("close", () => resolve(received.replace(/^Date: .*\r\n/gm, ""))); + return promise; + } + const request = (method: string, path: string, fields = "") => + `${method} ${path} HTTP/1.1\r\nHost: localhost\r\n${fields}\r\n`; + const handler = (calls: string[], name: string) => (req: Request) => { + calls.push(`${name} ${req.method}`); + return new Response(`${name} ${req.method}`, { headers: { Connection: "close" } }); + }; + const notImplemented = "HTTP/1.1 501 Not Implemented\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"; + + // Registering the same method and pattern twice removes the first handler, which used to break the any-method fallback. + const tables: Record Record> = { + "no routes": () => ({}), + "a static route for HEAD": () => ({ "/x": { HEAD: new Response("h") } }), + "two parameter routes of one shape": calls => ({ + "/u/:id": handler(calls, "id"), + "/u/:name": handler(calls, "name"), + }), + "a function route and a static route for HEAD": calls => ({ + "/x": handler(calls, "x"), + "/y": { HEAD: new Response("h") }, + }), + 'a GET route on "/*"': calls => ({ "/*": { GET: handler(calls, "star") } }), + }; + + test.each(Object.keys(tables))("answers 501 to an unknown method and runs no handler with %s", async name => { + const calls: string[] = []; + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + routes: tables[name](calls), + fetch: handler(calls, "fetch"), + }); + const targets = ["BREW /nope", "get /x", "patch /x", "GETS /u/1"]; + const received = await Promise.all( + // The server must not answer the GET behind the 501. + targets.map(target => + send(server.port!, request(...(target.split(" ") as [string, string])) + request("GET", "/")), + ), + ); + expect({ received, calls }).toEqual({ received: targets.map(() => notImplemented), calls: [] }); + }); + + test("answers 501 without upgrading, sending 100 Continue or reading the body", async () => { + const calls: string[] = []; + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req, server) { + calls.push(`fetch ${req.method}`); + if (server.upgrade(req)) return; + return new Response("fetch"); + }, + websocket: { message() {} }, + }); + const requests = [ + request( + "BREW", + "/ws", + "Connection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n", + ), + request("BREW", "/x", "Expect: 100-continue\r\nContent-Length: 5\r\n"), + request("BREW", "/x", "Content-Length: 5\r\n") + "hello", + request("BREW", "/x", "Transfer-Encoding: chunked\r\n") + "5\r\nhello\r\n0\r\n\r\n", + ]; + const received = await Promise.all(requests.map(bytes => send(server.port!, bytes))); + expect({ received, calls }).toEqual({ received: requests.map(() => notImplemented), calls: [] }); + }); + + test("answers 404 when the only matching route yields", async () => { + using dir = tempDir("serve-routes-miss", {}); + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + routes: { "/*": new Response(Bun.file(join(String(dir), "missing.txt"))) }, + }); + expect(await send(server.port!, request("GET", "/x") + request("GET", "/y"))).toBe( + "HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", + ); + }); +}); diff --git a/test/js/bun/http/bun-serve-ssl.test.ts b/test/js/bun/http/bun-serve-ssl.test.ts index 9402ed2f0f97..670a35d00478 100644 --- a/test/js/bun/http/bun-serve-ssl.test.ts +++ b/test/js/bun/http/bun-serve-ssl.test.ts @@ -349,6 +349,39 @@ describe("Bun.serve per-serverName client certificate policy", () => { }, ); + test.each(["TLSv1.2", "TLSv1.3"] as const)( + "closes a refused client without waiting for its close_notify (%s)", + async version => { + using server = Bun.serve({ + port: 0, + idleTimeout: 0, + tls: { key: serverKey, cert: serverCert, ca: clientCa, requestCert: true, rejectUnauthorized: true }, + fetch: () => new Response("served"), + }); + // allowHalfOpen keeps this side open after the server's close_notify, so the client never answers it. + const refused = tls.connect({ + host: "127.0.0.1", + port: server.port, + rejectUnauthorized: false, + allowHalfOpen: true, + minVersion: version, + maxVersion: version, + ...untrustedClient, + } as tls.ConnectionOptions); + const closed = Promise.withResolvers(); + refused.on("error", () => {}); + refused.on("close", () => closed.resolve()); + try { + await once(refused, "end"); + // The kernel answers a write to a closed socket with a reset, which closes this side. + refused.write("x"); + await closed.promise; + } finally { + refused.destroy(); + } + }, + ); + test("a session established on the open default name cannot be resumed to bypass a gated name", async () => { using server = Bun.serve({ port: 0, diff --git a/test/js/bun/http/bun-serve-static-helpers.ts b/test/js/bun/http/bun-serve-static-helpers.ts index 354a93d663b2..4ccdde908a64 100644 --- a/test/js/bun/http/bun-serve-static-helpers.ts +++ b/test/js/bun/http/bun-serve-static-helpers.ts @@ -47,77 +47,91 @@ for (const [path, response] of Object.entries(routes)) { export const stressPaths = ["/foo", "/big", "/foo/bar"] as const; export const stressMethods = ["arrayBuffer", "blob", "bytes", "text"] as const; +const checkedHeaders = ["content-type", "content-length", "transfer-encoding", "x-foo", "etag"]; + +function pickHeaders(headers: Headers): Record { + return Object.fromEntries(checkedHeaders.map(name => [name, headers.get(name)] as const)); +} + +// toEqual doesn't read Blob contents, so expand a Blob into its size and bytes. Blob.type is left out +// because the buffered and the streamed body paths derive it differently. +async function comparable(body: ArrayBuffer | Blob | Uint8Array | string) { + if (body instanceof Blob) { + return { size: body.size, bytes: await body.bytes() }; + } + return body; +} + +// macOS limits the listen backlog to 128, so one batch is 64 concurrent requests. +const batchSize = isWindows ? 8 : 64; +// One leaked body per request on /big adds 64 * 4MB = 256MB of RSS per measured batch: 1GB on release, +// 512MB on ASAN/debug. The 8-wide Windows batches leak less than either bound, so the RSS check can't +// catch a leak there. +const measuredBatches = isASAN || isDebug ? 2 : 4; +const rssDeltaBoundMB = isASAN || isDebug ? 192 : 512; + export async function runStress( server: Server, path: (typeof stressPaths)[number], accessBody: boolean, method: (typeof stressMethods)[number], ) { - const bytes = method === "blob" ? static_responses[path] : await static_responses[path][method](); - const expectedLength = String(static_responses[path].size); + const route = routes[path]; + const blob = static_responses[path]; + const url = new URL(path, server.url).href; + const expectedResponse = { + status: 200, + url, + headers: { + "content-type": route.headers.get("Content-Type"), + "content-length": String(blob.size), + "transfer-encoding": null, + "x-foo": route.headers.get("X-Foo"), + "etag": expect.stringMatching(/^"[0-9a-f]+"$/), + }, + bodyUsed: true, + body: await comparable(method === "blob" ? blob : await blob[method]()), + }; - // macOS limits backlog to 128. - const batchSize = Math.ceil(64 / (isWindows ? 8 : 1)); - // Debug/ASAN builds run the fetch loop ~10x slower than release; one warmup + - // measurement round at 64-wide still exercises the backpressure path on every - // /big request, and the delta assertion below catches a per-request body leak - // in a single measurement pass (64 * 4MB = 256MB >> threshold). - const iterations = Math.ceil((isASAN || isDebug ? 4 : 12) / (isWindows ? 8 : 1)); + // A batch reports its first failure after all its requests finish. The other responses skip the + // comparison: a failing toEqual on a 4MB body takes 16s under ASAN, so 63 more would time out. + let failure: { error: unknown } | undefined; - async function iterate() { - let array = new Array(batchSize); - const route = `${server.url}${path.substring(1)}`; - for (let i = 0; i < batchSize; i++) { - array[i] = fetch(route) - .then(res => { - expect(res.status).toBe(200); - expect(res.url).toBe(route); - expect(res.headers.get("Content-Length")).toBe(expectedLength); - if (accessBody) { - res.body; - } - return res[method](); - }) - .then(output => { - expect(output).toStrictEqual(bytes); - }); + async function request() { + try { + const res = await fetch(url); + if (accessBody) { + // Creates the ReadableStream, so res[method]() drains it instead of taking the buffered fast path. + expect(res.body).toBeInstanceOf(ReadableStream); + } + const body = await comparable(await res[method]()); + if (failure) return; + expect({ + status: res.status, + url: res.url, + headers: pickHeaders(res.headers), + bodyUsed: res.bodyUsed, + body, + }).toEqual(expectedResponse); + } catch (error) { + failure ??= { error }; } - - await Promise.all(array); - - Bun.gc(); } - for (let i = 0; i < iterations; i++) { - await iterate(); - } - - Bun.gc(true); - const baseline = (rss() / 1024 / 1024) | 0; - let lastRSS = baseline; - console.log("Start RSS", baseline); - for (let i = 0; i < iterations; i++) { - await iterate(); - const rssMB = (rss() / 1024 / 1024) | 0; - if (lastRSS + 50 < rssMB) { - console.log("RSS Growth", rssMB - lastRSS); - } - lastRSS = rssMB; + async function batch() { + await Promise.all(Array.from({ length: batchSize }, request)); + if (failure) throw failure.error; + // A static route releases its request before the last bytes reach the client. + expect(server.pendingRequests).toBe(0); + Bun.gc(true); } - Bun.gc(true); - const finalRSS = (rss() / 1024 / 1024) | 0; - const delta = finalRSS - baseline; - console.log("Final RSS", finalRSS); - console.log("Delta RSS", delta); - // ASAN's shadow memory + quarantine raise the absolute RSS floor. - expect(finalRSS).toBeLessThan(isASAN ? 6144 : 4092); - if (isASAN || isDebug) { - // With the reduced iteration count the absolute ceiling alone would miss a - // per-request body leak on the first /big case (4 iter * 64 * 4MB = 1GB is - // well under 6GB). Under ASAN the post-gc delta is stable within tens of - // MB, so bound it directly; release keeps 12 iterations where the ceiling - // is sufficient and mimalloc jitter on /big makes a tight delta flaky. - expect(delta).toBeLessThan(192); + await batch(); + const baselineMB = rss() / 1024 / 1024; + for (let i = 0; i < measuredBatches; i++) { + await batch(); } + const deltaMB = Math.round(rss() / 1024 / 1024 - baselineMB); + console.log(`${path} ${method} RSS delta: ${deltaMB}MB`); + expect(deltaMB).toBeLessThan(rssDeltaBoundMB); } diff --git a/test/js/bun/http/bun-serve-static.test.ts b/test/js/bun/http/bun-serve-static.test.ts index a44680a4d969..06ccb234f1d0 100644 --- a/test/js/bun/http/bun-serve-static.test.ts +++ b/test/js/bun/http/bun-serve-static.test.ts @@ -1,5 +1,8 @@ import { afterAll, beforeAll, describe, expect, it, mock, test } from "bun:test"; -import { isBroken, isMacOS, tempDir } from "harness"; +import { isBroken, isMacOS, tempDir, tls as tlsCert } from "harness"; +import { once } from "node:events"; +import net from "node:net"; +import tls from "node:tls"; import { routes, static_responses } from "./bun-serve-static-helpers"; describe.todoIf(isBroken && isMacOS)("static", () => { @@ -377,3 +380,123 @@ test("HEAD for a static route with an S3 file body reports the same Content-Leng get: { status: 200, contentLength: "0", body: "" }, }); }); + +test("an in-memory Blob route is served like new Response(blob)", async () => { + const blobs = { + typed: new Blob(["

hi

"], { type: "text/html" }), + untyped: new Blob(["plain bytes"]), + empty: new Blob([]), + file: new File(["file bytes"], "a.txt", { type: "text/plain" }), + slice: new Blob(["0123456789"], { type: "text/plain" }).slice(2, 5, "text/plain"), + }; + const routes: Record = {}; + for (const [name, blob] of Object.entries(blobs)) { + routes[`/blob/${name}`] = blob; + routes[`/method/${name}`] = { GET: blob }; + routes[`/response/${name}`] = new Response(blob); + } + using server = Bun.serve({ port: 0, routes, fetch: () => new Response("fallback", { status: 404 }) }); + + const probe = async (path: string, method: string) => { + const res = await fetch(new URL(path, server.url), { method }); + const { date, ...headers } = res.headers.toJSON(); + return { status: res.status, headers, body: await res.text() }; + }; + for (const name of Object.keys(blobs)) { + for (const method of ["GET", "HEAD"]) { + expect({ name, method, ...(await probe(`/blob/${name}`, method)) }).toEqual({ + name, + method, + ...(await probe(`/response/${name}`, method)), + }); + } + expect({ name, ...(await probe(`/method/${name}`, "GET")) }).toEqual({ + name, + ...(await probe(`/response/${name}`, "GET")), + }); + } + expect(await probe("/blob/slice", "GET")).toEqual({ + status: 200, + headers: { "content-type": "text/plain;charset=utf-8", "content-length": "3", "etag": '"2e0b86a10afe759b"' }, + body: "234", + }); + // The route doesn't consume the Blob. + expect(await blobs.typed.text()).toBe("

hi

"); +}); + +describe.each(["http", "https"])("a static or file route with a Connection header, over %s", protocol => { + let dir: ReturnType; + beforeAll(() => { + // Linux sends a file of 1 MB or more with sendfile(). + dir = tempDir("static-connection-close", { "a.txt": "file-body", "big.txt": Buffer.alloc(4 << 20, "x") }); + }); + afterAll(() => dir[Symbol.dispose]()); + + // Sends `request` and a request for /second in one write, then reads until the server closes the socket. + async function pipeline(route: (init: ResponseInit) => Response, connection: string, request: string) { + const ran: string[] = []; + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + tls: protocol === "https" ? tlsCert : undefined, + idleTimeout: 0, + routes: { "/r": route({ headers: { Connection: connection } }) }, + fetch(req) { + ran.push(new URL(req.url).pathname); + return new Response("fallback", { headers: { Connection: "close" } }); + }, + }); + const socket = + protocol === "https" + ? tls.connect({ host: "127.0.0.1", port: server.port, ca: tlsCert.cert, servername: "localhost" }) + : net.connect(server.port, "127.0.0.1"); + let raw = ""; + socket.on("data", chunk => (raw += chunk.toString("latin1"))); + socket.write(`${request} /r HTTP/1.1\r\nHost: x\r\n\r\nGET /second HTTP/1.1\r\nHost: x\r\n\r\n`); + await once(socket, "close"); + return { + lines: raw.match(/HTTP\/1\.1 \d+|^Connection: .*$/gm), + bytes: raw.length - raw.indexOf("\r\n\r\n") - 4, + ran, + }; + } + + const text = (init: ResponseInit) => new Response("static", init); + const file = (init: ResponseInit) => new Response(Bun.file(`${dir}/a.txt`), init); + + test.each<[string, (init: ResponseInit) => Response, string, number, number]>([ + ["static GET", text, "GET", 200, 6], + ["static HEAD", text, "HEAD", 200, 0], + ["static GET with a 4 MB body", init => new Response(Buffer.alloc(4 << 20, "y"), init), "GET", 200, 4 << 20], + ["static 204", init => new Response(null, { ...init, status: 204 }), "GET", 204, 0], + ["Bun.file GET", file, "GET", 200, 9], + ["Bun.file HEAD", file, "HEAD", 200, 0], + ["Bun.file GET of a 4 MB file", init => new Response(Bun.file(`${dir}/big.txt`), init), "GET", 200, 4 << 20], + ])("%s closes the connection after Connection: close", async (_name, route, method, status, bytes) => { + expect(await pipeline(route, "close", method)).toEqual({ + lines: [`HTTP/1.1 ${status}`, "Connection: close"], + bytes, + ran: [], + }); + }); + + test("finds the close option in a list, in any case", async () => { + const lines = ["HTTP/1.1 200", "Connection: keep-alive, Close"]; + expect({ + static: await pipeline(text, "keep-alive, Close", "GET"), + file: await pipeline(file, "keep-alive, Close", "GET"), + }).toEqual({ static: { lines, bytes: 6, ran: [] }, file: { lines, bytes: 9, ran: [] } }); + }); + + test("keeps the connection open after Connection: keep-alive", async () => { + const expected = { + lines: ["HTTP/1.1 200", "Connection: keep-alive", "HTTP/1.1 200", "Connection: close"], + bytes: expect.any(Number), + ran: ["/second"], + }; + expect({ + static: await pipeline(text, "keep-alive", "GET"), + file: await pipeline(file, "keep-alive", "GET"), + }).toEqual({ static: expected, file: expected }); + }); +}); diff --git a/test/js/bun/http/bun-server.test.ts b/test/js/bun/http/bun-server.test.ts index 58f153dc2462..66a0329fccaa 100644 --- a/test/js/bun/http/bun-server.test.ts +++ b/test/js/bun/http/bun-server.test.ts @@ -408,6 +408,35 @@ describe.concurrent("Server", () => { } }); + test("server.fetch should work with a URL object", async () => { + using server = Bun.serve({ + port: 0, + fetch: req => new Response(`${req.method} ${req.url}`), + }); + const url = new URL("/from-url?x=1", server.url); + expect(await (await server.fetch(url)).text()).toBe(`GET ${url.href}`); + expect(await (await server.fetch(url, { method: "POST", body: "b" })).text()).toBe(`POST ${url.href}`); + expect(await (await server.fetch(url, { method: "LIST" })).text()).toBe(`LIST ${url.href}`); + expect(await (await server.fetch(new Request(url, { method: "LIST" }))).text()).toBe(`LIST ${url.href}`); + // @ts-expect-error + await expect(server.fetch({})).rejects.toThrow("fetch() expects a string, but received Object"); + }); + + test("server.fetch passes the server as the handler's second argument", async () => { + using server = Bun.serve({ + port: 0, + fetch(req, srv) { + return Response.json({ + isServer: srv === server, + upgrade: srv.upgrade(req), + requestIP: srv.requestIP(req), + }); + }, + websocket: { message() {} }, + }); + expect(await (await server.fetch("/")).json()).toEqual({ isServer: true, upgrade: false, requestIP: null }); + }); + test("server.fetch(url, { headers }) copies a Headers object", async () => { using server = Bun.serve({ port: 0, @@ -429,6 +458,23 @@ describe.concurrent("Server", () => { expect(kept.map(h => h.get("x-i"))).toEqual(kept.map((_, i) => String(i * 2))); }); + test.each([ + ["the default hostname", {}, "http://0.0.0.0"], + ["tls", { hostname: "127.0.0.1", tls }, "https://127.0.0.1"], + ["only an http3 listener", { hostname: "127.0.0.1", tls, http3: true, http1: false }, "https://127.0.0.1"], + ])("server.fetch() joins a relative URL onto the bound port with %s", async (_, options, origin) => { + using server = Bun.serve({ ...options, port: 0, fetch: req => new Response(req.url) }); + const response = await server.fetch("/relative"); + expect(await response.text()).toBe(`${origin}:${server.port}/relative`); + }); + + test("server.fetch() joins a relative URL onto a baseURI that has no port", async () => { + // @ts-expect-error baseURI is undocumented + using server = Bun.serve({ port: 0, baseURI: "https://example.com/api/", fetch: req => new Response(req.url) }); + const response = await server.fetch("/relative"); + expect(await response.text()).toBe("https://example.com/api/relative"); + }); + test("server should return a body for a OPTIONS Request", async () => { using server = Bun.serve({ port: 0, @@ -2857,6 +2903,46 @@ describe.concurrent("HEAD requests #15355", () => { }); }); +// Bun.serve can't tunnel, so the bytes after a CONNECT head are never answered. +describe.concurrent("closes the connection after the response to a CONNECT request", () => { + const head = "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n"; + const next = "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"; + const immediate = () => new Promise(resolve => setImmediate(resolve)); + const notAProxy = () => new Response("not a proxy", { status: 405 }); + + test.each([ + ["head in one read", [head + next], notAProxy], + ["head split across reads", [head.slice(0, 40), head.slice(40) + next], notAProxy], + ["handler responds later", [head + next], () => immediate().then(notAProxy)], + ] as const)("%s", async (_, writes, fetch) => { + // The idle timeout must not be what closes the connection. + await using server = Bun.serve({ port: 0, hostname: "127.0.0.1", idleTimeout: 255, fetch }); + let received = ""; + const closed = Promise.withResolvers(); + const socket = await Bun.connect({ + hostname: "127.0.0.1", + port: server.port!, + socket: { + data(_socket, data) { + received += data.toString("latin1"); + }, + close: () => closed.resolve(), + error: (_socket, error) => closed.reject(error), + }, + }); + for (const write of writes) { + socket.write(write); + socket.flush(); + // The server shares this event loop and reads the write before the second immediate runs. + await immediate(); + await immediate(); + } + await closed.promise; + expect(received.match(/^HTTP\/1\.1 .*$/gm)).toEqual(["HTTP/1.1 405 Method Not Allowed"]); + expect(received).toEndWith("\r\n\r\nnot a proxy"); + }); +}); + describe.concurrent("websocket and routes test", () => { const serverConfigurations = [ { diff --git a/test/js/bun/http/decodeURIComponentSIMD.test.ts b/test/js/bun/http/decodeURIComponentSIMD.test.ts index a90218a484ef..f6f2ff87ecb6 100644 --- a/test/js/bun/http/decodeURIComponentSIMD.test.ts +++ b/test/js/bun/http/decodeURIComponentSIMD.test.ts @@ -355,48 +355,63 @@ describe("decodeURIComponentSIMD with UTF-8 byte input", () => { "a" + String.fromCodePoint(0xfffd) + "bA", ); }); -}); - -describe("decodeURIComponentSIMD edge cases", () => { - it("should handle cursor advancement correctly with invalid hex", () => { - // This test would fail because of the cursor advancement bug - // When it sees %GG, it only advances by 1 instead of 3, causing - // the GG to be treated as literal characters - expect(decodeURIComponentSIMD("%GG%20test")).toBe(String.fromCodePoint(0xfffd) + " " + "test"); - }); - - it("should handle multiple invalid sequences consecutively", () => { - // Similar cursor advancement issue - expect(decodeURIComponentSIMD("%ZZ%XX%YY")).toBe(String.fromCodePoint(0xfffd).repeat(3)); - }); - it("should handle incomplete sequences at SIMD boundaries", () => { - // Create a string that puts a % character right at the SIMD boundary - // then follow it with invalid hex digits - const prefix = "a".repeat(15); // 15 bytes to align the % at boundary - expect(decodeURIComponentSIMD(prefix + "%GG")).toBe(prefix + String.fromCodePoint(0xfffd)); + it("keeps an invalid escape next to multi-byte characters", () => { + expect(decodeURIComponentSIMD(encoder.encode("é%zz"))).toBe("é%zz"); + expect(decodeURIComponentSIMD(encoder.encode("%zzé"))).toBe("%zzé"); + expect(decodeURIComponentSIMD(encoder.encode("%é"))).toBe("%é"); + expect(decodeURIComponentSIMD(encoder.encode("%€"))).toBe("%€"); + expect(decodeURIComponentSIMD(encoder.encode("%😀"))).toBe("%😀"); }); +}); - it("should handle mixed valid/invalid sequences at SIMD boundaries", () => { - // This combines SIMD boundary alignment with the cursor advancement bug - const prefix = "a".repeat(15); - expect(decodeURIComponentSIMD(prefix + "%GG%20%HH%20")).toBe( - prefix + String.fromCodePoint(0xfffd) + " " + String.fromCodePoint(0xfffd) + " ", - ); +// URLSearchParams also keeps a "%" that isn't followed by two hex digits, so it's the reference here. +describe("decodeURIComponentSIMD with an invalid escape", () => { + it.each([ + "%", + "%%", + "%%%", + "%2", + "%g", + "%2g", + "%g2", + "%GG%20test", + "%ZZ%XX%YY", + "%2%3", + "%00%0G", + "50%-off", + "a%zzb", + "abc%", + "x%2", + "a%%b", + "%2sf%2a", + "%%2a", + "a%25b%zz", + "%zz%41", + "%41%zz", + "%GG%F0%9F%98%80", + "%C3%A9%zz", + "%é", + "é%", + "%€", + ...[15, 16, 31].map(length => Buffer.alloc(length, "a").toString() + "%GG%20%HH%20"), + Buffer.alloc(3000, "%GG").toString(), + ])("decodes %s like URLSearchParams", input => { + expect(decodeURIComponentSIMD(input)).toBe(new URLSearchParams("v=" + input).get("v")!); }); - it("should handle large sequences of invalid encodings", () => { - // This would really expose the cursor advancement issue - const input = "%GG".repeat(1000); - // it should be full of unicode replacement characters - expect(decodeURIComponentSIMD(input).length).toBe(String.fromCodePoint(0xfffd).repeat(1000).length); + it.each([ + ["%C3%zz", "\uFFFD%zz"], + ["%C3%", "\uFFFD%"], + ["%E2%82%", "\uFFFD%"], + ["%F0%9F%98%zz", "\uFFFD%zz"], + ["a%C3%zzb", "a\uFFFD%zzb"], + ])("decodes the truncated UTF-8 sequence in %s to one U+FFFD and keeps the '%%'", (input, expected) => { + expect(decodeURIComponentSIMD(input)).toBe(expected); }); - it("should handle invalid sequences followed by valid UTF-8", () => { - // This combines the cursor advancement bug with UTF-8 decoding - expect(decodeURIComponentSIMD("%GG%F0%9F%98%80")).toBe( - // replacement + replacement + smiley - String.fromCodePoint(0xfffd) + "😀", - ); + it("still replaces valid escapes that aren't valid UTF-8 with U+FFFD", () => { + expect(decodeURIComponentSIMD("a%FFb")).toBe("a\uFFFDb"); + expect(decodeURIComponentSIMD("a%C3x")).toBe("a\uFFFDx"); }); }); diff --git a/test/js/bun/http/fetch-abort-ssl-context-eviction.test.ts b/test/js/bun/http/fetch-abort-ssl-context-eviction.test.ts index cf18b456eb20..c534f4b13125 100644 --- a/test/js/bun/http/fetch-abort-ssl-context-eviction.test.ts +++ b/test/js/bun/http/fetch-abort-ssl-context-eviction.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN } from "harness"; +import { blackholePortSource, bunEnv, bunExe, isASAN, isMusl, isWindows } from "harness"; // Evicting a custom SSL context while it still has in-flight sockets closes // those sockets via no-op callbacks (cleanCallbacks runs first in @@ -9,7 +9,7 @@ import { bunEnv, bunExe, isASAN } from "harness"; // active (non-pooled) sockets. // // This test fills the cache past ssl_context_cache_max_size (60) with distinct -// TLS configs whose connects never complete (TEST-NET-1), so every cache entry +// TLS configs whose connects never complete, so every cache entry // has an active connecting socket. The 61st distinct config triggers // evictOldestSslContext. Aborting all requests then drains the tracker. // @@ -19,7 +19,7 @@ import { bunEnv, bunExe, isASAN } from "harness"; // reused; the fixture spams same-size-class allocations to make that likely // (~30% per run before the fix), so loop a few times. test("aborting fetches whose custom SSL context was evicted does not crash", async () => { - // The fixture relies on connects to TEST-NET-1 staying in-flight; strip any + // The fixture relies on its connects staying in-flight; strip any // ambient HTTP(S) proxy so those connects aren't intercepted. Raise the // per-process request cap so all 65+200 requests plus both barriers start in // one FIFO drain pass (default cap is 256; 65+200+1 would defer the second @@ -51,7 +51,14 @@ test("aborting fetches whose custom SSL context was evicted does not crash", asy // fetch queued after the 65 SSL fetches therefore cannot start until every // SSL context exists and eviction has fired for indices 60..64, so awaiting // that barrier fetch replaces the 5s/0.5s sleeps the original fixture used. +// +// blackholePortSource doesn't support Windows or musl, so those still dial TEST-NET-1 (RFC 5737). const fixture = /* js */ ` +${ + isWindows || isMusl + ? `const hung = "https://192.0.2.1/", filler = { destroy() {} };` + : `${blackholePortSource}const hung = "https://127.0.0.1:" + port + "/";` +} const N = 65; // > ssl_context_cache_max_size (60) const controllers = []; const promises = []; @@ -66,7 +73,7 @@ for (let i = 0; i < N; i++) { const ac = new AbortController(); controllers.push(ac); promises.push( - fetch("https://192.0.2.1/", { + fetch(hung, { signal: ac.signal, tls: { serverName: "host" + i + ".test" }, }).catch(() => {}) @@ -90,6 +97,7 @@ await barrier(); // Abort everything — drainQueuedShutdowns walks the tracker. for (const ac of controllers) ac.abort(); await Promise.all(promises); +filler.destroy(); console.log("ok"); `; diff --git a/test/js/bun/http/fetch-file-upload.test.ts b/test/js/bun/http/fetch-file-upload.test.ts index f077df18d360..6355ccb62dc7 100644 --- a/test/js/bun/http/fetch-file-upload.test.ts +++ b/test/js/bun/http/fetch-file-upload.test.ts @@ -1,5 +1,16 @@ import { describe, expect, test } from "bun:test"; -import { isBroken, isWindows, tempDir, withoutAggressiveGC } from "harness"; +import { + bunEnv, + bunExe, + getFDCount, + isBroken, + isLinux, + isPosix, + isWindows, + tempDir, + withoutAggressiveGC, +} from "harness"; +import { closeSync, existsSync, openSync } from "node:fs"; import { tmpdir } from "os"; import { join } from "path"; @@ -222,6 +233,118 @@ describe("Bun.file().slice() upload sends the slice's Content-Length", () => { }); }); +describe.skipIf(isWindows)("Bun.stdin fed by a pipe uploads every byte", () => { + test.concurrent.each(["the body", "a FormData part"])("as %s", async kind => { + const payload = Buffer.alloc(1024 * 1024, "0123456789abcdef_"); + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + development: false, + maxRequestBodySize: payload.length * 2, + async fetch(req) { + const bytes = + kind === "the body" ? await req.bytes() : await ((await req.formData()).get("file") as Blob).bytes(); + return new Response(`${bytes.length} ${Bun.SHA256.hash(bytes, "hex")}`); + }, + }); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `let body = Bun.stdin; + if (process.argv[1] !== "the body") { + body = new FormData(); + body.append("file", Bun.stdin, "stdin.bin"); + } + console.log(await (await fetch(process.argv[2], { method: "POST", body })).text());`, + kind, + server.url.href, + ], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + proc.stdin.write(payload); + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + proc.stdin.end(), + ]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: `${payload.length} ${Bun.SHA256.hash(payload, "hex")}\n`, + stderr: "", + exitCode: 0, + }); + }); +}); + +// A file of 32 KiB or more over plain http takes the sendfile path, where fetch() owns the descriptor. +describe.skipIf(!isPosix)("fetch() closes the file behind a Bun.file() body", () => { + const bytes = Buffer.alloc(64 * 1024, 7); + + test.each([ + [0, 0, 0], + [100, 100, 0], + [0, 40000, 40000], + ])("slice(%d, %d)", async (start, end, size) => { + using dir = tempDir("fetch-file-slice-closed", { "f.bin": bytes }); + const path = join(String(dir), "f.bin"); + using server = Bun.serve({ + port: 0, + development: false, + async fetch(req) { + const received = (await req.arrayBuffer()).byteLength; + return Response.json({ contentLength: req.headers.get("content-length"), received }); + }, + }); + const upload = async () => { + const res = await fetch(server.url, { method: "POST", body: Bun.file(path).slice(start, end) }); + expect(await res.json()).toEqual({ contentLength: String(size), received: size }); + }; + + // The keep-alive socket belongs in the baseline. + await upload(); + const before = getFDCount(); + for (let i = 0; i < 16; i++) await upload(); + expect(getFDCount() - before).toBeLessThan(4); + }); +}); + +// On Windows the body is read through libuv, which needs a C runtime descriptor for the duplicated handle. +test("fetch() releases the descriptor it duplicates for a Bun.file(fd) body", async () => { + using dir = tempDir("fetch-file-fd-released", { "body.txt": "hello" }); + const path = join(String(dir), "body.txt"); + using server = Bun.serve({ + port: 0, + development: false, + fetch: async req => new Response(await req.text()), + }); + const fd = openSync(path, "r"); + try { + const upload = async () => { + const res = await fetch(server.url, { method: "POST", body: Bun.file(fd) }); + await res.text(); + expect(res.status).toBe(200); + }; + // openSync() returns the lowest free descriptor. + const lowestFreeFd = () => { + const probe = openSync(path, "r"); + closeSync(probe); + return probe; + }; + + // The keep-alive socket belongs in the baseline. + await upload(); + const before = lowestFreeFd(); + for (let i = 0; i < 32; i++) await upload(); + expect(lowestFreeFd() - before).toBeLessThan(16); + } finally { + closeSync(fd); + } +}); + test("missing file throws the expected error", async () => { Bun.gc(true); // Run this 1000 times to check for GC bugs @@ -242,3 +365,154 @@ test("missing file throws the expected error", async () => { await Bun.sleep(0); Bun.gc(true); }); + +describe("a body that can't be read rejects with a TypeError", () => { + // The body is read before connecting, so an already-rejected promise means the body failed, not the connection. + async function bodyError(promise: Promise) { + expect(Bun.peek.status(promise)).toBe("rejected"); + const error = await promise.catch(e => e); + expect(error).toBeInstanceOf(TypeError); + return error; + } + const url = "http://127.0.0.1:1/"; + + test("Bun.file() that doesn't exist", async () => { + using dir = tempDir("fetch-unreadable-body", {}); + const path = join(String(dir), "missing.txt"); + const expected = { code: "ENOENT", errno: expect.any(Number), syscall: "open", path }; + expect(await bodyError(fetch(url, { method: "POST", body: Bun.file(path) }))).toMatchObject(expected); + const request = new Request(url, { method: "POST", body: Bun.file(path) }); + expect(await bodyError(fetch(request))).toMatchObject(expected); + }); + + test.skipIf(isWindows)("Bun.file() of a directory", async () => { + using dir = tempDir("fetch-unreadable-body", {}); + expect(await bodyError(fetch(url, { method: "POST", body: Bun.file(String(dir)) }))).toMatchObject({ + code: "EISDIR", + syscall: "read", + }); + }); + + test("FormData with a Bun.file() that doesn't exist", async () => { + using dir = tempDir("fetch-unreadable-body", { "present.txt": "present" }); + const path = join(String(dir), "missing.txt"); + const body = new FormData(); + body.append("field", "value"); + body.append("present", Bun.file(join(String(dir), "present.txt"))); + body.append("missing", Bun.file(path)); + const expected = { code: "ENOENT", errno: expect.any(Number), syscall: "open", path }; + expect(await bodyError(fetch(url, { method: "POST", body }))).toMatchObject(expected); + expect(await bodyError(fetch({ url, method: "POST", body } as any))).toMatchObject(expected); + + for (const construct of [() => new Response(body), () => new Request(url, { method: "POST", body })]) { + let error: unknown; + try { + construct(); + } catch (e) { + error = e; + } + expect(error).toMatchObject({ name: "Error", ...expected }); + } + }); + + test.skipIf(isWindows)("Bun.file() of a closed file descriptor", async () => { + const fd = 1 << 30; + expect(await bodyError(fetch(url, { method: "POST", body: Bun.file(fd) }))).toMatchObject({ code: "EBADF", fd }); + }); +}); + +describe.skipIf(!isLinux)("a procfs file, which is a regular file with st_size 0", () => { + async function runChild(script: string, env: Record = bunEnv) { + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], env, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + // Three 100 KB variables make the child's /proc/self/environ larger than the 256 KiB the first read takes. + test.concurrent("uploads the whole file and whole slices", async () => { + const big = Buffer.alloc(100_000, "x").toString(); + const { stdout, stderr, exitCode } = await runChild( + `const path = "/proc/self/environ"; + const all = require("node:fs").readFileSync(path); + await using server = Bun.serve({ + port: 0, + fetch: async req => { + const bytes = await req.bytes(); + return Response.json({ received: bytes.length, hash: String(Bun.hash(bytes)) }); + }, + }); + const file = Bun.file(path); + const cases = { + whole: [file, all], + from1: [file.slice(1), all.subarray(1)], + first270000: [file.slice(0, 270_000), all.subarray(0, 270_000)], + window: [file.slice(5, 270_005), all.subarray(5, 270_005)], + }; + const out = { total: all.length }; + for (const [name, [body, expected]] of Object.entries(cases)) { + const { received, hash } = await (await fetch(server.url, { method: "POST", body })).json(); + out[name] = { received, same: hash === String(Bun.hash(expected)) }; + } + console.log(JSON.stringify(out));`, + { ...bunEnv, BIG_ENV_A: big, BIG_ENV_B: big, BIG_ENV_C: big }, + ); + expect(stderr).toBe(""); + const result = JSON.parse(stdout); + const total: number = result.total; + expect(total).toBeGreaterThan(270_005); + expect(result).toEqual({ + total, + whole: { received: total, same: true }, + from1: { received: total - 1, same: true }, + first270000: { received: 270_000, same: true }, + window: { received: 270_000, same: true }, + }); + expect(exitCode).toBe(0); + }); + + // /proc/self/pagemap yields hundreds of GB. The child lowers the size limit of a JS buffer to keep this fast. + test + .skipIf(!existsSync("/proc/self/pagemap")) + .concurrent("rejects with ENOMEM at the size limit when the file has no practical end", async () => { + const { stdout, stderr, exitCode } = await runChild( + `require("bun:internal-for-testing").setSyntheticAllocationLimitForTesting(1024 * 1024); + const error = await fetch("http://127.0.0.1:1/", { method: "POST", body: Bun.file("/proc/self/pagemap") }).catch(e => e); + console.log(JSON.stringify({ code: error.code, syscall: error.syscall }));`, + ); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ code: "ENOMEM", syscall: "read" }); + expect(exitCode).toBe(0); + }); +}); + +describe.skipIf(!isPosix)("a character device, which never reaches EOF", () => { + test("uploads a whole slice", async () => { + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: async req => new Response(`${req.headers.get("content-length")} ${(await req.bytes()).length}`), + }); + const body = Bun.file("/dev/zero").slice(0, 1024 * 1024); + expect(await (await fetch(server.url, { method: "POST", body })).text()).toBe("1048576 1048576"); + }); + + // The child lowers the size limit of a JS buffer to keep this fast. + test("rejects with ENOMEM at the size limit", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `require("bun:internal-for-testing").setSyntheticAllocationLimitForTesting(1024 * 1024); + const error = await fetch("http://127.0.0.1:1/", { method: "POST", body: Bun.file("/dev/zero") }).catch(e => e); + console.log(JSON.stringify({ code: error.code, syscall: error.syscall }));`, + ], + 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({ code: "ENOMEM", syscall: "read" }); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/bun/http/fetch-header-count-limit.test.ts b/test/js/bun/http/fetch-header-count-limit.test.ts index ab1cb97bc793..7bcc76db5cbb 100644 --- a/test/js/bun/http/fetch-header-count-limit.test.ts +++ b/test/js/bun/http/fetch-header-count-limit.test.ts @@ -1,6 +1,6 @@ -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { once } from "node:events"; -import { createServer } from "node:net"; +import { type AddressInfo, createServer } from "node:net"; // Use a raw TCP server to avoid header count limits in HTTP servers. // The server reads the raw request, extracts header info, and sends a JSON response. @@ -14,19 +14,9 @@ function makeRawHttpServer() { const headerSection = data.split("\r\n\r\n")[0]; const lines = headerSection.split("\r\n"); // First line is the request line, rest are headers. - let customCount = 0; - const headerNames: string[] = []; - for (let i = 1; i < lines.length; i++) { - const lower = lines[i].toLowerCase(); - const colonIdx = lines[i].indexOf(":"); - if (colonIdx > 0) { - headerNames.push(lines[i].substring(0, colonIdx).toLowerCase()); - } - if (lower.startsWith("x-h-")) { - customCount++; - } - } - const body = JSON.stringify({ customCount, headerNames }); + const headerLines = lines.slice(1); + const customCount = headerLines.filter(line => line.toLowerCase().startsWith("x-h-")).length; + const body = JSON.stringify({ customCount, headerLines }); socket.write( `HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n${body}`, ); @@ -42,7 +32,7 @@ test("fetch with many headers does not crash", async () => { await once(server, "listening"); const port = (server.address() as any).port; - // Build a request with more headers than the internal fixed-size buffer (256). + // More headers than the shared scratch buffer holds (256). const headers = new Headers(); for (let i = 0; i < 300; i++) { headers.set(`x-h-${i}`, `v${i}`); @@ -52,8 +42,7 @@ test("fetch with many headers does not crash", async () => { expect(res.status).toBe(200); const { customCount } = (await res.json()) as any; - // Excess headers beyond the internal cap (250 user headers) are silently dropped. - expect(customCount).toBe(250); + expect(customCount).toBe(300); }); test("fetch with exactly 250 custom headers sends all of them", async () => { @@ -73,22 +62,16 @@ test("fetch with exactly 250 custom headers sends all of them", async () => { expect(customCount).toBe(250); }); -test("default headers preserved when user headers overflow the buffer", async () => { +test("sends the user's Host, User-Agent and Accept after 251 other headers", async () => { await using server = makeRawHttpServer().listen(0); await once(server, "listening"); const port = (server.address() as any).port; - // Use "a-" prefixed headers which sort alphabetically before "accept", - // "host", "user-agent", etc. This ensures the filler headers consume all - // 250 user-header slots first, pushing the special headers into overflow. - // Without the fix, the override flags for Host/Accept/User-Agent would - // still be set (suppressing defaults), but the headers themselves would be - // dropped — resulting in missing mandatory headers like Host. + // fetch() writes headers in code-point order of their case-preserved names, so "A-" sorts before "Accept". const headers = new Headers(); - for (let i = 0; i < 250; i++) { - headers.set(`a-${String(i).padStart(4, "0")}`, `v${i}`); + for (let i = 0; i < 251; i++) { + headers.set(`A-${String(i).padStart(4, "0")}`, `v${i}`); } - // These special headers sort after "a-*" and will overflow. headers.set("Host", "custom-host.example.com"); headers.set("User-Agent", "custom-agent"); headers.set("Accept", "text/html"); @@ -96,12 +79,80 @@ test("default headers preserved when user headers overflow the buffer", async () const res = await fetch(`http://127.0.0.1:${port}/test`, { headers }); expect(res.status).toBe(200); - const { headerNames } = (await res.json()) as any; + const { headerLines } = (await res.json()) as any; + expect(headerLines.filter((line: string) => /^(host|user-agent|accept):/i.test(line))).toEqual([ + "Accept: text/html", + "Host: custom-host.example.com", + "User-Agent: custom-agent", + ]); +}); + +describe("response header field count", () => { + // MAX_RESPONSE_HEADERS in src/http/lib.rs + const maxResponseHeaders = 1000; + + // `total` header fields: Set-Cookie lines, then the two fields that frame the body. + const makeCookies = (total: number) => Array.from({ length: total - 2 }, (_, i) => `c${i}=v${i}`); + const toWire = (cookies: string[]) => + "HTTP/1.1 200 OK\r\n" + + cookies.map(cookie => `Set-Cookie: ${cookie}\r\n`).join("") + + "Connection: close\r\nContent-Length: 2\r\n\r\nok"; + + // Answers the first request with `parts`, one write per part. + async function serveRaw(...parts: string[]) { + const server = createServer(socket => { + socket.on("error", () => {}); + socket.once("data", async () => { + socket.setNoDelay(true); + for (const part of parts.slice(0, -1)) { + await new Promise(resolve => socket.write(part, resolve)); + // Gives the client a chance to read this part on its own. The result is the same if it doesn't. + await new Promise(resolve => setImmediate(resolve)); + } + socket.end(parts.at(-1)); + }); + }).listen(0, "127.0.0.1"); + await once(server, "listening"); + return server; + } - // Even though the user-supplied Host, User-Agent, and Accept were dropped - // due to overflow, the DEFAULT versions of these headers must still be - // present (the override flags should not have been set for dropped headers). - expect(headerNames).toContain("host"); - expect(headerNames).toContain("user-agent"); - expect(headerNames).toContain("accept"); + const outcomeOf = (server: Awaited>) => + fetch(`http://127.0.0.1:${(server.address() as AddressInfo).port}/`).then( + async res => ({ + status: res.status, + cookies: res.headers.getSetCookie(), + contentLength: res.headers.get("content-length"), + body: await res.text(), + }), + e => ({ rejected: e.code }), + ); + + test.concurrent.each([256, 257, maxResponseHeaders])("resolves a response with %i header fields", async total => { + const cookies = makeCookies(total); + await using server = await serveRaw(toWire(cookies)); + expect(await outcomeOf(server)).toEqual({ status: 200, cookies, contentLength: "2", body: "ok" }); + }); + + test.concurrent("rejects one field past the limit with ResponseHeadersTooLarge", async () => { + await using server = await serveRaw(toWire(makeCookies(maxResponseHeaders + 1))); + expect(await outcomeOf(server)).toEqual({ rejected: "ResponseHeadersTooLarge" }); + }); + + // At the limit every slot is in use when the first read ends, and the parser has to ask for more bytes. + test.concurrent.each([ + ["300 fields, split after 280 lines", 300, (wire: string) => wire.indexOf("Set-Cookie: c280=")], + ["the limit, split before the blank line", maxResponseHeaders, (wire: string) => wire.indexOf("\r\n\r\n") + 2], + ])("resolves a header block that arrives in two reads: %s", async (_, total, cutAt) => { + const cookies = makeCookies(total); + const wire = toWire(cookies); + const cut = cutAt(wire); + await using server = await serveRaw(wire.slice(0, cut), wire.slice(cut)); + expect(await outcomeOf(server)).toEqual({ status: 200, cookies, contentLength: "2", body: "ok" }); + }); + + test.concurrent("still rejects a malformed line after 290 fields with Malformed_HTTP_Response", async () => { + const wire = toWire(makeCookies(300)).replace("Set-Cookie: c290=v290\r\n", "not a header line\r\n"); + await using server = await serveRaw(wire); + expect(await outcomeOf(server)).toEqual({ rejected: "Malformed_HTTP_Response" }); + }); }); diff --git a/test/js/bun/http/http-server-chunking.test.ts b/test/js/bun/http/http-server-chunking.test.ts index 6f3dc38b1566..665c9df894a7 100644 --- a/test/js/bun/http/http-server-chunking.test.ts +++ b/test/js/bun/http/http-server-chunking.test.ts @@ -1,648 +1,404 @@ -import type { Socket } from "bun"; import { setSocketOptions } from "bun:internal-for-testing"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isPosix } from "harness"; - -describe.if(isPosix)("HTTP server handles chunked transfer encoding", () => { - test.concurrentIf(!isASAN)("handles fragmented chunk terminators", async () => { - const script = ` - const server = Bun.serve({ - port: 0, - async fetch(req) { - const body = await req.text(); - return new Response("Got: " + body); - }, - }); - const { promise, resolve } = Promise.withResolvers(); - const socket = await Bun.connect({ - hostname: "localhost", - port: server.port, - socket: { - data(socket, data) { - console.log(data.toString()); - socket.end(); - }, - open(socket) { - socket.write("POST / HTTP/1.1\\r\\nHost: localhost\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n4\\r\\nWiki\\r"); - socket.flush(); - setTimeout(() => { - socket.write("\\n0\\r\\n\\r\\n"); - socket.flush(); - }, 50); - }, - error() {}, - close() { resolve(); }, - }, - }); - await promise; - server.stop(); - `; - - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", script], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]); - - expect(stdout).toContain("200 OK"); - expect(stdout).toContain("Got: Wiki"); - expect(exitCode).toBe(0); +import { isPosix } from "harness"; + +/** + * `tick()` resolves once an in-process receiver has read everything written to loopback before the call. + * It sends one byte over a spare loopback connection. Loopback delivers in write order, so when that byte + * arrives, the earlier writes were readable in the same poll. The `setImmediate` then lets the rest of + * that poll's callbacks run. `setImmediate` alone runs before the next poll, so the writes would coalesce. + */ +async function loopbackClock() { + let arrived = () => {}; + const listener = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + data() { + arrived(); + }, + }, }); - - test.concurrentIf(!isASAN)("rejects invalid terminator in fragmented reads", async () => { - const script = ` - const server = Bun.serve({ - port: 0, - async fetch(req) { - const body = await req.text(); + const socket = await Bun.connect({ + hostname: listener.hostname, + port: listener.port, + socket: { data() {} }, + }); + return { + async tick() { + const { promise, resolve } = Promise.withResolvers(); + arrived = resolve; + socket.write("."); + socket.flush(); + await promise; + await new Promise(resolve => setImmediate(resolve)); + }, + [Symbol.dispose]() { + socket.end(); + listener.stop(); + }, + }; +} + +interface SeenRequest { + path: string; + /** Set when `req.text()` resolved. */ + body?: string; + /** Set when `req.text()` rejected because the parser rejected the body. */ + error?: string; +} + +/** A pending `req.text()` rejects with this when the parser closes the connection. */ +const BODY_REJECTED = "AbortError: The connection was closed."; + +/** + * Sends `segments` over one connection to a Bun.serve that echoes the request body. Resolves when the + * server closes the connection, which every request asks for with `Connection: close`. + * + * The server reads each segment separately. Once it has answered or hung up, the remaining segments + * aren't sent. A server that wrongly accepts a bad segment still gets the rest and answers 200. + */ +async function exchange(segments: readonly string[], options: { maxRequestBodySize?: number } = {}) { + const seen: SeenRequest[] = []; + const handled: Promise[] = []; + await using server = Bun.serve({ + ...options, + port: 0, + fetch(req) { + const request: SeenRequest = { path: new URL(req.url).pathname }; + seen.push(request); + const response = req.text().then( + body => { + request.body = body; return new Response("Got: " + body); }, - }); - const { promise, resolve } = Promise.withResolvers(); - const socket = await Bun.connect({ - hostname: "localhost", - port: server.port, - socket: { - data(socket, data) { - console.log(data.toString()); - socket.end(); - }, - open(socket) { - socket.write("POST / HTTP/1.1\\r\\nHost: localhost\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n4\\r\\nTestX"); - socket.flush(); - setTimeout(() => { - socket.write("\\n0\\r\\n\\r\\n"); - socket.flush(); - }, 50); - }, - error() {}, - close() { resolve(); }, + (error: Error) => { + request.error = `${error.name}: ${error.message}`; + // The parser already answered and closed the connection, so the client never sees this 500. + return new Response(null, { status: 500 }); }, - }); - await promise; - server.stop(); - `; - - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", script], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]); + ); + handled.push(response); + return response; + }, + }); - expect(stdout).toContain("400"); - expect(exitCode).toBe(0); + let received = ""; + let closed = false; + const { promise: closedPromise, resolve: onClose } = Promise.withResolvers(); + using clock = await loopbackClock(); + using socket = await Bun.connect({ + hostname: server.hostname!, + port: server.port!, + socket: { + data(_socket, chunk) { + received += chunk.toString(); + }, + close() { + closed = true; + onClose(); + }, + error() {}, + }, }); -}); -describe.if(isPosix)("HTTP server handles split chunk-size CRLF", () => { - test.concurrentIf(!isASAN)("handles lone CR at end of chunk-size line across TCP segments", async () => { - // Regression test: when a TCP segment boundary falls between the \r and \n - // of a chunk-size line (e.g. "5\r" in one segment, "\n..." in the next), - // the server must buffer and resume correctly instead of spinning. - const script = ` - const server = Bun.serve({ - port: 0, - async fetch(req) { - const body = await req.text(); - return new Response("Got: " + body); - }, - }); - const { promise, resolve } = Promise.withResolvers(); - const socket = await Bun.connect({ - hostname: "localhost", - port: server.port, - socket: { - data(socket, data) { - console.log(data.toString()); - socket.end(); - }, - open(socket) { - // Send headers - socket.write("PUT / HTTP/1.1\\r\\nHost: localhost\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n"); - socket.flush(); - // After a delay, send chunk size with lone \\r (no \\n yet) - setTimeout(() => { - socket.write("5\\r"); - socket.flush(); - // After another delay, send the rest: \\n, chunk data, and final chunk - setTimeout(() => { - socket.write("\\nHello\\r\\n0\\r\\n\\r\\n"); - socket.flush(); - }, 50); - }, 50); - }, - error() {}, - close() { resolve(); }, - }, - }); - await promise; - server.stop(); - `; + for (const segment of segments) { + if (received !== "" || closed) break; + socket.write(segment); + socket.flush(); + // After the first tick the server has read the segment and written any response. + // After the second, that response is in `received`. + await clock.tick(); + await clock.tick(); + } - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", script], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); + await closedPromise; + await Promise.all(handled); - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]); + const [status] = received.split("\r\n"); + const headersEnd = received.indexOf("\r\n\r\n"); + return { status, body: headersEnd === -1 ? "" : received.slice(headersEnd + 4), seen }; +} - expect(stdout).toContain("200 OK"); - expect(stdout).toContain("Got: Hello"); - expect(exitCode).toBe(0); - }); +function chunkedRequest(path = "/") { + return `POST ${path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n`; +} - test.concurrentIf(!isASAN)("handles lone CR in chunk-size with extensions", async () => { - // Same split but with a chunk extension before the CRLF - const script = ` - const server = Bun.serve({ - port: 0, - async fetch(req) { - const body = await req.text(); - return new Response("Got: " + body); - }, - }); - const { promise, resolve } = Promise.withResolvers(); - const socket = await Bun.connect({ - hostname: "localhost", - port: server.port, - socket: { - data(socket, data) { - console.log(data.toString()); - socket.end(); - }, - open(socket) { - socket.write("PUT / HTTP/1.1\\r\\nHost: localhost\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n"); - socket.flush(); - setTimeout(() => { - // chunk size with extension, CR split from LF - socket.write("5;ext=val\\r"); - socket.flush(); - setTimeout(() => { - socket.write("\\nHello\\r\\n0\\r\\n\\r\\n"); - socket.flush(); - }, 50); - }, 50); - }, - error() {}, - close() { resolve(); }, +describe.if(isPosix)("HTTP server handles chunked transfer encoding", () => { + test.concurrent("writes separated by a loopbackClock() tick arrive as separate reads", async () => { + // If the writes coalesced, the split-segment cases below would still pass, but on unsplit input. + const reads: string[] = []; + using listener = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + data(_socket, chunk) { + reads.push(chunk.toString()); }, - }); - await promise; - server.stop(); - `; - - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", script], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", + }, + }); + using clock = await loopbackClock(); + using socket = await Bun.connect({ + hostname: listener.hostname, + port: listener.port, + socket: { data() {} }, }); - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]); + for (const segment of ["first", "second", "third"]) { + socket.write(segment); + socket.flush(); + await clock.tick(); + } - expect(stdout).toContain("200 OK"); - expect(stdout).toContain("Got: Hello"); - expect(exitCode).toBe(0); + expect(reads).toEqual(["first", "second", "third"]); }); - test.concurrentIf(!isASAN)("rejects chunk extensions that exceed the 16 KiB per-chunk cap", async () => { - // A hostile client can hold a connection and unmetered inbound bandwidth by - // streaming arbitrarily large chunk-extension bytes, which maxRequestBodySize - // never sees. llhttp (Node) caps this at 16 KiB per chunk; Bun.serve must too. - const script = ` - const server = Bun.serve({ - port: 0, - maxRequestBodySize: 1024 * 1024, - async fetch(req) { - const n = (await req.arrayBuffer()).byteLength; - return new Response("n=" + n); - }, - }); - const { promise, resolve } = Promise.withResolvers(); - let received = ""; - const socket = await Bun.connect({ - hostname: "localhost", - port: server.port, - socket: { - data(s, d) { received += d.toString(); }, - open(s) { - // 20 KiB of extension bytes on one chunk-size line (cap is 16 KiB). - // Body is 2 bytes, well under maxRequestBodySize. - const ext = Buffer.alloc(20 * 1024, "e").toString(); - s.write("POST / HTTP/1.1\\r\\nHost: x\\r\\nConnection: close\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n2;" + ext + "\\r\\nhi\\r\\n0\\r\\n\\r\\n"); - s.flush(); - }, - error() {}, - close() { console.log(JSON.stringify({ received })); resolve(); }, - }, - }); - await promise; - server.stop(); - `; - - 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(""); - const { received } = JSON.parse(stdout); - // Server must reject (413) and close the connection; it must not hand the - // request to fetch() (which would answer 200 "n=2"). - expect(received).not.toContain("200"); - expect(received).not.toContain("n=2"); - expect(received).toContain("413"); - expect(exitCode).toBe(0); + test.concurrent.each([ + { + name: "a chunk-data CRLF split after the CR", + segments: [chunkedRequest() + "4\r\nWiki\r", "\n0\r\n\r\n"], + body: "Wiki", + }, + { + // The parser has to remember the \r and resume instead of spinning. + name: "a chunk-size CRLF split after the CR", + segments: [chunkedRequest(), "5\r", "\nHello\r\n0\r\n\r\n"], + body: "Hello", + }, + { + // Same split, but the parser resumes from the chunk-extension state. + name: "a chunk-size CRLF split after the CR of a line with an extension", + segments: [chunkedRequest(), "5;ext=val\r", "\nHello\r\n0\r\n\r\n"], + body: "Hello", + }, + { + // 80 KiB of extension bytes in total, but the 16 KiB cap is per chunk, as in llhttp. + name: "an 8 KiB chunk extension on each of 10 chunks", + segments: [ + chunkedRequest() + + Array(10) + .fill(`1;${Buffer.alloc(8 * 1024, "e").toString()}\r\nA\r\n`) + .join("") + + "0\r\n\r\n", + ], + body: "AAAAAAAAAA", + }, + ])("accepts $name", async ({ segments, body }) => { + expect(await exchange(segments)).toEqual({ + status: "HTTP/1.1 200 OK", + body: "Got: " + body, + seen: [{ path: "/", body }], + }); }); - test.concurrentIf(!isASAN)( - "accepts small chunk extensions on every chunk (cap is per chunk, not per message)", - async () => { - // 10 chunks x 8 KiB extension each = 80 KiB total extension bytes, but each - // chunk-size line is under the 16 KiB cap so the request must succeed. - const script = ` - const server = Bun.serve({ - port: 0, - async fetch(req) { return new Response("Got: " + (await req.text())); }, - }); - const { promise, resolve } = Promise.withResolvers(); - let received = ""; - const socket = await Bun.connect({ - hostname: "localhost", - port: server.port, - socket: { - data(s, d) { received += d.toString(); }, - open(s) { - const ext = Buffer.alloc(8 * 1024, "e").toString(); - let wire = "POST / HTTP/1.1\\r\\nHost: x\\r\\nConnection: close\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n"; - for (let i = 0; i < 10; i++) wire += "1;" + ext + "\\r\\nA\\r\\n"; - wire += "0\\r\\n\\r\\n"; - s.write(wire); - s.flush(); - }, - error() {}, - close() { console.log(received); resolve(); }, - }, - }); - await promise; - server.stop(); - `; - - 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(stdout).toContain("200 OK"); - expect(stdout).toContain("Got: AAAAAAAAAA"); - expect(exitCode).toBe(0); + // RFC 9112 7.1.2: header lines can follow the last chunk, up to an empty line. Bun.serve discards them. + // "/next" only runs if the parser found the end of the trailer section. + const keepAlive = "POST /first HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n"; + const next = "GET /next HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"; + test.concurrent.each([ + { name: "in one read", segments: [keepAlive + "0\r\nX-Trail: one\r\nX-More: two\r\n\r\n" + next] }, + { + name: "split inside a field name", + segments: [keepAlive + "0\r\nX", "-Trail: one\r\nX-More: two\r\n\r\n" + next], }, - ); - - test.concurrentIf(!isASAN)("rejects bare LF in chunk-size position (invalid byte not stranded)", async () => { - // A byte <=32 that isn't \r in chunk-size position must error immediately. - // Previously this could strand the byte in HttpParser's fallback buffer, - // corrupting header parsing on the next request. - const script = ` - const server = Bun.serve({ - port: 0, - async fetch(req) { - try { - await req.text(); - return new Response("OK"); - } catch { - return new Response("Body error", { status: 400 }); - } - }, - }); - const { promise, resolve } = Promise.withResolvers(); - let received = ""; - const socket = await Bun.connect({ - hostname: "localhost", - port: server.port, - socket: { - data(socket, data) { - received += data.toString(); - }, - open(socket) { - // Bare LF (0x0A) where chunk-size hex is expected - socket.write("PUT / HTTP/1.1\\r\\nHost: localhost\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n\\n"); - socket.flush(); - }, - error() {}, - close() { - console.log(received); - resolve(); - }, - }, - }); - await promise; - server.stop(); - `; - - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", script], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", + { + name: "split after each line", + segments: [keepAlive + "0\r\n", "X-Trail: one\r\n", "X-More: two\r\n", "\r\n" + next], + }, + { + name: "split after a field line's CR", + segments: [keepAlive + "0\r\nX-Trail: one\r", "\nX-More: two\r\n\r\n" + next], + }, + { name: "split after the empty line's CR", segments: [keepAlive + "0\r\nX-Trail: one\r\n\r", "\n" + next] }, + { name: "after a last chunk with an extension", segments: [keepAlive + "0;ext=v\r\nX-Trail: one\r\n\r\n" + next] }, + ])("accepts a trailer section $name", async ({ segments }) => { + const { status, seen } = await exchange(segments); + expect({ status, seen }).toEqual({ + status: "HTTP/1.1 200 OK", + seen: [ + { path: "/first", body: "hello" }, + { path: "/next", body: "" }, + ], }); - - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - - // Server should reject with 400, not hang or accept - expect(stderr).toBe(""); - expect(stdout).toContain("400"); - expect(exitCode).toBe(0); }); - test.concurrentIf(!isASAN)("rejects Content-Length values that would alias chunked-encoding state bits", async () => { - // remainingStreamingBytes is shared between CL and chunked state. CL >= 2^59 would - // set STATE_HAS_HEXDIG and route a fixed-length body into the chunked decoder. - const script = ` - let urls = []; - const server = Bun.serve({ - port: 0, - async fetch(req) { urls.push(new URL(req.url).pathname); return new Response("OK"); }, - }); - const { promise, resolve } = Promise.withResolvers(); - let received = ""; - const socket = await Bun.connect({ - hostname: "localhost", - port: server.port, - socket: { - data(s, d) { received += d.toString(); }, - async open(s) { - s.write("GET /first HTTP/1.1\\r\\nHost: x\\r\\nContent-Length: 576460752303423488\\r\\n\\r\\n"); - s.flush(); - await Bun.sleep(20); - s.write("\\r\\n\\r\\nGET /smuggled HTTP/1.1\\r\\nHost: x\\r\\n\\r\\n"); - s.flush(); - }, - error() {}, - close() { console.log(JSON.stringify({ received, urls })); resolve(); }, - }, - }); - await promise; - server.stop(); - `; - - 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(""); - const { received, urls } = JSON.parse(stdout); - expect(received).toContain("400"); - expect(urls).not.toContain("/smuggled"); - expect(exitCode).toBe(0); + test.concurrent.each([ + { + // The read ends one byte into the chunk terminator, so the partial-read path has to reject the X. + // Otherwise the second segment completes a 200 for "Test". + name: "a chunk-data terminator whose CR is some other byte", + segments: [chunkedRequest() + "4\r\nTestX", "\n0\r\n\r\n"], + seen: [{ path: "/", error: BODY_REJECTED }], + }, + { + // A byte <= 0x20 other than \r must fail right away. Left in the parser's fallback buffer, it + // would corrupt the next request on the connection. + name: "a bare LF in chunk-size position", + segments: [chunkedRequest() + "\n"], + seen: [{ path: "/", error: BODY_REJECTED }], + }, + { + // RFC 7230 4.1: chunk-size = 1*HEXDIG. Parsing an empty chunk-size line as the last chunk would + // turn the bytes after it into a second request. + name: "a chunk-size line with no hex digits, followed by a smuggled request", + segments: [chunkedRequest("/first") + "\r\n\r\nGET /smuggled HTTP/1.1\r\nHost: x\r\n\r\n"], + seen: [{ path: "/first", error: BODY_REJECTED }], + }, + { + name: "a chunk-size line with only an extension, followed by a smuggled request", + segments: [chunkedRequest("/first") + ";a=b\r\n\r\nGET /smuggled HTTP/1.1\r\nHost: x\r\n\r\n"], + seen: [{ path: "/first", error: BODY_REJECTED }], + }, + { + // A server that parsed the line as the last chunk would answer the final CRLF with a 200. + name: "a chunk-size line with no hex digits, arriving in its own segment", + segments: [chunkedRequest(), "\r\n", "\r\n"], + seen: [{ path: "/", error: BODY_REJECTED }], + }, + { + name: "a chunk-size line with only an extension, arriving in its own segment", + segments: [chunkedRequest(), ";a=b\r\n", "\r\n"], + seen: [{ path: "/", error: BODY_REJECTED }], + }, + { + name: "a bare LF in the trailer section", + segments: [chunkedRequest() + "5\r\nhello\r\n0\r\nX-T: 9\n\r\n"], + seen: [{ path: "/", error: BODY_REJECTED }], + }, + { + name: "a CR without LF in the trailer section", + segments: [chunkedRequest() + "5\r\nhello\r\n0\r\nX-T: 9\rZ\r\n\r\n"], + seen: [{ path: "/", error: BODY_REJECTED }], + }, + { + // remainingStreamingBytes is shared between Content-Length and chunked state. 2^59 would set + // STATE_HAS_HEXDIG and route a fixed-length body into the chunked decoder, so the parser has to + // reject it before fetch() runs. + name: "a Content-Length that aliases the chunked decoder's state bits", + segments: [ + "GET /first HTTP/1.1\r\nHost: x\r\nContent-Length: 576460752303423488\r\n\r\n", + "\r\n\r\nGET /smuggled HTTP/1.1\r\nHost: x\r\n\r\n", + ], + seen: [], + }, + ])("answers 400 to $name", async ({ segments, seen }) => { + expect(await exchange(segments)).toEqual({ + status: "HTTP/1.1 400 Bad Request", + body: "", + seen: [...seen], + }); }); - describe.each([ - ["bare CRLF", "\\r\\n"], - ["extension only", ";a=b\\r\\n"], - ])("rejects chunk-size with zero hex digits (%s)", (_, chunkSizeLine) => { - // RFC 7230 4.1: chunk-size = 1*HEXDIG. A chunk-size line with no hex digit - // must be rejected. Previously this parsed as size 0 (last-chunk), allowing a - // pipelined request after the bogus terminator to be smuggled. - test.concurrentIf(!isASAN)("rejects and does not process trailing pipelined request", async () => { - const script = ` - let requests = 0; - const server = Bun.serve({ - port: 0, - async fetch(req) { - requests++; - try { - await req.text(); - return new Response("OK " + req.url); - } catch { - return new Response("Body error", { status: 400 }); - } - }, - }); - const { promise, resolve } = Promise.withResolvers(); - let received = ""; - const socket = await Bun.connect({ - hostname: "localhost", - port: server.port, - socket: { - data(socket, data) { received += data.toString(); }, - open(socket) { - socket.write( - "PUT /first HTTP/1.1\\r\\nHost: x\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n" + - "${chunkSizeLine}\\r\\n" + - "GET /smuggled HTTP/1.1\\r\\nHost: x\\r\\n\\r\\n" - ); - socket.flush(); - }, - error() {}, - close() { console.log(JSON.stringify({ received, requests })); resolve(); }, - }, - }); - await promise; - server.stop(); - `; - - 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(""); - const { received, requests } = JSON.parse(stdout); - expect(received).toContain("400"); - expect(received).not.toContain("/smuggled"); - // The smuggled request must never reach the handler. - expect(requests).toBeLessThanOrEqual(1); - expect(exitCode).toBe(0); + test.concurrent("answers 413 to a chunk extension over the 16 KiB per-chunk cap", async () => { + // Extension bytes don't count toward maxRequestBodySize, so they need their own cap. The body is + // 2 bytes against a 1 MiB limit, so this 413 can only come from the extension cap. + const segments = [chunkedRequest() + `2;${Buffer.alloc(20 * 1024, "e").toString()}\r\nhi\r\n0\r\n\r\n`]; + expect(await exchange(segments, { maxRequestBodySize: 1024 * 1024 })).toEqual({ + status: "HTTP/1.1 413 Payload Too Large", + body: "", + seen: [{ path: "/", error: BODY_REJECTED }], }); + }); - test.concurrentIf(!isASAN)("rejects when chunk-size line is split across packets", async () => { - const script = ` - const server = Bun.serve({ - port: 0, - async fetch(req) { - try { await req.text(); return new Response("OK"); } - catch { return new Response("Body error", { status: 400 }); } - }, - }); - const { promise, resolve } = Promise.withResolvers(); - let received = ""; - const socket = await Bun.connect({ - hostname: "localhost", - port: server.port, - socket: { - data(socket, data) { received += data.toString(); }, - async open(socket) { - socket.write("PUT / HTTP/1.1\\r\\nHost: x\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n"); - socket.flush(); - await Bun.sleep(20); - socket.write("${chunkSizeLine}"); - socket.flush(); - await Bun.sleep(20); - socket.write("\\r\\n"); - socket.flush(); - }, - error() {}, - close() { console.log(received); resolve(); }, - }, - }); - await promise; - server.stop(); - `; - - 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(stdout).toContain("400"); - expect(exitCode).toBe(0); + // The header size limit (16 KiB by default) also bounds the trailer section. + const bigTrailer = Buffer.alloc(17 * 1024, "X-T: " + Buffer.alloc(200, "v").toString() + "\r\n").toString(); + test.concurrent.each([ + { name: "in one read", segments: [chunkedRequest() + "0\r\n" + bigTrailer + "\r\n"] }, + { + // Each half is under the limit, so the count has to carry over to the second read. + name: "across two reads", + segments: [chunkedRequest() + "0\r\n" + bigTrailer.slice(0, 8 * 1024), bigTrailer.slice(8 * 1024) + "\r\n"], + }, + ])("answers 431 to a trailer section over the header size limit, $name", async ({ segments }) => { + expect(await exchange(segments)).toEqual({ + status: "HTTP/1.1 431 Request Header Fields Too Large", + body: "", + seen: [{ path: "/", error: BODY_REJECTED }], }); }); }); describe.if(isPosix)("HTTP server handles fragmented requests", () => { - test.concurrentIf(!isASAN)("handles requests with tiny send buffer (regression test)", async () => { - using server = Bun.serve({ + test.concurrent("parses pipelined requests trickling in through a tiny send buffer", async () => { + // A 1-byte send buffer delivers the requests a few bytes at a time, so over 20 pipelined requests + // the reads end at offsets all over the request, including right after the request line. + const connections = 10; + const requestsPerConnection = 20; + // Keyed by request path. Each connection uses its own path. + const seen: Record }[]> = {}; + await using server = Bun.serve({ hostname: "127.0.0.1", - port: 0, - async fetch(req) { - const body = await req.text(); - const headers: Record = {}; - req.headers.forEach((value, key) => { - headers[key] = value; + fetch(req) { + (seen[new URL(req.url).pathname] ??= []).push({ + method: req.method, + headers: Object.fromEntries(req.headers), }); - return new Response( - JSON.stringify({ - method: req.method, - url: req.url, - headers, - body, - }), - { - headers: { "Content-Type": "application/json" }, - }, - ); + return new Response("ok"); }, }); - const { port } = server; - // 10 == batchSize is deliberate: one full-width batch preserves the concurrent-accept - // pressure. The old value of 100 repeated the same per-connection path 10x over. - let remaining = 10; - const batchSize = 10; - - for (let i = 0; i < remaining; i += batchSize) { - const promises: Promise[] = []; - for (let j = 0; j < batchSize; j++) { - promises.push( - (async i => { - const { resolve: resolveClose, reject: rejectClose, promise: closePromise } = Promise.withResolvers(); - - let buffer: Buffer; - let offset = 0; - - function actuallyWrite(socket) { - while (offset < buffer.length) { - const written = socket.write(buffer, offset, 1); - - if (written == 0) break; - - if (written > 1) { - throw new Error(`Written ${written} bytes, expected 1`); - } - socket.flush(); - offset += written; - } - } - - let remainingRequests = 20; - - const socket = await Bun.connect({ - hostname: server.hostname!, - port: server.port!, - socket: { - open(socket: Socket) { - // Set a very small send buffer to force fragmentation - // This simulates the condition that triggered the bug - setSocketOptions(socket, 1, 1); // 1 = send buffer, 1 = size - - const input = `GET /test-${i} HTTP/1.1\r\nHost: ${server.hostname}:${port}\r\nUser-Agent: Bun-Test\r\nAccept: */*\r\n\r\n`; - const repeated = Buffer.alloc(input.length * remainingRequests, input); - - buffer = repeated; - actuallyWrite(socket); - }, - data(socket: Socket, data: Buffer) { - // Mini HTTP parser to count complete responses - const dataStr = data.toString(); - const responses = dataStr.split("\r\n\r\n"); - - // Count complete responses (those that have both headers and body) - for (let k = 0; k < responses.length - 1; k++) { - if (responses[k].includes("HTTP/1.1 200 OK")) { - remainingRequests--; - } - } - if (remainingRequests == 0) { - socket.end(); - } - }, - close() { - if (remainingRequests > 0) { - throw new Error(`Expected 20 responses, got ${20 - remainingRequests}`); - } - - resolveClose(); - }, - drain(socket: Socket) { - actuallyWrite(socket); - }, - error(_socket: Socket, error: Error) { - rejectClose(error); - }, - }, - }); + const headers = { host: `127.0.0.1:${server.port}`, "user-agent": "Bun-Test", accept: "*/*" }; + function request(connection: number, index: number) { + return ( + `GET /connection-${connection} HTTP/1.1\r\n` + + `Host: ${headers.host}\r\nUser-Agent: ${headers["user-agent"]}\r\nAccept: ${headers.accept}\r\n` + + // The server closes the connection after the last response, so the client knows it has them all. + (index === requestsPerConnection - 1 ? "Connection: close\r\n" : "") + + "\r\n" + ); + } - // Wait for the socket to close - await closePromise; - })(i), - ); + async function drive(connection: number): Promise { + const wire = Buffer.from( + Array.from({ length: requestsPerConnection }, (_, index) => request(connection, index)).join(""), + ); + let offset = 0; + function writeUntilFull(socket: Bun.Socket) { + while (offset < wire.length) { + const written = socket.write(wire, offset, 1); + if (written === 0) break; // drain() picks up from here + offset += written; + socket.flush(); + } } - await Promise.all(promises); + let received = ""; + const { promise, resolve, reject } = Promise.withResolvers(); + await Bun.connect({ + hostname: server.hostname!, + port: server.port!, + socket: { + open(socket) { + setSocketOptions(socket, 1 /* SO_SNDBUF */, 1); + writeUntilFull(socket); + }, + drain: writeUntilFull, + data(_socket, chunk) { + received += chunk.toString(); + }, + close() { + resolve(received); + }, + error(_socket, error) { + reject(error); + }, + }, + }); + return (await promise).match(/HTTP\/1\.1 [^\r]*/g) ?? []; } - server.stop(); + const statuses = await Promise.all(Array.from({ length: connections }, (_, connection) => drive(connection))); + + const expectedRequests = Array.from({ length: requestsPerConnection }, (_, index) => ({ + method: "GET", + headers: index === requestsPerConnection - 1 ? { ...headers, connection: "close" } : headers, + })); + expect({ statuses, seen }).toEqual({ + statuses: Array.from({ length: connections }, () => Array(requestsPerConnection).fill("HTTP/1.1 200 OK")), + seen: Object.fromEntries( + Array.from({ length: connections }, (_, connection) => [`/connection-${connection}`, expectedRequests]), + ), + }); }); }); diff --git a/test/js/bun/http/leaks-test.test.ts b/test/js/bun/http/leaks-test.test.ts index 3a8bbb7a3f27..e8035944386e 100644 --- a/test/js/bun/http/leaks-test.test.ts +++ b/test/js/bun/http/leaks-test.test.ts @@ -6,7 +6,9 @@ import { join } from "path"; test.concurrent( "request error doesn't leak", async () => { - expect(await bunRun(join(import.meta.dir, "request-constructor-leak-fixture.js"))).toSpawn(); + const result = await bunRun(join(import.meta.dir, "request-constructor-leak-fixture.js")); + expect(result.stdout).toMatch(/^RSS: \d+ MB$/); + expect(result).toSpawn(); }, 60_000, ); @@ -14,7 +16,9 @@ test.concurrent( test.concurrent( "response error doesn't leak", async () => { - expect(await bunRun(join(import.meta.dir, "response-constructor-leak-fixture.js"))).toSpawn(); + const result = await bunRun(join(import.meta.dir, "response-constructor-leak-fixture.js")); + expect(result.stdout).toMatch(/^RSS: \d+ MB$/); + expect(result).toSpawn(); }, 60_000, ); @@ -22,7 +26,9 @@ test.concurrent( test.concurrent( "server.fetch(string) doesn't leak the URL buffer", async () => { - expect(await bunRun(join(import.meta.dir, "server-fetch-string-leak-fixture.js"))).toSpawn(); + const result = await bunRun(join(import.meta.dir, "server-fetch-string-leak-fixture.js")); + expect(result.stdout).toMatch(/^RSS delta: -?\d+(\.\d+)? MB$/); + expect(result).toSpawn(); }, 60_000, ); diff --git a/test/js/bun/http/proxy-stress-adversarial.test.ts b/test/js/bun/http/proxy-stress-adversarial.test.ts index f86803f5c2fa..45e41d0048ab 100644 --- a/test/js/bun/http/proxy-stress-adversarial.test.ts +++ b/test/js/bun/http/proxy-stress-adversarial.test.ts @@ -8,6 +8,9 @@ * Also covers the WebSocket proxy tunnel (`WebSocketProxyTunnel`), which is * a separate SSLWrapper consumer from the fetch `ProxyTunnel` and had zero * stress coverage for the wss-through-https-proxy (double-TLS) case. + * + * Most tests share one {http, https} proxy pair. Tests that pass non-default + * proxy options or inspect proxy.connections create their own proxy. */ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; @@ -16,6 +19,7 @@ import { once } from "node:events"; import net from "node:net"; import tls from "node:tls"; import { + AdversarialProxy, cartesian, clearProxyEnv, createAdversarialOrigin, @@ -28,10 +32,48 @@ import { } from "./proxy-stress-helpers"; let savedEnv: Record; -beforeAll(() => { +let sharedHttpProxy: AdversarialProxy; +let sharedHttpsProxy: AdversarialProxy; +const sharedProxy = (tls: boolean) => (tls ? sharedHttpsProxy : sharedHttpProxy); + +// Shared echo origins, one per scheme. They echo the request path+search as the +// body and take the status from an X-Status request header. Tests that inspect +// origin.requests or shape the response framing create their own origin. +interface SharedOrigin { + server: ReturnType; + /** `server.url.origin` (no trailing slash, so `url + "/x"` is well-formed). */ + url: string; + port: number; +} +let sharedHttpOrigin: SharedOrigin; +let sharedHttpsOrigin: SharedOrigin; +const sharedOrigin = (tls: boolean) => (tls ? sharedHttpsOrigin : sharedHttpOrigin); +function echoHandler(req: Request) { + const url = new URL(req.url); + const status = Number(req.headers.get("x-status") ?? "200"); + // 204/304 carry no body per RFC; everything else echoes path+search. + const hasBody = status !== 204 && status !== 304; + return new Response(hasBody ? url.pathname + url.search : null, { status }); +} +function makeSharedOrigin(tls: boolean): SharedOrigin { + const server = Bun.serve({ port: 0, tls: tls ? tlsCert : undefined, fetch: echoHandler }); + return { server, url: server.url.origin, port: server.port! }; +} + +beforeAll(async () => { savedEnv = clearProxyEnv(); + [sharedHttpProxy, sharedHttpsProxy] = await Promise.all([ + createAdversarialProxy({ tls: false }), + createAdversarialProxy({ tls: true }), + ]); + sharedHttpOrigin = makeSharedOrigin(false); + sharedHttpsOrigin = makeSharedOrigin(true); }); -afterAll(() => { +afterAll(async () => { + await sharedHttpProxy?.close(); + await sharedHttpsProxy?.close(); + sharedHttpOrigin?.server.stop(true); + sharedHttpsOrigin?.server.stop(true); restoreProxyEnv(savedEnv); }); @@ -42,27 +84,45 @@ afterAll(() => { // ───────────────────────────────────────────────────────────────────────────── describe("stacked adversarial", () => { + // All 36 cases share 6 split-reply proxies (2 tls × 3 split values) and 3 + // chunked origins (one per encoding). Each test uses a unique request path to + // find its own entry in the shared origin's request log. + const splitProxies = new Map(); + const splitProxy = (tls: boolean, split: number) => splitProxies.get(`${tls}-${split}`)!; + const encOrigins = new Map>>(); + const payload = makeBody(8192, "A"); + beforeAll(async () => { + await Promise.all([ + ...cartesian({ tls: [false, true], split: [1, 3, 10] }).map(async ({ tls, split }) => { + splitProxies.set(`${tls}-${split}`, await createAdversarialProxy({ tls, splitConnectReply: split })); + }), + ...(["identity", "gzip", "br"] as const).map(async encoding => { + encOrigins.set( + encoding, + await createAdversarialOrigin({ tls: true, body: payload, framing: "chunked", encoding }), + ); + }), + ]); + }); + afterAll(async () => { + for (const p of splitProxies.values()) await p.close(); + for (const o of encOrigins.values()) await o.close(); + }); + + let uid = 0; for (const { proxyTls, splitConnect, encoding, keepalive } of cartesian({ proxyTls: [false, true] as const, splitConnect: [1, 3, 10] as const, encoding: ["identity", "gzip", "br"] as const, keepalive: [false, true] as const, })) { + const path = `/t-${uid++}`; test.concurrent( `${proxyTls ? "https" : "http"}-proxy split=${splitConnect} chunked/${encoding} keepalive=${keepalive}`, async () => { - const payload = makeBody(8192, "A"); - await using origin = await createAdversarialOrigin({ - tls: true, - body: payload, - framing: "chunked", - encoding, - }); - await using proxy = await createAdversarialProxy({ - tls: proxyTls, - splitConnectReply: splitConnect, - }); - const res = await fetch(origin.url, { + const origin = encOrigins.get(encoding)!; + const proxy = splitProxy(proxyTls, splitConnect); + const res = await fetch(origin.url + path, { method: "POST", body: Buffer.alloc(1024, "q"), proxy: proxy.url, @@ -71,7 +131,8 @@ describe("stacked adversarial", () => { }); expect(await res.text()).toBe(payload); expect(res.status).toBe(200); - expect(origin.requests[0].body.length).toBe(1024); + const mine = origin.requests.find(r => r.path === path); + expect(mine?.body.length).toBe(1024); }, ); } @@ -95,21 +156,17 @@ describe("origin status through tunnel", () => { async () => { // 204/304 carry no body per RFC; everything else does. const hasBody = status !== 204 && status !== 304; - await using origin = await createAdversarialOrigin({ - tls: originTls, - status, - body: hasBody ? `status-${status}` : "", - framing: "content-length", - }); - await using proxy = await createAdversarialProxy({ tls: proxyTls }); - const res = await fetch(origin.url, { + const origin = sharedOrigin(originTls); + const proxy = sharedProxy(proxyTls); + const res = await fetch(`${origin.url}/status-${status}`, { proxy: proxy.url, keepalive: false, tls: laxTls, redirect: "manual", // surface 3xx as-is + headers: { "X-Status": String(status) }, }); expect(res.status).toBe(status); - expect(await res.text()).toBe(hasBody ? `status-${status}` : ""); + expect(await res.text()).toBe(hasBody ? `/status-${status}` : ""); }, ); } @@ -130,7 +187,7 @@ describe("origin-facing headers", () => { `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin Host + user headers`, async () => { await using origin = await createAdversarialOrigin({ tls: originTls, body: "ok" }); - await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const proxy = sharedProxy(proxyTls); const res = await fetch(`${origin.url}/path?q=1`, { proxy: proxy.url, keepalive: false, @@ -163,7 +220,7 @@ describe("checkServerIdentity through tunnel", () => { for (const proxyTls of [false, true] as const) { test.concurrent(`${proxyTls ? "https" : "http"}-proxy: callback approves → request completes`, async () => { await using origin = await createAdversarialOrigin({ tls: true, body: "approved" }); - await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const proxy = sharedProxy(proxyTls); let called = 0; const res = await fetch(origin.url, { proxy: proxy.url, @@ -189,7 +246,7 @@ describe("checkServerIdentity through tunnel", () => { `${proxyTls ? "https" : "http"}-proxy: callback rejects → fetch rejects, origin untouched`, async () => { await using origin = await createAdversarialOrigin({ tls: true, body: "never" }); - await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const proxy = sharedProxy(proxyTls); let caught: any; try { await fetch(origin.url, { @@ -214,23 +271,28 @@ describe("checkServerIdentity through tunnel", () => { `${proxyTls ? "https" : "http"}-proxy: callback approves ${isASAN ? 50 : 20}× under GC pressure`, async () => { await using origin = Bun.serve({ port: 0, tls: tlsCert, fetch: () => new Response("gc") }); - await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const proxy = sharedProxy(proxyTls); const N = isASAN ? 50 : 20; - for (let i = 0; i < N; i++) { - const res = await fetch(origin.url, { - proxy: proxy.url, - keepalive: true, - tls: { - ca: tlsCert.cert, - rejectUnauthorized: true, - checkServerIdentity: () => { - Bun.gc(false); - return undefined; + let called = 0; + const results = await Promise.all( + Array.from({ length: N }, () => + fetch(origin.url, { + proxy: proxy.url, + keepalive: false, + tls: { + ca: tlsCert.cert, + rejectUnauthorized: true, + checkServerIdentity: () => { + called++; + Bun.gc(false); + return undefined; + }, }, - }, - }); - expect(await res.text()).toBe("gc"); - } + }).then(res => res.text()), + ), + ); + expect(results).toEqual(Array.from({ length: N }, () => "gc")); + expect(called).toBe(N); }, 60_000, ); @@ -283,15 +345,15 @@ describe("verbose through proxy", () => { test.concurrent( `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin verbose:true`, async () => { - await using origin = await createAdversarialOrigin({ tls: originTls, body: "v" }); - await using proxy = await createAdversarialProxy({ tls: proxyTls }); - const res = await fetch(origin.url, { + const origin = sharedOrigin(originTls); + const proxy = sharedProxy(proxyTls); + const res = await fetch(`${origin.url}/v`, { proxy: proxy.url, keepalive: false, tls: laxTls, verbose: true, }); - expect(await res.text()).toBe("v"); + expect(await res.text()).toBe("/v"); expect(res.status).toBe(200); }, ); @@ -323,7 +385,7 @@ describe("AbortSignal.timeout through proxy", () => { await once(server, "listening"); const originPort = (server.address() as net.AddressInfo).port; try { - await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const proxy = sharedProxy(proxyTls); let code: string; const t0 = Date.now(); @@ -382,6 +444,18 @@ describe("WebSocket through proxy", () => { }); } + let wsOrigin: ReturnType; + let wssOrigin: ReturnType; + const echoOrigin = (tls: boolean) => (tls ? wssOrigin : wsOrigin); + beforeAll(() => { + wsOrigin = makeEchoServer(false); + wssOrigin = makeEchoServer(true); + }); + afterAll(() => { + wsOrigin?.stop(true); + wssOrigin?.stop(true); + }); + for (const { proxyTls, originTls } of cartesian({ proxyTls: [false, true] as const, originTls: [false, true] as const, @@ -390,7 +464,7 @@ describe("WebSocket through proxy", () => { const route = `${proxyTls ? "https" : "http"}-proxy → ${scheme}-origin`; test.concurrent(`${route}: echo round-trip`, async () => { - await using origin = makeEchoServer(originTls); + const origin = echoOrigin(originTls); await using proxy = await createAdversarialProxy({ tls: proxyTls }); const received: string[] = []; @@ -418,8 +492,8 @@ describe("WebSocket through proxy", () => { }); test.concurrent(`${route}: large binary frame round-trip`, async () => { - await using origin = makeEchoServer(originTls); - await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const origin = echoOrigin(originTls); + const proxy = sharedProxy(proxyTls); const payload = new Uint8Array(256 * 1024).fill(0xab); const { promise: done, resolve, reject } = Promise.withResolvers(); @@ -451,7 +525,7 @@ describe("WebSocket through proxy", () => { }); test.concurrent(`${route}: proxy RSTs at CONNECT → error event`, async () => { - await using origin = makeEchoServer(originTls); + const origin = echoOrigin(originTls); await using proxy = await createAdversarialProxy({ tls: proxyTls, killClientAt: "request-received", @@ -469,7 +543,7 @@ describe("WebSocket through proxy", () => { }); test.concurrent(`${route}: CONNECT → 407 without auth → error/close event`, async () => { - await using origin = makeEchoServer(originTls); + const origin = echoOrigin(originTls); await using proxy = await createAdversarialProxy({ tls: proxyTls, auth: { user: "u", pass: "p" }, @@ -487,8 +561,8 @@ describe("WebSocket through proxy", () => { }); test.concurrent(`${route}: rapid close after open`, async () => { - await using origin = makeEchoServer(originTls); - await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const origin = echoOrigin(originTls); + const proxy = sharedProxy(proxyTls); const { promise, resolve, reject } = Promise.withResolvers(); const ws = new WebSocket(`${scheme}://localhost:${origin.port}/`, { proxy: proxy.url, @@ -504,25 +578,41 @@ describe("WebSocket through proxy", () => { // wss through https proxy: the double-TLS case that had zero coverage. // Churn it under GC pressure to look for tunnel lifecycle issues. - test("wss via https-proxy: open/close churn under GC", async () => { - await using origin = makeEchoServer(true); - await using proxy = await createAdversarialProxy({ tls: true }); - const N = isASAN ? 40 : 20; - for (let i = 0; i < N; i++) { - const { promise, resolve, reject } = Promise.withResolvers(); - const ws = new WebSocket(`wss://localhost:${origin.port}/`, { - proxy: proxy.url, - tls: laxTls, - } as any); - ws.onmessage = () => { - ws.close(); - }; - ws.onclose = () => resolve(); - ws.onerror = ev => reject(new Error("ws error: " + (ev as any).message)); - await promise; - if (i % 5 === 0) Bun.gc(true); - } - }, 60_000); + test.concurrent( + "wss via https-proxy: open/close churn under GC", + async () => { + const origin = echoOrigin(true); + const proxy = sharedProxy(true); + const N = isASAN ? 40 : 20; + // Each batch opens 8 tunnels at once and waits for every close. A full GC runs between batches. + const BATCH = 8; + let opened = 0; + for (let i = 0; i < N; i += BATCH) { + const n = Math.min(BATCH, N - i); + await Promise.all( + Array.from({ length: n }, () => { + const { promise, resolve, reject } = Promise.withResolvers(); + const ws = new WebSocket(`wss://localhost:${origin.port}/`, { + proxy: proxy.url, + tls: laxTls, + } as any); + ws.onopen = () => { + opened++; + }; + ws.onmessage = () => { + ws.close(); + }; + ws.onclose = () => resolve(); + ws.onerror = ev => reject(new Error("ws error: " + (ev as any).message)); + return promise; + }), + ); + Bun.gc(true); + } + expect(opened).toBe(N); + }, + 60_000, + ); }); // ───────────────────────────────────────────────────────────────────────────── @@ -533,7 +623,7 @@ describe("WebSocket through proxy", () => { describe("interleaved proxy/direct", () => { for (const proxyTls of [false, true] as const) { - test(`${proxyTls ? "https" : "http"}-proxy + direct to same https origin, alternating`, async () => { + test.concurrent(`${proxyTls ? "https" : "http"}-proxy + direct to same https origin, alternating`, async () => { await using origin = Bun.serve({ port: 0, tls: tlsCert, diff --git a/test/js/bun/http/proxy-stress-concurrent.test.ts b/test/js/bun/http/proxy-stress-concurrent.test.ts index 3bb319de6d9b..aa461ed8b12a 100644 --- a/test/js/bun/http/proxy-stress-concurrent.test.ts +++ b/test/js/bun/http/proxy-stress-concurrent.test.ts @@ -5,13 +5,17 @@ * host:port, proxy_auth_hash, established_with_reject_unauthorized), and * there is one pool per TLS context: the default one plus one per `tls` * option that needs its own context (ca / cert / key ...). These tests churn - * those pools: many parallel requests to one target, many targets through one - * proxy, reuse out of both kinds of context, interleaved aborts, and a - * subprocess leak probe that watches RSS over thousands of iterations. + * those pools: a subprocess leak probe per (proxy flavour, mode) cell, many + * parallel requests to one target, many targets through one proxy, reuse out + * of both kinds of context, and interleaved aborts. + * + * Every test builds its own proxy and origin, so they all run concurrently. + * The proxy records one entry per accepted connection, and the tests assert + * the exact connection list. */ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isCI, isMacOS, isWindows } from "harness"; +import { bunEnv, bunExe, isASAN } from "harness"; import { once } from "node:events"; import net from "node:net"; import { join } from "node:path"; @@ -36,16 +40,121 @@ afterAll(() => { }); // ───────────────────────────────────────────────────────────────────────────── -// Parallel requests to one origin through one proxy. +// Subprocess leak / UAF probe: one child per (proxy flavour, mode) cell, each +// running ITERATIONS fetch (and abort) cycles through a CONNECT proxy to an +// https origin. proxy-stress-memory-fixture.ts documents the line it prints. +// - A crash (UAF under ASAN, debug assert, segfault) shows in stderr and the exit code. +// - On the ASAN lanes the runner turns LeakSanitizer on for this file, so a +// leaked object shows in stderr too. +// - A leaked tunnel adds one to `sslCtxGrowth` per request, in every build. +// - Anything else that grows per request shows in `rssGrowth`, on release builds. +// More iterations make none of these more sensitive. macOS and Windows can't +// take more than 300: they have 16k ephemeral ports, and every request leaves +// 2 loopback connections in TIME_WAIT across 12 concurrent children. +// +// Under ASAN `bun test` runs at most 5 tests at a time and these children are +// the slowest, so this block comes first, with the slowest modes first. +// ───────────────────────────────────────────────────────────────────────────── + +describe("memory probe (subprocess)", () => { + const ITERATIONS = 300; + const HALF = ITERATIONS / 2; + + type Outcome = { completed: number; failed: number; errors: Record }; + const allComplete: Outcome = { completed: ITERATIONS, failed: 0, errors: {} }; + const allAborted: Outcome = { completed: 0, failed: ITERATIONS, errors: { AbortError: ITERATIONS } }; + + // A request aborted on the microtask after fetch() races the HTTP thread to + // the proxy, so those two modes only bound the number of CONNECTs. + const EXPECTED: Record = { + // Origin redirects once per request; keepalive is off, so each hop is its own tunnel. + "redirect": { outcome: allComplete, connects: 2 * ITERATIONS }, + "complete": { outcome: allComplete, connects: ITERATIONS }, + "concurrent-32": { outcome: allComplete, connects: ITERATIONS }, + // 32 in flight, the odd-numbered half aborted on the next microtask. + "concurrent-32-abort": { + outcome: { completed: HALF, failed: HALF, errors: { AbortError: HALF } }, + connects: [HALF, ITERATIONS], + }, + // Aborted once the proxy has read the CONNECT head: exactly one CONNECT each. + "abort-after-connect": { outcome: allAborted, connects: ITERATIONS }, + "abort-immediate": { outcome: allAborted, connects: [0, ITERATIONS] }, + }; + + for (const { mode, proxyTls } of cartesian({ + mode: Object.keys(EXPECTED), + proxyTls: [true, false] as const, + })) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → https-origin mode=${mode} ×${ITERATIONS}`, + async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + join(import.meta.dir, "proxy-stress-memory-fixture.ts"), + proxyTls ? "https" : "http", + mode, + String(ITERATIONS), + ], + env: { + ...bunEnv, + ...proxyFreeEnv, + TLS_CERT: tlsCert.cert, + TLS_KEY: tlsCert.key, + // UAFs on the HTTP thread must abort the process rather + // than race the main thread's clean exit. + ASAN_OPTIONS: (bunEnv.ASAN_OPTIONS ?? "") + ":abort_on_error=1:halt_on_error=1", + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // Anything on stderr is a crash, a sanitizer report or a fixture error. + expect(stderr).toBe(""); + const { connects, sslCtxGrowth, sslCtxInFlight, rssGrowth, ...outcome } = JSON.parse(stdout); + + const want = EXPECTED[mode]; + expect(outcome).toEqual(want.outcome); + if (typeof want.connects === "number") { + expect(connects).toBe(want.connects); + } else { + expect(connects).toBeWithin(want.connects[0], want.connects[1] + 1); + } + + // Steady state is 0. A leaked tunnel adds one per request after the + // warm-up, so 200 or more per cell. If tunnels ever stop owning an + // SSL_CTX, the `sslCtxInFlight` check fails instead of the growth + // check going silent. + expect(sslCtxGrowth).toBeLessThanOrEqual(2); + if (want.outcome.completed > 0) expect(sslCtxInFlight).toBeGreaterThanOrEqual(1); + + // A release build measures 0 to 9 MB. 32 MB catches leaks from about + // 150 KB per request, such as a response or TLS buffer. Under ASAN + // freed memory sits in the quarantine, so RSS isn't meaningful. + if (!isASAN) expect(rssGrowth).toBeLessThan(32 * 1024 * 1024); + + expect(exitCode).toBe(0); + }, + 120_000, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Parallel requests to one origin through one proxy. All N are dispatched +// before the proxy (which runs on this thread) can relay anything, and N is +// below the client's in-flight cap (256, BUN_CONFIG_MAX_HTTP_REQUESTS). None +// of them can reuse a pooled connection, so the proxy sees exactly N. // ───────────────────────────────────────────────────────────────────────────── describe("parallel requests, single origin", () => { + const N = 32; for (const { proxyTls, originTls, keepalive } of cartesian({ proxyTls: [false, true] as const, originTls: [false, true] as const, keepalive: [false, true] as const, })) { - const N = 32; test.concurrent( `${N}× parallel ${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin keepalive=${keepalive}`, async () => { @@ -69,16 +178,10 @@ describe("parallel requests, single origin", () => { ), ); - // Every request got its own response body back — no cross-talk. - for (let i = 0; i < N; i++) { - expect(results[i]).toEqual({ status: 200, body: String(i) }); - } - - // All traffic went through the proxy. - expect(proxy.connections.length).toBeGreaterThanOrEqual(1); - if (originTls) { - expect(proxy.connections.every(c => c.method === "CONNECT")).toBe(true); - } + // Every request got its own response body back: no cross-talk. + expect(results).toEqual(Array.from({ length: N }, (_, i) => ({ status: 200, body: String(i) }))); + // CONNECT tunnels to an https origin, absolute-form requests to an http one. + expect(proxy.connections.map(c => c.method)).toEqual(Array(N).fill(originTls ? "CONNECT" : "GET")); }, 30_000, ); @@ -94,47 +197,56 @@ describe("parallel requests, single origin", () => { describe("tunnel reuse", () => { for (const proxyTls of [false, true] as const) { - test(`${proxyTls ? "https" : "http"}-proxy → https-origin, 5 sequential requests reuse one CONNECT`, async () => { - await using origin = Bun.serve({ - port: 0, - tls: tlsCert, - fetch: () => new Response("reused"), - }); - await using proxy = await createAdversarialProxy({ tls: proxyTls }); + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → https-origin, 5 sequential requests reuse one CONNECT`, + async () => { + await using origin = Bun.serve({ + port: 0, + tls: tlsCert, + fetch: () => new Response("reused"), + }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); - for (let i = 0; i < 5; i++) { - const res = await fetch(origin.url, { proxy: proxy.url, keepalive: true, tls: laxTls }); - expect(await res.text()).toBe("reused"); - expect(res.status).toBe(200); - } - // laxTls carries a `ca`, so the connection to the proxy lives in that - // config's own TLS context. The finished tunnel must be pooled into - // that same context (where the next request looks for it), so a - // single CONNECT serves all five requests for both proxy flavours. - expect(proxy.connectCount()).toBe(1); - }); + const responses: Array<{ status: number; body: string }> = []; + for (let i = 0; i < 5; i++) { + const res = await fetch(origin.url, { proxy: proxy.url, keepalive: true, tls: laxTls }); + responses.push({ status: res.status, body: await res.text() }); + } + expect(responses).toEqual(Array(5).fill({ status: 200, body: "reused" })); + // laxTls carries a `ca`, so the connection to the proxy lives in that + // config's own TLS context. The finished tunnel must be pooled into + // that same context (where the next request looks for it), so a + // single CONNECT serves all five requests for both proxy flavours. + expect(proxy.connections.map(c => c.method)).toEqual(["CONNECT"]); + }, + ); - test(`${proxyTls ? "https" : "http"}-proxy → https-origin, different auth hashes use separate tunnels`, async () => { - await using origin = Bun.serve({ port: 0, tls: tlsCert, fetch: () => new Response("ok") }); - await using proxy = await createAdversarialProxy({ tls: proxyTls }); + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → https-origin, different auth hashes use separate tunnels`, + async () => { + await using origin = Bun.serve({ port: 0, tls: tlsCert, fetch: () => new Response("ok") }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); - const creds = ["a:1", "b:2", "a:1"]; // third reuses the first tunnel - for (const c of creds) { - const res = await fetch(origin.url, { - proxy: `${proxyTls ? "https" : "http"}://${c}@127.0.0.1:${proxy.port}`, - keepalive: true, - tls: laxTls, - }); - expect(res.status).toBe(200); - await res.arrayBuffer(); - } - // Different auth hashes must never share a tunnel; the repeat of the - // first credentials must find the first tunnel in the pool. - expect(proxy.connectCount()).toBe(2); - // The two CONNECTs carried different Proxy-Authorization. - const auths = proxy.connections.map(r => r.headers["proxy-authorization"]); - expect(auths[0]).not.toBe(auths[1]); - }); + const creds = ["a:1", "b:2", "a:1"]; // third reuses the first tunnel + const statuses: number[] = []; + for (const c of creds) { + const res = await fetch(origin.url, { + proxy: `${proxyTls ? "https" : "http"}://${c}@127.0.0.1:${proxy.port}`, + keepalive: true, + tls: laxTls, + }); + statuses.push(res.status); + await res.arrayBuffer(); + } + expect(statuses).toEqual([200, 200, 200]); + // One CONNECT per distinct credential. The repeat of the first + // credentials reuses the first tunnel instead of opening a third. + expect(proxy.connections.map(c => [c.method, c.headers["proxy-authorization"]])).toEqual([ + ["CONNECT", `Basic ${btoa("a:1")}`], + ["CONNECT", `Basic ${btoa("b:2")}`], + ]); + }, + ); } // A `tls` option that needs its own TLS context (`ca` here; cert/key etc. @@ -225,8 +337,8 @@ describe("tunnel reuse", () => { } expect(responses).toEqual([RESPONSES[shape], RESPONSES[shape], RESPONSES[shape]]); - expect({ connects: proxy.connectCount(), originConnections: origin.connections }).toEqual({ - connects: 1, + expect({ connects: proxy.connections.map(c => c.method), originConnections: origin.connections }).toEqual({ + connects: ["CONNECT"], originConnections: 1, }); }, @@ -239,34 +351,37 @@ describe("tunnel reuse", () => { // ───────────────────────────────────────────────────────────────────────────── describe("many origins, one proxy", () => { + const N_ORIGINS = 12; for (const proxyTls of [false, true] as const) { - test(`${proxyTls ? "https" : "http"}-proxy → 12 https origins, interleaved`, async () => { - const N_ORIGINS = 12; - const origins: Array<{ url: string; stop: () => void }> = []; - for (let i = 0; i < N_ORIGINS; i++) { - const body = `origin-${i}`; - const s = Bun.serve({ port: 0, tls: tlsCert, fetch: () => new Response(body) }); - origins.push({ url: String(s.url), stop: () => s.stop(true) }); - } - await using proxy = await createAdversarialProxy({ tls: proxyTls }); - - try { - // Two rounds so each origin is reused once. - for (let round = 0; round < 2; round++) { - for (let i = 0; i < N_ORIGINS; i++) { - const res = await fetch(origins[i].url, { proxy: proxy.url, keepalive: true, tls: laxTls }); - expect(await res.text()).toBe(`origin-${i}`); - expect(res.status).toBe(200); + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → ${N_ORIGINS} https origins, interleaved`, + async () => { + const origins = Array.from({ length: N_ORIGINS }, (_, i) => + Bun.serve({ port: 0, tls: tlsCert, fetch: () => new Response(`origin-${i}`) }), + ); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + + try { + // Two rounds so each origin is reused once. + const responses: Array<{ status: number; body: string }> = []; + for (let round = 0; round < 2; round++) { + for (const origin of origins) { + const res = await fetch(origin.url, { proxy: proxy.url, keepalive: true, tls: laxTls }); + responses.push({ status: res.status, body: await res.text() }); + } } + const round = origins.map((_, i) => ({ status: 200, body: `origin-${i}` })); + expect(responses).toEqual([...round, ...round]); + // Round one opens one CONNECT per origin, in order. Round two reuses every tunnel. + expect(proxy.connections.map(c => [c.method, c.target])).toEqual( + origins.map(origin => ["CONNECT", origin.url.host]), + ); + } finally { + for (const origin of origins) origin.stop(true); } - // Every request went through the proxy as a CONNECT (none - // bypassed it), and round two reused every tunnel round one - // pooled, for both proxy flavours. - expect(proxy.connectCount()).toBe(N_ORIGINS); - } finally { - for (const o of origins) o.stop(); - } - }, 45_000); + }, + 45_000, + ); } }); @@ -277,42 +392,32 @@ describe("many origins, one proxy", () => { describe("reject_unauthorized pool gate", () => { for (const proxyTls of [false, true] as const) { - test(`${proxyTls ? "https" : "http"}-proxy → https-origin: lax then strict opens a fresh CONNECT`, async () => { - await using origin = Bun.serve({ port: 0, tls: tlsCert, fetch: () => new Response("g") }); - await using proxy = await createAdversarialProxy({ tls: proxyTls }); - - // 1: lax - let res = await fetch(origin.url, { - proxy: proxy.url, - keepalive: true, - tls: { rejectUnauthorized: false }, - }); - expect(res.status).toBe(200); - await res.arrayBuffer(); - expect(proxy.connectCount()).toBe(1); - - // 2: strict with matching CA — must not reuse the lax tunnel. - res = await fetch(origin.url, { - proxy: proxy.url, - keepalive: true, - tls: { ca: tlsCert.cert, rejectUnauthorized: true }, - }); - expect(res.status).toBe(200); - await res.arrayBuffer(); - expect(proxy.connectCount()).toBe(2); - - // 3: strict again, reuses the strict tunnel. With an https proxy the - // strict tunnel lives in the `ca` config's own TLS context; it must - // have been pooled there, not in the default context. - res = await fetch(origin.url, { - proxy: proxy.url, - keepalive: true, - tls: { ca: tlsCert.cert, rejectUnauthorized: true }, - }); - expect(res.status).toBe(200); - await res.arrayBuffer(); - expect(proxy.connectCount()).toBe(2); - }, 30_000); + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → https-origin: lax then strict opens a fresh CONNECT`, + async () => { + await using origin = Bun.serve({ port: 0, tls: tlsCert, fetch: () => new Response("g") }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + + const lax = { rejectUnauthorized: false }; + const strict = { ca: tlsCert.cert, rejectUnauthorized: true }; + const steps: Array<{ status: number; connects: number }> = []; + for (const tlsOption of [lax, strict, strict]) { + const res = await fetch(origin.url, { proxy: proxy.url, keepalive: true, tls: tlsOption }); + await res.arrayBuffer(); + steps.push({ status: res.status, connects: proxy.connectCount() }); + } + // 1: lax pools a tunnel. 2: strict must not reuse it and opens its + // own. 3: strict again reuses the strict tunnel. With an https proxy + // the strict tunnel has to be pooled in the `ca` config's own TLS + // context, not in the default one. + expect(steps).toEqual([ + { status: 200, connects: 1 }, + { status: 200, connects: 2 }, + { status: 200, connects: 2 }, + ]); + }, + 30_000, + ); } }); @@ -324,9 +429,10 @@ describe("reject_unauthorized pool gate", () => { describe("large bidirectional", () => { const SIZE = 4 * 1024 * 1024; + const N = 4; for (const proxyTls of [false, true] as const) { test.concurrent( - `${proxyTls ? "https" : "http"}-proxy → https-origin, 4× concurrent ${SIZE}B echo`, + `${proxyTls ? "https" : "http"}-proxy → https-origin, ${N}× concurrent ${SIZE}B echo`, async () => { await using origin = Bun.serve({ port: 0, @@ -337,7 +443,7 @@ describe("large bidirectional", () => { const payload = makeBody(SIZE, "L"); const results = await Promise.all( - Array.from({ length: 4 }, () => + Array.from({ length: N }, () => fetch(origin.url, { method: "POST", body: payload, @@ -350,9 +456,11 @@ describe("large bidirectional", () => { }), ), ); - for (const r of results) { - expect(r).toEqual({ status: 200, len: SIZE, ok: true }); - } + expect(results).toEqual(Array(N).fill({ status: 200, len: SIZE, ok: true })); + // One tunnel per request, each relaying the whole payload both ways (plus TLS framing). + expect( + proxy.connections.map(c => ({ method: c.method, relayedBothWays: c.bytesUp > SIZE && c.bytesDown > SIZE })), + ).toEqual(Array(N).fill({ method: "CONNECT", relayedBothWays: true })); }, 60_000, ); @@ -366,7 +474,7 @@ describe("large bidirectional", () => { // letting the stale byte reach the next request. // ───────────────────────────────────────────────────────────────────────────── -test("idle pooled tunnel receiving data is evicted", async () => { +test.concurrent("idle pooled tunnel receiving data is evicted", async () => { await using origin = Bun.serve({ port: 0, tls: tlsCert, fetch: () => new Response("ok") }); // Custom proxy that exposes each live client socket so the test can @@ -401,160 +509,27 @@ test("idle pooled tunnel receiving data is evicted", async () => { }); server.listen(0, "127.0.0.1"); await once(server, "listening"); - const proxyPort = (server.address() as net.AddressInfo).port; + const proxy = `http://127.0.0.1:${(server.address() as net.AddressInfo).port}`; try { // First request: pools the tunnel. - let res = await fetch(origin.url, { - proxy: `http://127.0.0.1:${proxyPort}`, - keepalive: true, - tls: laxTls, - }); - expect(await res.text()).toBe("ok"); - expect(connects).toBe(1); + let res = await fetch(origin.url, { proxy, keepalive: true, tls: laxTls }); + expect({ body: await res.text(), connects }).toEqual({ body: "ok", connects: 1 }); - // Tunnel is now parked. Push a stray byte into every live client + // Tunnel is now parked. Push a stray byte into the one live client // socket from the proxy side. The client's idle-data handler evicts // the pooled entry, which RSTs the proxy connection. - const parked = [...liveClients]; - expect(parked.length).toBe(1); - const closed = Promise.all( - parked.map( - c => - new Promise(resolve => { - if (c.destroyed) return resolve(); - c.once("close", () => resolve()); - }), - ), - ); - for (const c of parked) c.write(Buffer.from([0x17, 0x03, 0x03, 0x00, 0x01, 0x00])); + expect(liveClients.size).toBe(1); + const [parked] = liveClients; + const closed = new Promise(resolve => parked.once("close", () => resolve())); + parked.write(Buffer.from([0x17, 0x03, 0x03, 0x00, 0x01, 0x00])); await closed; // Second request: must open a fresh CONNECT and succeed. - res = await fetch(origin.url, { - proxy: `http://127.0.0.1:${proxyPort}`, - keepalive: true, - tls: laxTls, - }); - expect(await res.text()).toBe("ok"); - expect(connects).toBe(2); + res = await fetch(origin.url, { proxy, keepalive: true, tls: laxTls }); + expect({ body: await res.text(), connects }).toEqual({ body: "ok", connects: 2 }); } finally { for (const c of liveClients) c.destroy(); server.close(); } }); - -// ───────────────────────────────────────────────────────────────────────────── -// Subprocess-based memory / leak / UAF probe. -// -// Run a child that issues thousands of fetch/abort cycles through a proxy -// to an https origin across every stage of the tunnel, tracking RSS. The -// child exits non-zero on any crash (ASAN UAF, debug assert, segfault) and -// reports RSS growth ratio so the test can fail on leaks. -// ───────────────────────────────────────────────────────────────────────────── - -describe("memory probe (subprocess)", () => { - const MODES = [ - "complete", // let every request finish - "abort-immediate", // abort on next microtask - "abort-after-connect", // abort once the proxy sees the CONNECT - "concurrent-32", // 32 in flight at once, all complete - "concurrent-32-abort", // 32 in flight, abort half at random - "redirect", // origin redirects once per request - ] as const; - - for (const { proxyTls, mode } of cartesian({ - proxyTls: [false, true] as const, - mode: MODES, - })) { - // ASAN inflates RSS and slows everything down; use fewer iterations - // there but still enough to surface a UAF. Windows and macOS are capped - // for a different reason: both use a 16,384-port ephemeral range - // (49152-65535), and 12 concurrent subprocesses x 1200 iterations x 2 - // loopback connections each leave ~15k entries in TIME_WAIT (120s drain - // on Windows, 30s on macOS). On Windows that poisons later tests in the - // shard (listen(0)/connect() recycling into stale TIME_WAIT 4-tuples, - // observed as ERR_POSTGRES_CONNECTION_REFUSED / WSAENOTCONN in - // test/js/sql/postgres-binary-array-bounds.test.ts); on macOS the - // https-proxy fixtures themselves see a single ConnectionRefused at - // ~i=550 once TIME_WAIT passes ~15k. - const iterations = isASAN || isWindows || isMacOS ? 300 : isCI ? 1200 : 600; - - test.concurrent( - `${proxyTls ? "https" : "http"}-proxy → https-origin mode=${mode} ×${iterations}`, - async () => { - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - join(import.meta.dir, "proxy-stress-memory-fixture.ts"), - proxyTls ? "https" : "http", - mode, - String(iterations), - ], - env: { - ...bunEnv, - ...proxyFreeEnv, - // UAFs on the HTTP thread must abort the process rather - // than race the main thread's clean exit. - ASAN_OPTIONS: ((bunEnv as any).ASAN_OPTIONS ?? "") + ":abort_on_error=1:halt_on_error=1", - }, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - if (exitCode !== 0) console.error("fixture stderr:\n" + stderr); - - // Surface the child's final stats line before asserting. - const lines = stdout.trim().split("\n"); - const lastLine = lines[lines.length - 1]; - let result: { - completed: number; - failed: number; - firstError?: string; - rssStart: number; - rssEnd: number; - rssMax: number; - }; - try { - result = JSON.parse(lastLine); - } catch { - console.error("fixture stdout:\n" + stdout); - throw new Error("fixture did not emit a JSON summary line"); - } - - expect(exitCode).toBe(0); - expect(result.completed + result.failed).toBe(iterations); - // Non-abort modes must complete every request; abort modes must - // have actually aborted something. - if (mode.includes("abort")) { - expect(result.failed).toBeGreaterThan(0); - } else { - // Carry `firstError` in the diff so a CI failure shows the - // actual fetch error, not just the count. - expect({ failed: result.failed, firstError: result.firstError }).toEqual({ - failed: 0, - firstError: undefined, - }); - expect(result.completed).toBe(iterations); - } - - // RSS leak check: after a warm-up, RSS should plateau. Allow a - // generous 3× growth factor (ASAN, fragmentation, per-target pool - // entries) — a real leak of one tunnel/request shows as 10×+ with - // these iteration counts. Skip the threshold under ASAN because - // LeakSanitizer's shadow memory makes RSS non-representative; a - // UAF there shows up as a crash, not a slow leak. - if (!isASAN) { - const growth = result.rssEnd / Math.max(1, result.rssStart); - // Carry `mode` + the rounded ratio in the failing diff. - expect({ mode, growth: Number(growth.toFixed(2)), withinBound: growth < 3.0 }).toEqual({ - mode, - growth: expect.any(Number), - withinBound: true, - }); - } - }, - 120_000, - ); - } -}); diff --git a/test/js/bun/http/proxy-stress-errors.test.ts b/test/js/bun/http/proxy-stress-errors.test.ts index 88d7a5b9dd3c..f5744afa324d 100644 --- a/test/js/bun/http/proxy-stress-errors.test.ts +++ b/test/js/bun/http/proxy-stress-errors.test.ts @@ -15,6 +15,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { once } from "node:events"; import net from "node:net"; import tls from "node:tls"; +import zlib from "node:zlib"; import { cartesian, clearProxyEnv, @@ -425,3 +426,107 @@ describe("HTTP/2 not offered through proxy", () => { }); } }); + +// ───────────────────────────────────────────────────────────────────────────── +// A decoder error must reject the body read through a tunnel, same as on a direct +// connection. The client's own close can't count as the origin ending the body. +// ───────────────────────────────────────────────────────────────────────────── + +describe("damaged compressed body through a CONNECT tunnel", () => { + const payload = Buffer.alloc(108_000, "line of an honest download\n"); + + const codecs = { + gzip: { compress: zlib.gzipSync, error: "ZlibError" }, + deflate: { compress: zlib.deflateSync, error: "ZlibError" }, + br: { compress: zlib.brotliCompressSync, error: "BrotliDecompressionError" }, + zstd: { compress: zlib.zstdCompressSync, error: "ZstdDecompressionError" }, + } as const; + type Encoding = keyof typeof codecs; + type Framing = "chunked" | "close-delimited"; + + const damages = { + none: (stream: Buffer) => stream, + cut: (stream: Buffer) => stream.subarray(0, stream.length >> 1), + /** Flips one bit of the trailing checksum (CRC-32 for gzip, Adler-32 for deflate). */ + checksum: (stream: Buffer, encoding: Encoding) => { + const damaged = Buffer.from(stream); + damaged[damaged.length - (encoding === "gzip" ? 5 : 1)] ^= 1; + return damaged; + }, + garbage: () => Buffer.from("this is no compressed stream at all"), + }; + type Damage = keyof typeof damages; + + /** + * An https origin that sends the response head, then waits for `release()` to send the body. + * Callers release after `fetch()` resolves, so the body arrives in its own socket read. + */ + async function createHeldBodyOrigin(encoding: Encoding, framing: Framing, body: Buffer) { + const { promise: released, resolve: release } = Promise.withResolvers(); + const server = tls.createServer(tlsCert, sock => { + sock.on("error", () => {}); + sock.once("data", async () => { + sock.write( + `HTTP/1.1 200 OK\r\nContent-Encoding: ${encoding}\r\n` + + (framing === "chunked" ? "Transfer-Encoding: chunked\r\n\r\n" : "Connection: close\r\n\r\n"), + ); + await released; + if (framing === "chunked") { + sock.write( + Buffer.concat([Buffer.from(`${body.length.toString(16)}\r\n`), body, Buffer.from("\r\n0\r\n\r\n")]), + ); + } else { + sock.end(body); + } + }); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + return { + url: `https://127.0.0.1:${(server.address() as net.AddressInfo).port}`, + release, + async [Symbol.asyncDispose]() { + server.close(); + }, + }; + } + + /** Reads the body through the tunnel and returns its byte count or the error code. */ + async function readBody(encoding: Encoding, framing: Framing, damage: Damage, proxyTls: boolean) { + const body = damages[damage](codecs[encoding].compress(payload), encoding); + await using origin = await createHeldBodyOrigin(encoding, framing, body); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const res = await fetch(origin.url, { proxy: proxy.url, keepalive: false, tls: laxTls }); + expect(proxy.connectCount()).toBe(1); + origin.release(); + try { + return { bytes: (await res.arrayBuffer()).byteLength }; + } catch (e) { + return { error: errcode(e) }; + } + } + + const cells: Array<{ encoding: Encoding; damage: Damage }> = [ + ...cartesian({ encoding: ["gzip", "deflate", "br", "zstd"], damage: ["none", "cut", "garbage"] } as const), + // Only gzip and deflate end in a checksum. + ...cartesian({ encoding: ["gzip", "deflate"], damage: ["checksum"] } as const), + ]; + + for (const { encoding, damage } of cells) { + for (const framing of ["chunked", "close-delimited"] as const) { + test.concurrent(`${encoding}, ${framing}, ${damage}`, async () => { + expect(await readBody(encoding, framing, damage, false)).toEqual( + damage === "none" ? { bytes: payload.length } : { error: codecs[encoding].error }, + ); + }); + } + } + + // Failing the request closes the proxy socket, so cover a TLS proxy socket in both body stages. + test.concurrent("https proxy: gzip, chunked, cut", async () => { + expect(await readBody("gzip", "chunked", "cut", true)).toEqual({ error: "ZlibError" }); + }); + test.concurrent("https proxy: gzip, close-delimited, garbage", async () => { + expect(await readBody("gzip", "close-delimited", "garbage", true)).toEqual({ error: "ZlibError" }); + }); +}); diff --git a/test/js/bun/http/proxy-stress-helpers.ts b/test/js/bun/http/proxy-stress-helpers.ts index c01af31b0195..44e36c3ef11c 100644 --- a/test/js/bun/http/proxy-stress-helpers.ts +++ b/test/js/bun/http/proxy-stress-helpers.ts @@ -721,6 +721,11 @@ export interface DeadPort { * `listen(0)`, and not listening, so `connect()` to it is refused. Do not * simplify to bind-then-close: that frees the port into exactly the pool * `listen(0)` draws from, and a sibling `test.concurrent` will take it. + * + * `localAddress` makes the holder `bind()` before it connects. On Linux, a + * `connect()` to a port that an earlier `connect()` picked can get that same + * port as its source port and connect to itself (about 1 in 14k dials). Linux + * never picks a port that `bind()` holds. */ export async function deadPort(): Promise { let accepted: net.Socket | undefined; @@ -730,7 +735,11 @@ export async function deadPort(): Promise { }); sink.listen(0, "127.0.0.1"); await once(sink, "listening"); - const holder = net.connect({ host: "127.0.0.1", port: (sink.address() as net.AddressInfo).port }); + const holder = net.connect({ + host: "127.0.0.1", + port: (sink.address() as net.AddressInfo).port, + localAddress: "127.0.0.1", + }); holder.on("error", () => {}); await once(holder, "connect"); const port = (holder.address() as net.AddressInfo).port; diff --git a/test/js/bun/http/proxy-stress-memory-fixture.ts b/test/js/bun/http/proxy-stress-memory-fixture.ts index 35c40a32c842..5a2086374213 100644 --- a/test/js/bun/http/proxy-stress-memory-fixture.ts +++ b/test/js/bun/http/proxy-stress-memory-fixture.ts @@ -1,40 +1,54 @@ /** - * Subprocess fixture for proxy-stress-concurrent.test.ts: issue many - * requests through a local CONNECT proxy to a local HTTPS origin, under - * one of several modes (complete, abort-immediate, abort-after-connect, - * concurrent-32, concurrent-32-abort, redirect), tracking RSS across the - * run. Emits a single JSON summary line on stdout and exits 0 on clean - * completion. + * Subprocess fixture for the "memory probe" block of proxy-stress-concurrent.test.ts. + * + * Sends requests through a local CONNECT proxy to a local https + * origin in one of the modes below, and prints one JSON line: + * + * completed requests that returned 200 with the expected body + * failed requests that rejected or returned something else + * errors `failed`, counted by error code (or error name) + * connects CONNECT requests the proxy received + * sslCtxGrowth live SSL_CTX count at the end minus the count after the + * warm-up. Every tunnel owns one SSL_CTX, so a leaked tunnel + * adds one per request. + * sslCtxInFlight highest live SSL_CTX count sampled with tunnels in flight + * minus the lowest sampled with none. Above 0 proves that + * `sslCtxGrowth` can see a leaked tunnel. + * rssGrowth RSS at the end minus RSS after the warm-up, in bytes + * + * The first quarter of the iterations is the warm-up. * * Usage: bun proxy-stress-memory-fixture.ts + * Env: TLS_CERT, TLS_KEY. Importing "harness" for them would cost each child + * about a second of start-up in a debug build. */ +import { sslCtxLiveCount } from "bun:internal-for-testing"; +import { once } from "node:events"; import net from "node:net"; import tls from "node:tls"; -import { once } from "node:events"; -import { tls as tlsCert } from "harness"; const [proxyScheme, mode, iterStr] = process.argv.slice(2); -const iterations = Number(iterStr ?? "600"); +const iterations = Number(iterStr); const isHttpsProxy = proxyScheme === "https"; - -type ConnectRecord = { count: number; resolveNext?: () => void }; -const connectRecord: ConnectRecord = { count: 0 }; - -function notifyConnect() { - connectRecord.count++; - if (connectRecord.resolveNext) { - const r = connectRecord.resolveNext; - connectRecord.resolveNext = undefined; - r(); - } -} - -function waitForNextConnect(): Promise { - return new Promise(resolve => { - connectRecord.resolveNext = resolve; - }); -} +const tlsCert = { cert: process.env.TLS_CERT!, key: process.env.TLS_KEY! }; + +type Abort = "never" | "microtask" | "after-connect"; +const MODES: Record Abort }> = { + // Let every request finish. + "complete": { concurrency: 1, abort: () => "never" }, + // Abort on the microtask after fetch() returns: races the HTTP thread's connect. + "abort-immediate": { concurrency: 1, abort: () => "microtask" }, + // Abort as soon as the proxy has read the CONNECT head. + "abort-after-connect": { concurrency: 1, abort: () => "after-connect" }, + // 32 in flight at once, all complete. + "concurrent-32": { concurrency: 32, abort: () => "never" }, + // 32 in flight at once, the odd ones aborted on the next microtask. + "concurrent-32-abort": { concurrency: 32, abort: i => (i % 2 === 1 ? "microtask" : "never") }, + // The origin redirects once per request, so every iteration opens two tunnels. + "redirect": { concurrency: 1, abort: () => "never" }, +}; +const { concurrency, abort } = MODES[mode]; // HTTPS origin. Optionally redirects once (for mode=redirect). const origin = Bun.serve({ @@ -49,8 +63,17 @@ const origin = Bun.serve({ }, }); -// A CONNECT proxy (HTTP or HTTPS outer socket). It intentionally tracks -// connects so the fixture can synchronize aborts to the CONNECT boundary. +let connects = 0; +let onNextConnect: (() => void) | undefined; + +// Sampling starts after the warm-up, so contexts created lazily during it don't count as tunnels. +let sslCtxStart = -1; +let sslCtxIdleMin = Infinity; +let sslCtxBusyMax = -Infinity; + +// A CONNECT proxy (plain or TLS outer socket). It counts CONNECT heads so the +// test can check that every request went through it, and so "after-connect" +// aborts can wait for the CONNECT. function handleClient(client: net.Socket) { client.on("error", () => {}); let head = Buffer.alloc(0); @@ -64,18 +87,24 @@ function handleClient(client: net.Socket) { head = Buffer.concat([head, chunk]); const end = head.indexOf("\r\n\r\n"); if (end === -1) return; - notifyConnect(); + const requestLine = head.subarray(0, head.indexOf("\r\n")).toString("latin1"); + const [method, target] = requestLine.split(" "); + if (method === "CONNECT") { + connects++; + onNextConnect?.(); + onNextConnect = undefined; + } const leftover = head.subarray(end + 4); - const firstLine = head.subarray(0, head.indexOf("\r\n")).toString("latin1"); - const [, hostPort] = firstLine.split(" "); - const colon = hostPort!.lastIndexOf(":"); - const host = hostPort!.slice(0, colon); - const port = Number(hostPort!.slice(colon + 1)); - upstream = net.connect(port, host, () => { + // Dial 127.0.0.1 instead of resolving the target's `localhost`. + const port = Number(target.slice(target.lastIndexOf(":") + 1)); + upstream = net.connect(port, "127.0.0.1", () => { client.write("HTTP/1.1 200 Connection Established\r\n\r\n"); if (leftover.length) upstream!.write(leftover); - // client → upstream relay is the outer on("data") handler above; - // only pipe the upstream → client direction here. + // The first upstream byte is the origin's ServerHello, so the client's tunnel owns its SSL_CTX by now. + upstream!.once("data", () => { + if (sslCtxStart !== -1) sslCtxBusyMax = Math.max(sslCtxBusyMax, sslCtxLiveCount()); + }); + // The "data" handler above relays client → upstream. upstream!.pipe(client); }); upstream.on("error", () => client.destroy()); @@ -88,110 +117,96 @@ const proxy = isHttpsProxy : net.createServer(handleClient); proxy.listen(0, "127.0.0.1"); await once(proxy, "listening"); -const proxyPort = (proxy.address() as net.AddressInfo).port; -const proxyUrl = `${isHttpsProxy ? "https" : "http"}://127.0.0.1:${proxyPort}`; +const proxyUrl = `${proxyScheme}://127.0.0.1:${(proxy.address() as net.AddressInfo).port}`; const laxTls = { ca: tlsCert.cert, rejectUnauthorized: false } as const; -const originUrl = (p: string) => `https://localhost:${origin.port}${p}`; let completed = 0; let failed = 0; -let firstError: string | undefined; -let rssStart = 0; -let rssMax = 0; +const errors: Record = {}; -function recordError(i: number, e: unknown) { +function recordFailure(key: string) { failed++; - if (firstError === undefined) { - const any = e as { code?: unknown; name?: unknown; message?: unknown }; - firstError = `[i=${i}] ${any?.code ?? any?.name ?? "Error"}: ${any?.message ?? e}`; - } + errors[key] = (errors[key] ?? 0) + 1; } -const rss = process.memoryUsage.rss; +function errorKey(e: unknown): string { + const any = e as { code?: unknown; name?: unknown }; + return typeof any?.code === "string" ? any.code : typeof any?.name === "string" ? any.name : String(e); +} async function one(i: number): Promise { const path = mode === "redirect" ? "/start" : `/${i}`; - const ac = new AbortController(); - - if (mode === "abort-immediate") { - queueMicrotask(() => ac.abort()); - } else if (mode === "abort-after-connect") { - waitForNextConnect().then(() => ac.abort()); + const expectedBody = mode === "redirect" ? "ok-/final" : `ok-${path}`; + let signal: AbortSignal | undefined; + const when = abort(i); + if (when !== "never") { + const ac = new AbortController(); + signal = ac.signal; + if (when === "microtask") queueMicrotask(() => ac.abort()); + else new Promise(resolve => (onNextConnect = resolve)).then(() => ac.abort()); } try { - const res = await fetch(originUrl(path), { + const res = await fetch(`https://localhost:${origin.port}${path}`, { proxy: proxyUrl, keepalive: false, tls: laxTls, - signal: mode.startsWith("abort") ? ac.signal : undefined, + signal, }); - await res.arrayBuffer(); - completed++; + const body = await res.text(); + if (res.status === 200 && body === expectedBody) { + completed++; + } else { + recordFailure(`unexpected response ${res.status} ${JSON.stringify(body)}`); + } } catch (e) { - recordError(i, e); + recordFailure(errorKey(e)); } } -async function run() { - // Warm-up: first 20 iterations establish baseline RSS (JIT, TLS session - // cache, first-time allocations). rssStart is sampled after. - const WARMUP = Math.min(20, Math.floor(iterations / 4)); - - if (mode === "concurrent-32" || mode === "concurrent-32-abort") { - let i = 0; - while (i < iterations) { - const batch = Math.min(32, iterations - i); - const tasks: Promise[] = []; - for (let j = 0; j < batch; j++) { - const idx = i + j; - if (mode === "concurrent-32-abort" && idx % 2 === 1) { - const ac = new AbortController(); - const p = fetch(originUrl(`/${idx}`), { - proxy: proxyUrl, - keepalive: false, - tls: laxTls, - signal: ac.signal, - }) - .then(async r => { - await r.arrayBuffer(); - completed++; - }) - .catch(e => { - recordError(idx, e); - }); - queueMicrotask(() => ac.abort()); - tasks.push(p); - } else { - tasks.push(one(idx)); - } - } - await Promise.all(tasks); - i += batch; - if (i >= WARMUP && rssStart === 0) { - Bun.gc(true); - rssStart = rss(); - } - rssMax = Math.max(rssMax, rss()); - } - } else { - for (let i = 0; i < iterations; i++) { - await one(i); - if (i === WARMUP) { - Bun.gc(true); - rssStart = rss(); - } - rssMax = Math.max(rssMax, rss()); - } +const rss = process.memoryUsage.rss; + +const warmup = Math.floor(iterations / 4); +let rssStart = -1; + +for (let done = 0; done < iterations; ) { + const batch = Math.min(concurrency, iterations - done); + await Promise.all(Array.from({ length: batch }, (_, j) => one(done + j))); + done += batch; + // Nothing is in flight here. + if (rssStart === -1 && done >= warmup) { + Bun.gc(true); + rssStart = rss(); + sslCtxStart = sslCtxLiveCount(); } + if (sslCtxStart !== -1) sslCtxIdleMin = Math.min(sslCtxIdleMin, sslCtxLiveCount()); +} + +Bun.gc(true); +const rssEnd = rss(); - Bun.gc(true); - const rssEnd = rss(); - console.log(JSON.stringify({ completed, failed, firstError, rssStart, rssEnd, rssMax })); +// The HTTP thread frees the last request's tunnel shortly after it delivers the response. +// A leak keeps the count up until the deadline and still gets reported. +const deadline = Date.now() + 5_000; +while (sslCtxLiveCount() > sslCtxStart && Date.now() < deadline) { + await Bun.sleep(5); } +const sslCtxEnd = sslCtxLiveCount(); +sslCtxIdleMin = Math.min(sslCtxIdleMin, sslCtxEnd); + +console.log( + JSON.stringify({ + completed, + failed, + errors, + connects, + sslCtxGrowth: sslCtxEnd - sslCtxStart, + sslCtxInFlight: sslCtxBusyMax === -Infinity ? 0 : sslCtxBusyMax - sslCtxIdleMin, + rssGrowth: rssEnd - rssStart, + }), +); -await run(); origin.stop(true); proxy.close(); process.exit(0); diff --git a/test/js/bun/http/proxy.test.js b/test/js/bun/http/proxy.test.js index 35a893b06619..e3bba9b06d07 100644 --- a/test/js/bun/http/proxy.test.js +++ b/test/js/bun/http/proxy.test.js @@ -2,9 +2,28 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import fs from "fs"; import { bunEnv, bunExe, gc } from "harness"; import path from "path"; +import { clearProxyEnv, proxyFreeEnv, restoreProxyEnv } from "./proxy-stress-helpers"; + +// Forwards an absolute-form request and marks the response so tests can tell it went through the proxy. +async function forward(request) { + const response = await fetch(request.url, { + method: request.method, + body: await request.text(), + }); + const headers = new Headers(response.headers); + headers.set("x-proxy-used", "1"); + return new Response(response.body, { + status: response.status, + headers, + }); +} let proxy, auth_proxy, server; +// An ambient NO_PROXY=localhost would bypass the proxies below, and their upstream fetch() would follow +// an ambient HTTP_PROXY. +let savedProxyEnv; beforeAll(() => { + savedProxyEnv = clearProxyEnv(); proxy = Bun.serve({ port: 0, async fetch(request) { @@ -15,17 +34,7 @@ beforeAll(() => { // simple http proxy if (request.url.startsWith("http://")) { - const response = await fetch(request.url, { - method: request.method, - body: await request.text(), - }); - // Add marker header to indicate request went through proxy - const headers = new Headers(response.headers); - headers.set("x-proxy-used", "1"); - return new Response(response.body, { - status: response.status, - headers, - }); + return await forward(request); } // no TLS support here @@ -54,10 +63,7 @@ beforeAll(() => { // simple http proxy if (request.url.startsWith("http://")) { - return await fetch(request.url, { - method: request.method, - body: await request.text(), - }); + return await forward(request); } // no TLS support here @@ -80,6 +86,7 @@ afterAll(() => { server.stop(true); proxy.stop(true); auth_proxy.stop(true); + restoreProxyEnv(savedProxyEnv); }); const test = process.env.PROXY_URL ? it : it.skip; @@ -116,38 +123,39 @@ describe.concurrent(() => { expect(result.data).toBe(data); }); - describe("proxy non-TLS", async () => { + describe("proxy non-TLS", () => { let url; let auth_proxy_url; let proxy_url; - const requests = [ - () => [new Request(url), auth_proxy_url], - () => [ + const requests = { + "GET Request through auth proxy": () => [new Request(url), auth_proxy_url], + "POST Request through auth proxy": () => [ new Request(url, { method: "POST", body: "Hello, World", }), auth_proxy_url, ], - () => [url, auth_proxy_url], - () => [new Request(url), proxy_url], - () => [ + "GET url through auth proxy": () => [url, auth_proxy_url], + "GET Request through proxy": () => [new Request(url), proxy_url], + "POST Request through proxy": () => [ new Request(url, { method: "POST", body: "Hello, World", }), proxy_url, ], - () => [url, proxy_url], - ]; + "GET url through proxy": () => [url, proxy_url], + }; beforeAll(() => { url = `http://localhost:${server.port}`; auth_proxy_url = `http://squid_user:ASD123%40123asd@localhost:${auth_proxy.port}`; proxy_url = `localhost:${proxy.port}`; }); - for (let callback of requests) { - test(async () => { + // These only need the in-process servers, so they aren't gated on PROXY_URL. + for (const [name, callback] of Object.entries(requests)) { + it(name, async () => { const [request, proxy] = callback(); gc(); const response = await fetch(request, { verbose: true, proxy }); @@ -155,6 +163,7 @@ describe.concurrent(() => { const text = await response.text(); gc(); expect(text).toBe("Hello, World"); + expect(response.headers.get("x-proxy-used")).toBe("1"); }); } }); @@ -231,6 +240,7 @@ describe.concurrent(() => { cmd: [bunExe(), "-e", `await fetch("${localServer.url}")`], env: { ...bunEnv, + ...proxyFreeEnv, http_proxy: http_proxy, https_proxy: https_proxy, }, @@ -259,6 +269,7 @@ describe.concurrent(() => { cmd: [bunExe(), "-e", `fetch("http://localhost:1").catch(() => {})`], env: { ...bunEnv, + ...proxyFreeEnv, http_proxy: "http://127.0.0.1:1", NO_PROXY: no_proxy, }, @@ -287,6 +298,7 @@ describe.concurrent(() => { ], env: { ...bunEnv, + ...proxyFreeEnv, http_proxy: `http://localhost:${proxy.port}`, NO_PROXY: `localhost:${server.port}`, }, @@ -318,6 +330,7 @@ describe.concurrent(() => { ], env: { ...bunEnv, + ...proxyFreeEnv, http_proxy: `http://localhost:${proxy.port}`, NO_PROXY: `localhost:${differentPort}`, }, @@ -348,6 +361,7 @@ describe.concurrent(() => { ], env: { ...bunEnv, + ...proxyFreeEnv, http_proxy: `http://localhost:${proxy.port}`, NO_PROXY: `localhost`, }, @@ -379,6 +393,7 @@ describe.concurrent(() => { ], env: { ...bunEnv, + ...proxyFreeEnv, http_proxy: `http://localhost:${proxy.port}`, NO_PROXY: `example.com, localhost:${differentPort}, localhost:${server.port}`, }, diff --git a/test/js/bun/http/proxy.test.ts b/test/js/bun/http/proxy.test.ts index 3090ca41588c..e7eac3553bcf 100644 --- a/test/js/bun/http/proxy.test.ts +++ b/test/js/bun/http/proxy.test.ts @@ -252,6 +252,37 @@ test.concurrent("POST of a Bun.file() large enough for sendfile through a TLS pr expect({ stdout, stderr, exitCode }).toEqual({ stdout: "200 true\n", stderr: "", exitCode: 0 }); }); +test.concurrent("re-sends a Bun.file() body through a CONNECT tunnel when the origin redirects to https", async () => { + const content = Buffer.alloc(2_000_000, "abcdefgh"); + using dir = tempDir("proxy-sendfile-redirect", { "body.bin": content }); + using origin = Bun.serve({ + port: 0, + async fetch(req) { + await req.arrayBuffer(); + return new Response(null, { status: 307, headers: { Location: `https://127.0.0.1:${httpsServer.port}/` } }); + }, + }); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const file = Bun.file("body.bin"); + const res = await fetch("http://127.0.0.1:${origin.port}/", { method: "POST", body: file, tls: { ca: ${JSON.stringify(tlsCert.cert)} } }); + console.log(res.status, res.url, (await res.text()) === (await file.text()));`, + ], + env: { ...bunEnv, ...proxyFreeEnv, http_proxy: httpProxyServer.url, https_proxy: httpProxyServer.url }, + 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: `200 https://127.0.0.1:${httpsServer.port}/ true\n`, + stderr: "", + exitCode: 0, + }); +}); + for (const server_tls of [false, true]) { describe.concurrent(`proxy can handle redirects with ${server_tls ? "TLS" : "non-TLS"} server`, () => { test("with empty body #12007", async () => { diff --git a/test/js/bun/http/req-url-leak-fixture.js b/test/js/bun/http/req-url-leak-fixture.js index 83e4c5e4110c..d5d9b2247a31 100644 --- a/test/js/bun/http/req-url-leak-fixture.js +++ b/test/js/bun/http/req-url-leak-fixture.js @@ -1,8 +1,16 @@ const rss = process.memoryUsage.rss; let existingPromise = null; const server = Bun.serve({ + hostname: "127.0.0.1", port: 0, async fetch(req) { + // Routed by method so the GET handler never touches req.url. + if (req.method === "POST") { + Bun.gc(true); + Bun.shrink(); + await new Promise(resolve => setImmediate(resolve)); + return new Response(rss().toString()); + } if (!existingPromise) { existingPromise = Bun.sleep(0); } @@ -11,17 +19,8 @@ const server = Bun.serve({ if (existingPromise === waitedUpon) { existingPromise = null; } - return new Response(rss().toString()); + return new Response("ok"); }, }); -setInterval(() => { - console.log("RSS", (rss() / 1024 / 1024) | 0); -}, 1000); -console.log("Server started on", server.url.href); - -if (process.channel) { - process.send({ - url: server.url.href, - }); -} +process.send?.({ url: server.url.href }); diff --git a/test/js/bun/http/req-url-leak.test.ts b/test/js/bun/http/req-url-leak.test.ts index a104a1480553..b136568f59f1 100644 --- a/test/js/bun/http/req-url-leak.test.ts +++ b/test/js/bun/http/req-url-leak.test.ts @@ -1,57 +1,51 @@ import { expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug } from "harness"; import path from "path"; + test( "req.url doesn't leak memory", async () => { - const { promise, resolve } = Promise.withResolvers(); - await using process = Bun.spawn({ + const { promise, resolve, reject } = Promise.withResolvers(); + await using proc = Bun.spawn({ cmd: [bunExe(), path.join(import.meta.dir, "req-url-leak-fixture.js")], - env: bunEnv, + env: { + ...bunEnv, + // Free eagerly under ASAN so RSS tracks live memory. + ASAN_OPTIONS: [ + bunEnv.ASAN_OPTIONS, + "quarantine_size_mb=0", + "thread_local_quarantine_size_kb=0", + "allocator_release_to_os_interval_ms=0", + ] + .filter(Boolean) + .join(":"), + }, stdout: "inherit", stderr: "inherit", - stdin: "inherit", - ipc(message, child) { - if (message.url) { - resolve(message.url); - } + stdin: "ignore", + ipc(message) { + if (message.url) resolve(message.url); }, }); + proc.exited.then(code => reject(new Error(`fixture exited (${code}) before sending its URL`))); const baseURL = await promise; - const url = new URL(Buffer.alloc(1024 * 15, "Z").toString(), baseURL); + const sampleRSS = async () => Number(await (await fetch(baseURL, { method: "POST" })).text()); - let maxRSS = 0; - - for (let i = 0; i < 256; i++) { - const batchSize = 64; - const promises: Promise[] = []; - for (let j = 0; j < batchSize; j++) { - promises.push( - fetch(url) - .then(r => r.text()) - .then(rssText => { - const rss = parseFloat(rssText); - if (Number.isSafeInteger(rss)) { - maxRSS = Math.max(maxRSS, rss); - } - }), - ); + // 6 samples, 512 requests apart. Growth is measured from the second sample, once the allocator has + // warmed up. A leaked 15 KB url per request grows RSS by 30 MB over those 2048 requests. + const samples: number[] = []; + for (let i = 0; i < 6; i++) { + for (let batch = 0; batch < 8; batch++) { + await Promise.all(Array.from({ length: 64 }, () => fetch(url).then(res => res.text()))); } - await Promise.all(promises); + samples.push(await sampleRSS()); } - console.log("Max RSS", (maxRSS / 1024 / 1024) | 0, "MB"); - - // 297 MB on Bun 1.2 - // 44 MB on Bun 1.3 - // ASAN's quarantine + shadow memory raise the absolute RSS floor; widen there. - expect(maxRSS).toBeLessThan(1024 * 1024 * (isASAN ? 450 : 150)); - // Same calibration for the deadline: the 16K fetches that finish in ~3s on a - // release build run ~10x slower under ASAN's instrumentation (observed - // ~12-13s), and the run used to die on this timeout with the RSS assertion - // itself passing. + const growthMB = (samples.at(-1)! - samples[1]) / 1024 / 1024; + console.log("RSS samples (MB)", samples.map(sample => (sample / 1024 / 1024) | 0).join(" ")); + expect(growthMB).toBeLessThan(10); }, - isASAN ? 90_000 : 10_000, + isASAN || isDebug ? 90_000 : 10_000, ); diff --git a/test/js/bun/http/request-constructor-leak-fixture.js b/test/js/bun/http/request-constructor-leak-fixture.js index 09d0a2cbfe6f..18617ed2aa17 100644 --- a/test/js/bun/http/request-constructor-leak-fixture.js +++ b/test/js/bun/http/request-constructor-leak-fixture.js @@ -5,17 +5,19 @@ // - the url leaks // const rss = process.memoryUsage.rss; -const buf = new Uint8Array(1024 * 1024 * 16); +// The constructor copies the body before the `signal` coercion throws. Retaining 2 MiB * 1000 is twice the threshold. +const buf = new Uint8Array(2 * 1024 * 1024); +const longHost = Buffer.alloc(35 * 1024, "superduperlongurlwowsuchlengthicant").toString(); +const longContentType = Buffer.alloc(64 * 1024, "yo de lay ").toString(); for (var i = 0; i < 1000; i++) { try { - new Request("http://" + "superduperlongurlwowsuchlengthicant".repeat(1024) + ".com/" + i, { + new Request("http://" + longHost + ".com/" + i, { body: buf, signal: Symbol("leaky-error"), headers: { // That means the string needs to be long enough to otherwise show up with a 0-length body. - ["Content-Type"]: - "yo de lay yo de lay yo de lay yo de lay yo de lay yo de lay ".repeat(1024) + Math.random(), + ["Content-Type"]: longContentType + Math.random(), "Invalid-Header-Name-☺️": "1", }, }); diff --git a/test/js/bun/http/request-smuggling.test.ts b/test/js/bun/http/request-smuggling.test.ts index ff488341438a..6f5c83417b54 100644 --- a/test/js/bun/http/request-smuggling.test.ts +++ b/test/js/bun/http/request-smuggling.test.ts @@ -81,6 +81,40 @@ test("rejects Transfer-Encoding with chunked not last", async () => { }); }); +test.each(["chunked,", "chunked, ", "chunked ,,"])( + "rejects a comma after chunked in Transfer-Encoding (%j)", + async te => { + await using server = Bun.serve({ + port: 0, + fetch(req) { + return new Response("OK"); + }, + }); + + const client = net.connect(server.port!, "127.0.0.1"); + + const maliciousRequest = [ + "POST / HTTP/1.1", + "Host: localhost", + `Transfer-Encoding: ${te}`, + "", + "1", + "A", + "0", + "", + "", + ].join("\r\n"); + + const response = await new Promise((resolve, reject) => { + client.on("error", reject); + client.on("data", data => resolve(data.toString())); + client.write(maliciousRequest); + }); + client.destroy(); + expect(response).toStartWith("HTTP/1.1 400"); + }, +); + test("rejects duplicate chunked in Transfer-Encoding", async () => { await using server = Bun.serve({ port: 0, @@ -894,11 +928,11 @@ describe("SPILL.TERM - invalid chunk terminators", () => { }); }); - // TE.TE desync: the last-chunk "0\r\n" must be followed by a strict "\r\n" - // (end-of-body). Consuming arbitrary bytes there lets an attacker smuggle a - // second request while an upstream proxy parses the same bytes as a valid - // trailer line. See https://github.com/oven-sh/bun/issues/29732. - test("rejects arbitrary bytes in place of final CRLF after zero-chunk", async () => { + // TE.TE desync (https://github.com/oven-sh/bun/issues/29732): skipping two + // unchecked bytes after "0\r\n" would drop "X:" and run the rest as a second + // request. Those bytes are the first request's trailer section (RFC 9112 + // 7.1.2), which ends at an empty line. + test("doesn't dispatch a request line inside a chunked body's trailer section", async () => { const urls: string[] = []; await using server = Bun.serve({ port: 0, @@ -913,41 +947,41 @@ describe("SPILL.TERM - invalid chunk terminators", () => { const client = net.connect(server.port!, "127.0.0.1"); - // The bytes "X:" replace the required "\r\n" after the zero-chunk. - // A vulnerable server consumes "X:" as the terminator and then parses - // "POST /admin HTTP/1.1" as a second (smuggled) request. + // The first request can't send "Connection: close", because Bun drops + // everything after such a request and the test would pass for the wrong + // reason. "/after" is the control: it shows that a request right after the + // trailer section does run. const smuggleAttempt = "POST / HTTP/1.1\r\n" + "Host: localhost\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n" + "0\r\n" + - "X:POST /admin HTTP/1.1\r\n" + + "X:GET /admin HTTP/1.1\r\n" + "Host: localhost\r\n" + - "Content-Length: 5\r\n" + "\r\n" + - "admin"; + "GET /after HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "Connection: close\r\n" + + "\r\n"; - await new Promise((resolve, reject) => { - let responseData = ""; + const responseData = await new Promise((resolve, reject) => { + let data = ""; client.on("error", reject); - client.on("data", data => { - responseData += data.toString(); - }); - client.on("close", () => { - expect(responseData).toContain("HTTP/1.1 400"); - // The smuggled second request must never reach the handler. - expect(urls).not.toContain("/admin"); - // No ADMIN-ACCESS body should have been produced. - expect(responseData).not.toContain("ADMIN-ACCESS"); - resolve(); + client.on("data", chunk => { + data += chunk.toString(); }); + client.on("close", () => resolve(data)); client.write(smuggleAttempt); }); + + expect({ urls, adminBody: responseData.includes("ADMIN-ACCESS") }).toEqual({ + urls: ["/", "/after"], + adminBody: false, + }); }); - test("rejects zero-chunk terminator with wrong first byte", async () => { - // First byte of the terminator must be '\r'. Anything else must be rejected. + test("rejects a bare LF in the trailer section", async () => { await using server = Bun.serve({ port: 0, fetch() { @@ -958,7 +992,7 @@ describe("SPILL.TERM - invalid chunk terminators", () => { const client = net.connect(server.port!, "127.0.0.1"); const maliciousRequest = - "POST / HTTP/1.1\r\n" + "Host: localhost\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n" + "0\r\n" + "A\n"; // 'A' instead of '\r' + "POST / HTTP/1.1\r\n" + "Host: localhost\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n" + "0\r\n" + "A\n"; await new Promise((resolve, reject) => { let responseData = ""; @@ -974,8 +1008,7 @@ describe("SPILL.TERM - invalid chunk terminators", () => { }); }); - test("rejects zero-chunk terminator with CR but wrong second byte", async () => { - // Second byte of the terminator must be '\n'. Anything else must be rejected. + test("rejects a CR without LF in the trailer section", async () => { await using server = Bun.serve({ port: 0, fetch() { @@ -986,7 +1019,7 @@ describe("SPILL.TERM - invalid chunk terminators", () => { const client = net.connect(server.port!, "127.0.0.1"); const maliciousRequest = - "POST / HTTP/1.1\r\n" + "Host: localhost\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n" + "0\r\n" + "\rA"; // '\r' followed by 'A' instead of '\n' + "POST / HTTP/1.1\r\n" + "Host: localhost\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n" + "0\r\n" + "\rA"; await new Promise((resolve, reject) => { let responseData = ""; @@ -1046,14 +1079,8 @@ describe("SPILL.TERM - invalid chunk terminators", () => { test("accepts zero-chunk with correct CRLF terminator split across segments", async () => { // Control for the fragmentation test above: a valid split must still work. // - // Tricky bit: the parser emits the FIN chunk as soon as it sees the - // zero-chunk size line (`0\r\n`) — before the terminator bytes are - // consumed. That means the server can respond to request 1 based on just - // the first segment. To prove the `\n` in the second segment *does* get - // validated (rather than being ignored or causing an error), we pipeline - // a second request after it: if the drop-trailer loop rejected the `\n` - // as malformed, the state would go to STATE_IS_ERROR, the connection - // would be torn down, and the pipelined GET would never complete. + // The pipelined GET only completes if the parser accepted the `\n` in the + // second segment. const seen: string[] = []; await using server = Bun.serve({ port: 0, @@ -1783,6 +1810,65 @@ describe("Host header field values in request.url", () => { expect(response.slice(response.indexOf("\r\n\r\n") + 4)).toBe("/index"); }); + // RFC 9112 3.2. req.url would use the first line and the Host header would join them all. + test.each([ + ["two Host lines", "GET /index HTTP/1.1\r\nHost: a.test\r\nHost: b.test\r\nConnection: close\r\n\r\n"], + ["two identical Host lines", "GET /index HTTP/1.1\r\nHost: a.test\r\nHost: a.test\r\nConnection: close\r\n\r\n"], + [ + "three Host lines in mixed case", + "GET /index HTTP/1.1\r\nHost: a.test\r\nHOST: b.test\r\nhost: c.test\r\nConnection: close\r\n\r\n", + ], + ["two Host lines on HTTP/1.0", "GET /index HTTP/1.0\r\nHost: a.test\r\nHost: b.test\r\n\r\n"], + [ + "two Host lines on a static route", + "GET /static HTTP/1.1\r\nHost: a.test\r\nHost: b.test\r\nConnection: close\r\n\r\n", + ], + [ + "two Host lines on a WebSocket upgrade", + "GET /index HTTP/1.1\r\nHost: a.test\r\nHost: b.test\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n", + ], + ])("rejects a request with %s", async (_name, payload) => { + const urls: string[] = []; + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + routes: { "/static": new Response("static") }, + fetch(req, server) { + urls.push(req.url); + if (server.upgrade(req)) return; + return new Response("OK"); + }, + websocket: { + message() {}, + }, + }); + + // The valid request ahead of it is still answered. + const response = await sendRawRequest(server, "GET /first HTTP/1.1\r\nHost: a.test\r\n\r\n" + payload); + expect({ statuses: response.match(/HTTP\/1\.1 \d+/g), urls }).toEqual({ + statuses: ["HTTP/1.1 200", "HTTP/1.1 400"], + urls: ["http://a.test/first"], + }); + }); + + test("node:http keeps the first of two Host lines", async () => { + const server = createServer((req, res) => { + res.end(String(req.headers.host)); + }); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const response = await sendRawRequest( + server.address() as { port: number }, + "GET / HTTP/1.1\r\nHost: a.test\r\nHost: b.test\r\nConnection: close\r\n\r\n", + ); + expect(response).toContain("HTTP/1.1 200"); + expect(response.slice(response.indexOf("\r\n\r\n") + 4)).toBe("a.test"); + } finally { + server.close(); + } + }); + // Only node:http relaxes the Host requirement, because Node emits 'upgrade' // and 'connect' before it checks for Host. Bun.serve has no such events, so a // request without Host would reach fetch() with a relative req.url. diff --git a/test/js/bun/http/response-constructor-leak-fixture.js b/test/js/bun/http/response-constructor-leak-fixture.js index f7f69803a1aa..cfdd85ea7f33 100644 --- a/test/js/bun/http/response-constructor-leak-fixture.js +++ b/test/js/bun/http/response-constructor-leak-fixture.js @@ -6,6 +6,7 @@ const rss = process.memoryUsage.rss; const buf = new Uint8Array(1024 * 1024 * 32); +const longContentType = Buffer.alloc(64 * 1024, "yo de lay ").toString(); for (var i = 0; i < 1000; i++) { try { @@ -16,8 +17,7 @@ for (var i = 0; i < 1000; i++) { status: 200, headers: { // That means the string needs to be long enough to otherwise show up with a 0-length body. - ["Content-Type"]: - "yo de lay yo de lay yo de lay yo de lay yo de lay yo de lay ".repeat(1024) + Math.random(), + ["Content-Type"]: longContentType + Math.random(), }, }); } catch (e) {} diff --git a/test/js/bun/http/serve-413-streaming-late-resolve-fixture.ts b/test/js/bun/http/serve-413-streaming-late-resolve-fixture.ts index e239fb8fcc92..496e30a6c673 100644 --- a/test/js/bun/http/serve-413-streaming-late-resolve-fixture.ts +++ b/test/js/bun/http/serve-413-streaming-late-resolve-fixture.ts @@ -1,131 +1,89 @@ -import { connect } from "node:net"; - // A chunked (no Content-Length) POST that exceeds maxRequestBodySize hits the -// streaming 413 in onBufferedBodyChunk, not the up-front server.zig check. -// That path previously wrote the 413 directly on the raw uWS response without -// detaching ctx.resp or releasing the base ref — uWS markDone() nulls -// onAborted, so when the socket closed no abort fired. If the fetch handler's -// Promise then settled, handleResolve()/handleReject() dereferenced the -// already-freed response (heap-use-after-free under ASAN) and the -// RequestContext leaked. +// streaming 413 in on_buffered_body_chunk, not the up-front Content-Length +// check. uWS markDone() clears onAborted, so no abort fires when the socket +// closes. The 413 itself has to detach ctx.resp and release the base ref, or a +// late handleResolve()/handleReject() dereferences the freed response +// (heap-use-after-free under ASAN). +import { connect } from "node:net"; -async function sendChunkedOverflow(port: number) { +// Sends the overflowing POST and resolves with everything the server wrote once +// it closes the connection. uSockets frees a closed socket at the end of the +// loop iteration that closed it, and the client sees the close in a later one, +// so the uWS response is already freed. +async function sendChunkedOverflow(port: number): Promise { const sock = connect(port, "127.0.0.1"); await new Promise((resolve, reject) => { sock.on("connect", resolve); sock.on("error", reject); }); + // The server closes with part of the body unread, so a reset is expected. + sock.removeAllListeners("error"); + sock.on("error", () => {}); - sock.write( - "POST / HTTP/1.1\r\n" + // - `Host: 127.0.0.1:${port}\r\n` + - "Transfer-Encoding: chunked\r\n" + - "\r\n", - ); + let received = ""; + const { promise: closed, resolve: onClose } = Promise.withResolvers(); + sock.on("data", d => (received += d.toString("latin1"))); + sock.on("close", () => onClose(received)); + + sock.write("POST / HTTP/1.1\r\n" + `Host: 127.0.0.1:${port}\r\n` + "Transfer-Encoding: chunked\r\n" + "\r\n"); const chunk = Buffer.alloc(2048, "A"); sock.write(chunk.length.toString(16) + "\r\n"); sock.write(chunk); sock.write("\r\n0\r\n\r\n"); - - let received = ""; - const { promise, resolve: done } = Promise.withResolvers(); - sock.on("data", d => { - received += d.toString(); - if (received.includes("\r\n\r\n")) done(); - }); - sock.on("close", () => done()); - await promise; - sock.destroy(); - return received.split("\r\n")[0]; -} - -async function waitForPending(server: ReturnType, n: number) { - for (let i = 0; i < 100 && server.pendingRequests !== n; i++) { - Bun.gc(true); - await Bun.sleep(10); - } - return server.pendingRequests; + return closed; } -// --- resolve path --- -{ - let capturedResolve: ((r: Response) => void) | undefined; - let bodyErr = ""; +for (const settle of ["resolve", "reject"] as const) { + let settleLate: (() => void) | undefined; + let handlerPromise: Promise | undefined; + let bodyError = ""; + let errorCalls = 0; const server = Bun.serve({ port: 0, idleTimeout: 0, maxRequestBodySize: 1024, fetch(req) { if (req.method !== "POST") return new Response("follow-up"); - req.text().catch(e => (bodyErr = String(e?.message ?? e))); - return new Promise(resolve => { - capturedResolve = resolve; - }); - }, - }); - - const status = await sendChunkedOverflow(Number(server.port)); - // Let the closed socket's memory be reclaimed before the late resolve so a - // stale ctx.resp is a real dangling pointer, not just a done-but-live one. - await waitForPending(server, 1); - await Bun.sleep(10); - - // handleResolve must observe isAbortedOrEnded() and drop the Response; it - // must not cork/write on the freed uWS socket. - capturedResolve!(new Response("late")); - capturedResolve = undefined; - const pending = await waitForPending(server, 0); - - // A follow-up request must still work and must not see the stale "late". - const ok = await fetch(server.url, { method: "GET" }); - const okText = await ok.text(); - - console.log( - JSON.stringify({ - case: "resolve", - status, - bodyErr, - pendingAfterResolve: pending, - followUp: { status: ok.status, text: okText }, - }), - ); - server.stop(true); -} - -// --- reject path --- -{ - let capturedReject: ((e: unknown) => void) | undefined; - const server = Bun.serve({ - port: 0, - idleTimeout: 0, - maxRequestBodySize: 1024, - fetch(req) { - req.text().catch(() => {}); - return new Promise((_resolve, reject) => { - capturedReject = reject; + req.text().catch(e => (bodyError = (e as Error).message)); + handlerPromise = new Promise((resolve, reject) => { + settleLate = + settle === "resolve" ? () => resolve(new Response("late")) : () => reject(new Error("late reject")); }); + return handlerPromise; }, error() { - // A late reject after the 413 should be a no-op for this request; the - // error handler only renders when it can still respond. + errorCalls++; return new Response("error-handler", { status: 500 }); }, }); - const status = await sendChunkedOverflow(Number(server.port)); - await waitForPending(server, 1); - await Bun.sleep(10); + const response = await sendChunkedOverflow(Number(server.port)); + // The 413 tears the context down before its bytes reach the client. + const pendingAfter413 = server.pendingRequests; - capturedReject!(new Error("late reject")); - capturedReject = undefined; - const pending = await waitForPending(server, 0); + // handleResolve/handleReject must not render, call error() or touch the freed + // uWS response. The native reaction was attached first, so awaiting the + // handler promise orders the checks after it. + settleLate!(); + const outcome = await handlerPromise!.then( + () => "resolved", + (e: Error) => `rejected: ${e.message}`, + ); + settleLate = undefined; + // A request on a fresh connection must not see the stale "late" response. + const followUp = await fetch(server.url); console.log( JSON.stringify({ - case: "reject", - status, - pendingAfterReject: pending, + settle, + response, + bodyError, + pendingAfter413, + outcome, + pendingAfterSettle: server.pendingRequests, + errorCalls, + followUp: [followUp.status, await followUp.text()], }), ); - server.stop(true); + await server.stop(); } diff --git a/test/js/bun/http/serve-async-stream-client-abort-fixture.ts b/test/js/bun/http/serve-async-stream-client-abort-fixture.ts index ba2e8f068d0b..b24ab2b339fb 100644 --- a/test/js/bun/http/serve-async-stream-client-abort-fixture.ts +++ b/test/js/bun/http/serve-async-stream-client-abort-fixture.ts @@ -7,10 +7,37 @@ // so the socket-close abort callback then fired with the *sink* pointer // instead of the RequestContext it was registered with — a type-confused // read that segfaulted (ASAN: heap-buffer-overflow in onAbort). +// +// The sink only uses tryEnd() before the response head is sent, and the server +// sends the head of a stream that is still pending after the handler's +// microtasks. So the requests alternate between two bodies: +// most of them: pull() waits for a task, then ends. The stream is pending +// when the abort arrives, and the sink ends a chunked body +// on the dying socket. +// every tenth: pull() ends a body too big for the socket, then waits for a +// task. Its tryEnd() is waiting for the drain when the abort +// arrives. + +const large = new Uint8Array(8 * 1024 * 1024); +let requests = 0; const server = Bun.serve({ port: 0, fetch() { + if (++requests % 10 === 0) { + return new Response( + new ReadableStream({ + type: "direct", + async pull(controller: any) { + // Above the payload, so write() only buffers and end() sends it all. + controller.start({ highWaterMark: large.length + 1 }); + controller.write(large); + controller.end(); + await Bun.sleep(0); + }, + } as any), + ); + } return new Response( new ReadableStream({ async pull(controller) { diff --git a/test/js/bun/http/serve-body-leak.test.ts b/test/js/bun/http/serve-body-leak.test.ts index 32e1f27f7e1a..7bc05a55c125 100644 --- a/test/js/bun/http/serve-body-leak.test.ts +++ b/test/js/bun/http/serve-body-leak.test.ts @@ -1,153 +1,118 @@ import type { Subprocess } from "bun"; import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, isASAN, isDebug } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, tls } from "harness"; import { join } from "path"; const payload = Buffer.alloc(512 * 1024, "1").toString("utf-8"); // decent size payload to test memory leak -const batchSize = 40; -// A leaked 512 KB body × totalCount would grow RSS by gigabytes; the assertions -// below compare against O(100 MB), so the slower ASAN/debug lanes keep the same -// margin with fewer iterations (each request is ~2-10× slower there). -const baseCount = isASAN || isDebug ? 3_000 : 10_000; -// The HTTP/2 pass repeats every scenario; 40% keeps the file inside its CI -// budget while a leaked 512 KB body per request would still be gigabytes. -let totalCount = baseCount; const zeroCopyPayload = new Blob([payload]); const zeroCopyJSONPayload = new Blob([JSON.stringify({ bun: payload })]); -let fetchOptions: { protocol?: "http2"; tls?: { rejectUnauthorized: boolean } } = {}; -const fetch = (url: string | URL, init: RequestInit = {}) => globalThis.fetch(url, { ...fetchOptions, ...init }); - -async function getMemoryUsage(url: URL): Promise { - return (await fetch(`${url.origin}/report`).then(res => res.json())) as number; -} - -async function warmup(url: URL) { - var remaining = totalCount; - - while (remaining > 0) { - const batch = new Array(batchSize); - for (let j = 0; j < batchSize; j++) { - // warmup the server with streaming requests, because is the most memory intensive - batch[j] = fetch(`${url.origin}/streaming`, { - method: "POST", - body: zeroCopyPayload, - }).then(res => res.text()); - } - await Promise.all(batch); - remaining -= batchSize; - } - // clean up memory before first test - await getMemoryUsage(url); -} - -async function callBuffering(url: URL) { - const result = await fetch(`${url.origin}/buffering`, { - method: "POST", - body: zeroCopyPayload, - }).then(res => res.text()); - expect(result).toBe("Ok"); -} -async function callJSONBuffering(url: URL) { - const result = await fetch(`${url.origin}/json-buffering`, { - method: "POST", +const concurrency = 40; +// Each scenario samples the fixture's RSS at `checkpoints` evenly spaced points and measures growth +// from the first one, so growth covers the last 3/4 of the requests. A retained 512 KB body per +// request grows RSS by 360 MB on release and 120 MB on ASAN/debug. +const requestCount = isASAN || isDebug ? 320 : 960; +const checkpoints = 4; +// Per route. Enough to JIT the handlers and grow the heap to its steady state. +const warmupCount = 80; +// Leak-free samples stay within a few MB. A debug build without ASAN links JSC against libpas, so +// Bun.shrink() can't purge mimalloc's freed pages and a sample can include a batch of freed bodies. +const maxGrowthMB = isDebug && !isASAN ? 64 : 32; +// The release fixture sits near 50 MB, the debug one near 110 MB and the ASAN one near 400 MB. +const maxRssMB = isASAN ? 512 : 256; + +type Scenario = { name: string; path: string; body: Blob; expected: string }; +const scenarios: Scenario[] = [ + { name: "#10265 should not leak memory when ignoring the body", path: "/", body: zeroCopyPayload, expected: "Ok" }, + { name: "should not leak memory when buffering the body", path: "/buffering", body: zeroCopyPayload, expected: "Ok" }, + { + name: "should not leak memory when buffering a JSON body", + path: "/json-buffering", body: zeroCopyJSONPayload, - }).then(res => res.text()); - expect(result).toBe("Ok"); -} - -async function callBufferingBodyGetter(url: URL) { - const result = await fetch(`${url.origin}/buffering+body-getter`, { - method: "POST", + expected: "Ok", + }, + { + name: "should not leak memory when buffering the body and accessing req.body", + path: "/buffering+body-getter", body: zeroCopyPayload, - }).then(res => res.text()); - expect(result).toBe("Ok"); -} -async function callStreaming(url: URL) { - const result = await fetch(`${url.origin}/streaming`, { - method: "POST", + expected: "Ok", + }, + { name: "should not leak memory when streaming the body", path: "/streaming", body: zeroCopyPayload, expected: "Ok" }, + { + name: "should not leak memory when streaming the body incompletely", + path: "/incomplete-streaming", body: zeroCopyPayload, - }).then(res => res.text()); - expect(result).toBe("Ok"); -} -async function callIncompleteStreaming(url: URL) { - const result = await fetch(`${url.origin}/incomplete-streaming`, { - method: "POST", + expected: "Ok", + }, + { + name: "should not leak memory when streaming the body and echoing it back", + path: "/streaming-echo", body: zeroCopyPayload, - }).then(res => res.text()); - expect(result).toBe("Ok"); -} -async function callStreamingEcho(url: URL) { - const result = await fetch(`${url.origin}/streaming-echo`, { - method: "POST", - body: zeroCopyPayload, - }).then(res => res.text()); - expect(result).toBe(payload); -} -async function callIgnore(url: URL) { - const result = await fetch(url, { - method: "POST", - body: zeroCopyPayload, - }).then(res => res.text()); - expect(result).toBe("Ok"); -} + expected: payload, + }, +]; -async function calculateMemoryLeak(fn: (url: URL) => Promise, url: URL) { - const start_memory = await getMemoryUsage(url); - const memory_examples: Array = []; - let peak_memory = start_memory; +type Target = { url: URL; fetchInit: BunFetchRequestInit }; - var remaining = totalCount; - while (remaining > 0) { - const batch = new Array(batchSize); - for (let j = 0; j < batchSize; j++) { - batch[j] = fn(url); - } - await Promise.all(batch); - remaining -= batchSize; +async function getMemoryUsage({ url, fetchInit }: Target): Promise { + const res = await fetch(new URL("/report", url), fetchInit); + expect(res.status).toBe(200); + return (await res.json()) as number; +} - // garbage collect and check memory usage every 1000 requests - if (remaining > 0 && remaining % 1000 === 0) { - const report = await getMemoryUsage(url); - if (report > peak_memory) { - peak_memory = report; - } - memory_examples.push(report); +async function sendRequests({ url, fetchInit }: Target, { path, body, expected }: Scenario, count: number) { + for (let remaining = count; remaining > 0; remaining -= concurrency) { + const batch = new Array(Math.min(concurrency, remaining)); + for (let i = 0; i < batch.length; i++) { + batch[i] = fetch(new URL(path, url), { ...fetchInit, method: "POST", body }).then(async res => { + const text = await res.text(); + expect(res.status).toBe(200); + expect(text.length).toBe(expected.length); + expect(text).toBe(expected); + }); } + await Promise.all(batch); } +} + +const toMB = (bytes: number) => Math.round(bytes / 1024 / 1024); - // wait for the last memory usage to be stable - const end_memory = await getMemoryUsage(url); - if (end_memory > peak_memory) { - peak_memory = end_memory; +async function measureMemoryGrowth(target: Target, scenario: Scenario, count: number) { + const startMB = toMB(await getMemoryUsage(target)); + const samplesMB: number[] = []; + for (let i = 0; i < checkpoints; i++) { + await sendRequests(target, scenario, count / checkpoints); + samplesMB.push(toMB(await getMemoryUsage(target))); } - // use first example as a reference if is a memory leak this should keep increasing and not be stable - const consumption = end_memory - memory_examples[0]; - // memory leak in MB - const leak = Math.floor(consumption > 0 ? consumption / 1024 / 1024 : 0); - return { leak, start_memory, peak_memory, end_memory, memory_examples }; + // Using the first checkpoint as the baseline ignores one-time growth while the scenario ramps up. + const growthMB = Math.max(...samplesMB.slice(1)) - samplesMB[0]; + return { growthMB, startMB, endMB: samplesMB[samplesMB.length - 1], samplesMB }; } -// Since the payload size is 512 KB -// If it was leaking the body, the memory usage would be at least 512 KB * totalCount = multiple GB -// If it ends up around 280 MB, it's probably not leaking the body. -// -// One fixture subprocess serves every scenario below: spawning a fresh one per -// test (and re-running the 10k-request warmup each time) was the dominant cost -// on ASAN. Sequential reuse keeps the RSS assertions meaningful because a real -// body leak compounds across scenarios instead of being hidden by a restart. +// One fixture subprocess serves every scenario: a real body leak compounds across them +// instead of being hidden by a restart, and the warmup runs once. describe.each([false, true])("request body leak (http2: %p)", http2 => { let fixture: Subprocess; - let url: URL; + let target: Target; beforeAll(async () => { - fetchOptions = http2 ? { protocol: "http2", tls: { rejectUnauthorized: false } } : {}; - totalCount = http2 ? baseCount * 0.4 : baseCount; const defer = Promise.withResolvers(); fixture = Bun.spawn( [bunExe(), "--smol", join(import.meta.dirname, "body-leak-test-fixture.ts"), ...(http2 ? ["--http2"] : [])], { - env: bunEnv, + env: { + ...bunEnv, + // ASAN keeps up to 256 MB of freed blocks in quarantine and returns free pages to the OS at + // most every 5 s. Free eagerly so RSS tracks live memory. + ASAN_OPTIONS: [ + bunEnv.ASAN_OPTIONS, + "quarantine_size_mb=0", + "thread_local_quarantine_size_kb=0", + "allocator_release_to_os_interval_ms=0", + ] + .filter(Boolean) + .join(":"), + }, stdout: "inherit", stderr: "inherit", stdin: "ignore", @@ -157,8 +122,13 @@ describe.each([false, true])("request body leak (http2: %p)", http2 => { }, ); fixture.exited.then(code => defer.reject(new Error(`body-leak fixture exited (${code}) before sending its URL`))); - url = new URL(await defer.promise); - await warmup(url); + target = { + url: new URL(await defer.promise), + fetchInit: http2 ? { protocol: "http2", tls: { ca: tls.cert } } : {}, + }; + for (const scenario of scenarios) { + await sendRequests(target, scenario, warmupCount); + } }, 60_000); afterAll(async () => { @@ -166,38 +136,34 @@ describe.each([false, true])("request body leak (http2: %p)", http2 => { await fixture?.exited; }); - for (const test_info of [ - ["#10265 should not leak memory when ignoring the body", callIgnore, 64], - ["should not leak memory when buffering the body", callBuffering, 64], - ["should not leak memory when buffering a JSON body", callJSONBuffering, 64], - ["should not leak memory when buffering the body and accessing req.body", callBufferingBodyGetter, 64], - ["should not leak memory when streaming the body", callStreaming, 64], - ["should not leak memory when streaming the body incompletely", callIncompleteStreaming, 64], - ["should not leak memory when streaming the body and echoing it back", callStreamingEcho, 64], - ] as const) { - const [testName, fn, maxMemoryGrowth] = test_info; + for (const scenario of scenarios) { it( - testName, + scenario.name, async () => { // fail fast with the exit code instead of a ConnectionRefused cascade if a prior scenario crashed the fixture expect(fixture.exitCode ?? fixture.signalCode).toBeNull(); - const report = await calculateMemoryLeak(fn, url); - console.log(report); - // Samples are taken between batches, so up to one batch of in-flight bodies plus - // not-yet-purged garbage may be counted; a leaked 512 KB body per request would blow - // past this by an order of magnitude. - expect(report.peak_memory - report.start_memory).toBeLessThan(256 * 1024 * 1024); - // acceptable memory leak - expect(report.leak).toBeLessThanOrEqual(maxMemoryGrowth); - // ASAN quarantine + debug-assertions instrumentation inflate RSS; - // give the asan lane more headroom than a plain release build. The - // http2 variant also runs TLS, which adds ~200 MB of baseline under ASAN. - const ceilingMB = (isASAN ? 768 : 512) + (http2 ? 256 : 0); - expect(report.end_memory).toBeLessThanOrEqual(ceilingMB * 1024 * 1024); + const report = await measureMemoryGrowth(target, scenario, requestCount); + console.log(scenario.path, report); + expect(report.growthMB).toBeLessThanOrEqual(maxGrowthMB); + expect(report.endMB).toBeLessThanOrEqual(maxRssMB); }, - isDebug || isASAN ? 60_000 : 40_000, + isDebug || isASAN ? 60_000 : 30_000, ); } + + // Positive control. /retain keeps every body, so the same measurement has to flag it: 360 bodies + // (180 MB) after the first checkpoint. Runs last because they stay resident until the fixture exits. + it( + "detects a retained body", + async () => { + expect(fixture.exitCode ?? fixture.signalCode).toBeNull(); + const scenario = { name: "retain", path: "/retain", body: zeroCopyPayload, expected: "Ok" }; + const report = await measureMemoryGrowth(target, scenario, 480); + console.log(scenario.path, report); + expect(report.growthMB).toBeGreaterThan(maxGrowthMB); + }, + isDebug || isASAN ? 60_000 : 30_000, + ); }); // A client disconnecting while a direct response stream is suspended inside pull() must not 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 b200dbcd6613..892b54d8b0d3 100644 --- a/test/js/bun/http/serve-direct-readable-stream.test.ts +++ b/test/js/bun/http/serve-direct-readable-stream.test.ts @@ -831,14 +831,20 @@ describe("a pull() that outlives its own end() does not hold the request", () => // tick make a single tryEnd() of the whole body. The client is not reading, // so most of it stays unsent and end() parks on the drain. The request then // waits for that drain, and only for it. - describe("end() parked on transport backpressure", () => { + // + // If pull() is still pending after the handler's microtasks, the server sends + // the head with Transfer-Encoding: chunked. A later end() doesn't wait for the + // drain: the body is one chunk, uWS buffers what the client hasn't read, and + // the request is released right away. + describe("end() under transport backpressure", () => { const payload = Buffer.alloc(32 * 1024 * 1024, "x"); + const chunkFraming = `${payload.length.toString(16)}\r\n`.length + "\r\n0\r\n\r\n".length; // Windows takes the first write of a response whole, whatever its size // (250 MB to a client that is not reading), so one tryEnd() never parks. test.skipIf(isWindows).each([ - ["in the first tick of pull()", false], - ["from a later task", true], + ["waits for the drain when called in the first tick of pull()", false], + ["doesn't wait for the drain when called from a later task, after the head was sent", true], ] as const)("%s", async (_when, fromLaterTask) => { const ended = Promise.withResolvers(); await using server = Bun.serve({ @@ -862,9 +868,11 @@ describe("a pull() that outlives its own end() does not hold the request", () => }); const socket = net.connect(server.port!, "127.0.0.1"); - const received = Promise.withResolvers(); + const received = Promise.withResolvers<{ chunked: boolean; bodyBytes: number }>(); let head = ""; + let chunked = false; let bodyBytes = -1; + let tail = ""; socket.on("error", received.reject); socket.on("close", () => received.reject(new Error("closed before the whole body arrived"))); socket.on("data", chunk => { @@ -872,11 +880,14 @@ describe("a pull() that outlives its own end() does not hold the request", () => head += chunk.toString("latin1"); const headEnd = head.indexOf("\r\n\r\n"); if (headEnd < 0) return; + chunked = /^transfer-encoding: chunked$/im.test(head.slice(0, headEnd)); bodyBytes = Buffer.byteLength(head, "latin1") - (headEnd + 4); + tail = head.slice(headEnd + 4).slice(-5); } else { bodyBytes += chunk.length; + tail = (tail + chunk.subarray(-5).toString("latin1")).slice(-5); } - if (bodyBytes >= payload.length) received.resolve(bodyBytes); + if (chunked ? tail === "0\r\n\r\n" : bodyBytes >= payload.length) received.resolve({ chunked, bodyBytes }); }); socket.on("connect", () => { socket.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); @@ -887,14 +898,19 @@ describe("a pull() that outlives its own end() does not hold the request", () => const parked = await ended.promise; const pendingWhileParked = server.pendingRequests; socket.resume(); - const length = await received.promise; + const body = await received.promise; // The drain that completed the response and the release of its request share one event loop turn; the client sees the last bytes in a later one. - expect({ parked, pendingWhileParked, length, pendingRequests: server.pendingRequests }).toEqual({ - parked: true, - pendingWhileParked: 1, - length: payload.length, - pendingRequests: 0, - }); + expect({ parked, pendingWhileParked, ...body, pendingRequests: server.pendingRequests }).toEqual( + fromLaterTask + ? { + parked: false, + pendingWhileParked: 0, + chunked: true, + bodyBytes: payload.length + chunkFraming, + pendingRequests: 0, + } + : { parked: true, pendingWhileParked: 1, chunked: false, bodyBytes: payload.length, pendingRequests: 0 }, + ); } finally { socket.destroy(); } @@ -2285,12 +2301,13 @@ describe("direct stream edge cases over Bun.serve", () => { const finished = Promise.withResolvers(); using server = Bun.serve({ port: 0, - fetch: () => + fetch: req => new Response( (async function* () { try { yield "first"; - for (let i = 0; i < 50; i++) { + // No turn count is enough. macOS delivers loopback packets from another kernel thread. + while (!req.signal.aborted) { await later(); yield "more"; } diff --git a/test/js/bun/http/serve-directory-routes.test.ts b/test/js/bun/http/serve-directory-routes.test.ts index a95c64cf9989..d34443fb5049 100644 --- a/test/js/bun/http/serve-directory-routes.test.ts +++ b/test/js/bun/http/serve-directory-routes.test.ts @@ -516,6 +516,25 @@ describe("Bun.serve() directory routes", () => { expect(exitCode).toBe(0); }); + it("serves dotfiles and files inside dot-directories", async () => { + using dir = tempDir("serve-dir-dotfiles", { + "public/.env": "KEY=value", + "public/.git/config": "[core]", + }); + + server = serve({ + port: 0, + routes: { "/static/*": { dir: join(String(dir), "public") } }, + fetch: () => new Response("fallback", { status: 404 }), + }); + + const env = await fetch(`${server.url}static/.env`); + expect({ status: env.status, body: await env.text() }).toEqual({ status: 200, body: "KEY=value" }); + + const config = await fetch(`${server.url}static/.git/config`); + expect({ status: config.status, body: await config.text() }).toEqual({ status: 200, body: "[core]" }); + }); + it("percent-decodes file names", async () => { using dir = tempDir("serve-dir-pct", { "public/hello world.txt": "hi", diff --git a/test/js/bun/http/serve-epoll-add-fail.test.ts b/test/js/bun/http/serve-epoll-add-fail.test.ts index e98cb43140a1..0b18bbab214f 100644 --- a/test/js/bun/http/serve-epoll-add-fail.test.ts +++ b/test/js/bun/http/serve-epoll-add-fail.test.ts @@ -1,6 +1,7 @@ // An LD_PRELOAD shim makes epoll_ctl(EPOLL_CTL_ADD) fail with ENOSPC (what the // kernel returns when fs.epoll.max_user_watches is exhausted) so Bun.serve / // Bun.listen must throw and accepted connections must be closed, not parked. +// The shim also makes getaddrinfo() fail for two fixed names. import { afterAll, beforeAll, expect, test } from "bun:test"; import { bunEnv, bunExe, isLinux, tempDir } from "harness"; import net from "node:net"; @@ -15,6 +16,7 @@ const SHIM_C = /* c */ ` #define _GNU_SOURCE #include #include +#include #include #include #include @@ -46,6 +48,41 @@ int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event) { } return real_epoll_ctl(epfd, op, fd, event); } + +int getaddrinfo(const char *node, const char *service, const struct addrinfo *hints, struct addrinfo **res) { + static int (*real)(const char *, const char *, const struct addrinfo *, struct addrinfo **); + if (!real) real = (int (*)(const char *, const char *, const struct addrinfo *, struct addrinfo **)) dlsym(RTLD_NEXT, "getaddrinfo"); + if (node && strcmp(node, "eai-noname.test") == 0) return EAI_NONAME; + if (node && strcmp(node, "eai-again.test") == 0) return EAI_AGAIN; + return real(node, service, hints, res); +} +`; + +const DNS_FIXTURE = /* js */ ` +const shape = ({ code, syscall, hostname, message }) => ({ code, syscall, hostname, message }); +const out = {}; +for (const hostname of ["eai-noname.test", "eai-again.test"]) { + try { + Bun.serve({ hostname, port: 0, fetch: () => new Response() }).stop(true); + out["Bun.serve " + hostname] = "listening"; + } catch (e) { + out["Bun.serve " + hostname] = shape(e); + } + try { + Bun.listen({ hostname, port: 0, socket: { data() {} } }).stop(true); + out["Bun.listen " + hostname] = "listening"; + } catch (e) { + out["Bun.listen " + hostname] = shape(e); + } + for (const name of ["http", "net"]) { + out[name + " " + hostname] = await new Promise(resolve => { + const server = require("node:" + name).createServer(); + server.on("error", e => resolve(shape(e))); + server.listen(0, hostname, () => server.close(() => resolve("listening"))); + }); + } +} +console.log(JSON.stringify(out)); `; const SERVE_FIXTURE = /* js */ ` @@ -147,6 +184,7 @@ beforeAll(async () => { "fetch.js": FETCH_FIXTURE, "connect.js": CONNECT_FIXTURE, "udp.js": UDP_FIXTURE, + "dns.js": DNS_FIXTURE, }); shimPath = join(String(dir), "shim.so"); await using ccProc = Bun.spawn({ @@ -208,7 +246,7 @@ test.concurrent.skipIf(!isLinux || !cc)( const result = JSON.parse(line); expect(result).toEqual({ ok: false, - errno: 28, // ENOSPC + errno: -28, // ENOSPC code: "ENOSPC", syscall: "listen", message: expect.any(String), @@ -332,3 +370,37 @@ test.concurrent.skipIf(!isLinux || !cc)( expect(exitCode).toBe(0); }, ); + +test.concurrent.skipIf(!isLinux || !cc)( + "listening on a hostname that doesn't resolve throws the getaddrinfo error", + async () => { + // Every listen fails before its epoll_ctl(), so the mode doesn't matter. + const { stdout, stderr, exitCode } = await runWithShim("dns.js", "udp"); + const notFound = { + code: "ENOTFOUND", + syscall: "getaddrinfo", + hostname: "eai-noname.test", + message: "getaddrinfo ENOTFOUND eai-noname.test", + }; + const again = { + code: "EAI_AGAIN", + syscall: "getaddrinfo", + hostname: "eai-again.test", + message: "getaddrinfo EAI_AGAIN eai-again.test", + }; + expect({ out: JSON.parse(stdout || "null"), stderr }).toEqual({ + out: { + "Bun.serve eai-noname.test": notFound, + "Bun.listen eai-noname.test": notFound, + "http eai-noname.test": notFound, + "net eai-noname.test": notFound, + "Bun.serve eai-again.test": again, + "Bun.listen eai-again.test": again, + "http eai-again.test": again, + "net eai-again.test": again, + }, + stderr: "", + }); + expect(exitCode).toBe(0); + }, +); diff --git a/test/js/bun/http/serve-http2-fixture.ts b/test/js/bun/http/serve-http2-fixture.ts index ed26d0143dc3..83e6198b6011 100644 --- a/test/js/bun/http/serve-http2-fixture.ts +++ b/test/js/bun/http/serve-http2-fixture.ts @@ -274,12 +274,15 @@ async function handler(req: Request, server: Bun.Server): Promise { - server.stop(); + server.stop().then(() => exit && process.exit(0)); stopCalled.resolve(); }, 0); return new Response("stopping"); + } // Starts its body at once and ends it after /stop: a client that has the response has a stream open across the stop. case "/open-until-stop": return new Response(async function* () { diff --git a/test/js/bun/http/serve-http2-helpers.ts b/test/js/bun/http/serve-http2-helpers.ts index f9b9e869db2f..e8dc71682350 100644 --- a/test/js/bun/http/serve-http2-helpers.ts +++ b/test/js/bun/http/serve-http2-helpers.ts @@ -228,18 +228,25 @@ export class RawH2 { closed = false; static async connect(port: number, secure: boolean, opts: { sendPreface?: boolean; settings?: Buffer } = {}) { - const c = new RawH2(); + let socket!: net.Socket | tls.TLSSocket; await new Promise((resolve, reject) => { const onErr = (e: Error) => reject(e); if (secure) { - c.socket = tls.connect({ port, host: "127.0.0.1", ALPNProtocols: ["h2"], rejectUnauthorized: false }, () => + socket = tls.connect({ port, host: "127.0.0.1", ALPNProtocols: ["h2"], rejectUnauthorized: false }, () => resolve(), ); } else { - c.socket = net.connect({ port, host: "127.0.0.1" }, () => resolve()); + socket = net.connect({ port, host: "127.0.0.1" }, () => resolve()); } - c.socket.once("error", onErr); + socket.once("error", onErr); }); + return RawH2.over(socket, opts); + } + + /** Speaks HTTP/2 over a socket that's already connected (and, for TLS, past its handshake). */ + static over(socket: net.Socket | tls.TLSSocket, opts: { sendPreface?: boolean; settings?: Buffer } = {}) { + const c = new RawH2(); + c.socket = socket; c.socket.on("data", (d: Buffer) => c.onData(d)); c.socket.on("close", () => { c.closed = true; diff --git a/test/js/bun/http/serve-http2-lifecycle.test.ts b/test/js/bun/http/serve-http2-lifecycle.test.ts index ab5c3d603f51..0ce1460a0283 100644 --- a/test/js/bun/http/serve-http2-lifecycle.test.ts +++ b/test/js/bun/http/serve-http2-lifecycle.test.ts @@ -1,10 +1,15 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; +import { once } from "node:events"; import http2 from "node:http2"; +import net from "node:net"; +import { Duplex } from "node:stream"; import tls from "node:tls"; import { F, + Fixture, H2Result, + PREFACE, RawH2, T, baseHeaders, @@ -126,6 +131,111 @@ describe("Bun.serve http2 lifecycle", () => { raw.close(); }); + // Such a connection is still an HTTP/1 socket when stop() sends GOAWAY to the HTTP/2 connections. + describe("graceful stop: a connection that becomes HTTP/2 after stop() serves one stream, then closes", () => { + async function stopThenBecomeH2(fx: Fixture, secure: boolean, becomeH2: () => Promise) { + const session = await connectH2(fx.port, secure); + const sessionClosed = once(session, "close"); + expect((await request(session, { ":path": "/stop?exit" })).body.toString()).toBe("stopping"); + // stop() closes this drained session, so once it's closed stop() has run. + await sessionClosed; + + const raw = await becomeH2(); + const get = (id: number) => + frame(T.HEADERS, F.END_HEADERS | F.END_STREAM, id, hpackLiteral(baseHeaders("/hello"))); + // One write, so the server has read both requests before it can close. + raw.write(Buffer.concat([get(1), get(3)])); + expect((await raw.body(1)).toString()).toBe("hello"); + await raw.waitForClose(); + expect({ + goaways: raw.frames + .filter(f => f.type === T.GOAWAY) + .map(f => ({ lastStreamId: f.payload.readUInt32BE(0), code: f.payload.readUInt32BE(4) })), + answeredStream3: raw.frames.some(f => f.streamId === 3), + }).toEqual({ goaways: [{ lastStreamId: 1, code: 0 }], answeredStream3: false }); + // The fixture exits when stop() resolves. + expect(await fx.proc.exited).toBe(0); + } + + test("TLS handshake pending", async () => { + await using fx = await startFixture({ tls: true }); + const tcp = net.connect({ port: fx.port, host: "127.0.0.1" }); + await once(tcp, "connect"); + // The ClientHello goes out, but the server's reply is held back so the handshake can't finish yet. + let hold = true; + const held: Buffer[] = []; + const wire = new Duplex({ read() {}, write: (chunk, _encoding, cb) => void tcp.write(chunk, cb) }); + tcp.on("data", d => void (hold ? held.push(d) : wire.push(d))); + tcp.on("close", () => wire.push(null)); + const socket = tls.connect({ socket: wire, ALPNProtocols: ["h2"], rejectUnauthorized: false }); + socket.on("error", () => {}); + await once(tcp, "data"); + + await stopThenBecomeH2(fx, true, async () => { + const secured = once(socket, "secureConnect"); + hold = false; + for (const d of held) wire.push(d); + await secured; + return RawH2.over(socket); + }); + }); + + test("cleartext preface pending", async () => { + await using fx = await startFixture({ tls: false }); + const tcp = net.connect({ port: fx.port, host: "127.0.0.1" }); + await once(tcp, "connect"); + // Too short to tell HTTP/2 from HTTP/1, so the server buffers these bytes and waits for more. + tcp.write(PREFACE.subarray(0, 3)); + + await stopThenBecomeH2(fx, false, async () => { + const raw = RawH2.over(tcp, { sendPreface: false }); + raw.write(PREFACE.subarray(3)); + raw.write(frame(T.SETTINGS, 0, 0)); + return raw; + }); + }); + }); + + // close(2) resets the connection when another frame arrives, and the reset drops what the kernel hasn't delivered. + describe.each([ + ["h2c", false], + ["TLS", true], + ] as const)( + "graceful stop over %s delivers the whole response to a client that keeps sending frames", + (_, secure) => { + test.each([ + ["it's already in the kernel's send buffer", "/fixed?n=1048576", 1024 * 1024], + ["the server is still writing it", "/big", 5 * 1024 * 1024], + ])("when %s", async (_name, path, size) => { + await using fx = await startFixture({ tls: secure }); + // Windows wide enough that flow control never holds the response back. + const raw = await RawH2.connect(fx.port, secure, { settings: Buffer.from([0, 4, 0x40, 0, 0, 0]) }); + raw.write(frame(T.WINDOW_UPDATE, 0, 0, Buffer.from([0x40, 0, 0, 0]))); + raw.headers(1, baseHeaders(path)); + await raw.waitFor(f => f.type === T.HEADERS && f.streamId === 1); + // The client stops reading, so the response backs up in the kernel and then in the server. + raw.socket.pause(); + + const session = await connectH2(fx.port, secure); + const sessionClosed = once(session, "close"); + expect((await request(session, { ":path": "/stop?exit" })).body.toString()).toBe("stopping"); + await sessionClosed; + + // Like a client that sends WINDOW_UPDATE while it downloads. + raw.socket.on("data", () => { + if (!raw.closed && raw.socket.writable) raw.write(frame(T.PING, 0, 0, Buffer.from("8 bytes!"))); + }); + raw.write(frame(T.PING, 0, 0, Buffer.from("8 bytes!"))); + raw.socket.resume(); + expect((await raw.body(1)).length).toBe(size); + expect(await raw.goaway()).toEqual({ lastStreamId: 1, code: 0 }); + await raw.waitForClose(); + // The fixture exits when stop() resolves. + expect(await fx.proc.exited).toBe(0); + }); + }, + ); + test("a paused (slowly read) request body does not idle out the connection", async () => { // usockets ticks timeouts in 4 s steps, so this needs real seconds. await using fx = await startFixture({ tls: false, idleTimeout: 2 }); diff --git a/test/js/bun/http/serve-http2.test.ts b/test/js/bun/http/serve-http2.test.ts index 0a9613f62fff..428c0990495a 100644 --- a/test/js/bun/http/serve-http2.test.ts +++ b/test/js/bun/http/serve-http2.test.ts @@ -1,6 +1,7 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { runSocketTimeoutSweepSoon, setSocketOptions } from "bun:internal-for-testing"; +import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; import { randomBytes } from "crypto"; -import { bunEnv, bunExe, tempDir, tls as tlsCert } from "harness"; +import { bunEnv, bunExe, isLinux, sweepSocketTimeouts, tempDir, tls as tlsCert } from "harness"; import http2 from "node:http2"; import net from "node:net"; import { join } from "node:path"; @@ -406,10 +407,10 @@ for (const secure of [true, false]) { for (const r of results) expect(sha256(r.body)).toBe(expected); }, 30000); // 40 MB through 64 KB client windows; slow on debug/ASAN - test("expect: 100-continue gets an interim response", async () => { + test.each(["100-continue", "100-Continue"])("expect: %s gets an interim response", async expectation => { const raw = await RawH2.connect(fx.port, secure); await raw.waitFor(f => f.type === T.SETTINGS); - raw.headers(1, [...baseHeaders("/echo", "POST"), ["expect", "100-continue"]], F.END_HEADERS); + raw.headers(1, [...baseHeaders("/echo", "POST"), ["expect", expectation]], F.END_HEADERS); const interim = await raw.waitFor(f => f.type === T.HEADERS && f.streamId === 1); expect(decodeStatus(interim.payload)).toBe(100); expect(interim.flags & F.END_STREAM).toBe(0); @@ -970,4 +971,105 @@ describe("Bun.serve http2 in-process", () => { pendingRequests: 0, }); }); + + // allowHalfOpen keeps the client's side open after the server's close_notify, so the client never answers it. + describe("a TLS client that never answers close_notify", () => { + async function pendingRequest(idleTimeout: number) { + const started = Promise.withResolvers(); + const aborted = Promise.withResolvers(); + const server = Bun.serve({ + port: 0, + tls: tlsCert, + http2: true, + idleTimeout, + fetch(req) { + req.signal.addEventListener("abort", () => aborted.resolve()); + started.resolve(); + return new Promise(() => {}); + }, + }); + const raw = await RawH2.connect(server.port!, true); + raw.socket.allowHalfOpen = true; + raw.socket.once("close", () => started.reject(new Error("closed before the request reached the handler"))); + raw.headers(1, baseHeaders("/")); + await started.promise; + return { server, raw, aborted: aborted.promise }; + } + + test("stop(true) aborts the open stream and resolves", async () => { + const { server, raw, aborted } = await pendingRequest(0); + try { + await Promise.all([server.stop(true), aborted]); + } finally { + raw.close(); + } + }); + + test("the idle timeout aborts the open stream", async () => { + const { server, raw, aborted } = await pendingRequest(1); + try { + runSocketTimeoutSweepSoon(); + await aborted; + } finally { + raw.close(); + await server.stop(true); + } + }); + }); + + // Linux only: macOS sends to a reader that freed less than a segment of its window only at the next persist probe, seconds later. + test.skipIf(!isLinux).each([ + ["cleartext", false], + ["TLS", true], + ] as const)("a slow reader stays connected until it stalls (%s)", async (_name, secure) => { + const onAbort = mock(); + await using server = Bun.serve({ + port: 0, + tls: secure ? tlsCert : undefined, + http2: true, + // Two sweeps, so each timeout comes a whole read after the timer was armed. + idleTimeout: 5, + fetch(req) { + req.signal.addEventListener("abort", onAbort); + return new Response(new Uint8Array(16 * 1024 * 1024)); + }, + }); + // Both flow-control windows are wide open, so only the socket holds the response back. + const maxWindow = 0x7fffffff; + const streamWindow = Buffer.alloc(6); + streamWindow.writeUInt16BE(0x4 /* SETTINGS_INITIAL_WINDOW_SIZE */, 0); + streamWindow.writeUInt32BE(maxWindow, 2); + const connectionWindow = Buffer.alloc(4); + connectionWindow.writeUInt32BE(maxWindow - 65535); + const raw = await RawH2.connect(server.port!, secure, { settings: streamWindow }); + // A small receive buffer makes every read open the window. The reads stay too small for a writable event on the server. + setSocketOptions(raw.socket, 2 /* SO_RCVBUF */, 128 * 1024); + let received = 0; + let wake = Promise.withResolvers(); + raw.socket.on("close", () => wake.resolve()); + raw.socket.on("data", chunk => { + received += chunk.length; + wake.resolve(); + }); + try { + raw.write(frame(T.WINDOW_UPDATE, 0, 0, connectionWindow)); + raw.headers(1, baseHeaders("/")); + for (let i = 0; i < 8; i++) { + const target = received + 256 * 1024; + raw.socket.resume(); + while (received < target && !raw.closed) { + wake = Promise.withResolvers(); + await wake.promise; + } + raw.socket.pause(); + await sweepSocketTimeouts(); + } + expect(onAbort).not.toHaveBeenCalled(); + + for (let i = 0; i < 8 && onAbort.mock.calls.length === 0; i++) await sweepSocketTimeouts(); + expect(onAbort).toHaveBeenCalledTimes(1); + } finally { + raw.close(); + } + }); }); diff --git a/test/js/bun/http/serve-http3.test.ts b/test/js/bun/http/serve-http3.test.ts index 185f4650e325..59c548919600 100644 --- a/test/js/bun/http/serve-http3.test.ts +++ b/test/js/bun/http/serve-http3.test.ts @@ -1,5 +1,6 @@ import type { ServerWebSocket } from "bun"; -import { describe, expect, test } from "bun:test"; +import { socketFaultInjection } from "bun:internal-for-testing"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { createHash, createPrivateKey, randomBytes, X509Certificate } from "crypto"; import { readFileSync } from "fs"; import { bunEnv, bunExe, isASAN, tempDir, tls } from "harness"; @@ -24,209 +25,229 @@ const fetchH3 = (port: number, path: string, init: RequestInit = {}) => // a live loopback server answers in well under this even on the ASAN lane. const DEAD_PORT_ABORT_MS = 1000; -// Every fixture server in this file is torn down on stdin close so the client's +// Every spawned fixture in this file is torn down on stdin close so the client's // pooled session sees CONNECTION_CLOSE and is dropped; otherwise a killed // process leaves the client retransmitting until lsquic's idle timeout, and if // the OS reuses that ephemeral UDP port a later test's request gets matched // onto the dead conn. us_quic_listen_socket_close() flushes CONNECTION_CLOSE -// synchronously during stop(true); the trailing setTimeout gives the kernel a -// tick to deliver the loopback datagram before the process exits. -const STOP_ON_STDIN_END = `process.stdin.on("end", () => { server.stop(true); setTimeout(() => process.exit(0), 100); });`; - -const fixture = ` -import { serve } from "bun"; - -const big = Buffer.alloc(512 * 1024, "abcdefghijklmnop"); - -const server = serve({ - port: 0, - tls: ${JSON.stringify(tls)}, - http3: true, - http1: process.env.H3_ONLY !== "1", - routes: { - "/api/:id": req => new Response("id=" + req.params.id, { headers: { "x-route": "api" } }), - "/route-only": { POST: () => new Response("posted") }, - "/lifetime/:id": async req => { - const before = req.params.id; - await Bun.sleep(0); - return new Response(before + "|" + req.params.id); +// synchronously during stop(true), so the loopback datagram is already queued on +// the client's socket and the process can exit right away. +// +// The fixtures use Bun.stdin and Bun.stdout. The first access to process.stdin or +// process.stdout loads node's stream stack, which takes about a second per +// process on a debug build. +const STDIN_HELPERS = ` +async function readStdin(onChunk = () => {}) { + for await (const chunk of Bun.stdin.stream()) onChunk(new TextDecoder().decode(chunk)); +} +function stopOnStdinEnd(onChunk) { + readStdin(onChunk).then(() => { server.stop(true); process.exit(0); }); +} +`; +const STOP_ON_STDIN_END = `${STDIN_HELPERS}stopOnStdinEnd();`; + +const md5 = (b: Uint8Array | ArrayBuffer) => + createHash("md5") + .update(Buffer.from(b as Uint8Array)) + .digest("hex"); + +const BIG_BODY = Buffer.alloc(512 * 1024, "abcdefghijklmnop"); +const BIG_FILE_BYTES = Buffer.alloc(200 * 1024, "FILEfile"); +const HUGE_FILE_BYTES = Buffer.alloc(2 * 1024 * 1024, "0123456789abcdef"); + +/** In-process server with every route the request/response tests use. `dir` + * contains big.bin (BIG_FILE_BYTES) and huge.bin (HUGE_FILE_BYTES). */ +function serveFixture(dir: string, { http1 = true } = {}) { + const bigFile = join(dir, "big.bin"); + const hugeFile = join(dir, "huge.bin"); + return Bun.serve({ + port: 0, + tls, + http3: true, + http1, + routes: { + "/api/:id": req => new Response("id=" + req.params.id, { headers: { "x-route": "api" } }), + "/route-only": { POST: () => new Response("posted") }, + "/lifetime/:id": async req => { + const before = req.params.id; + await Bun.sleep(0); + return new Response(before + "|" + req.params.id); + }, + "/static": new Response("from-static-route", { + headers: { "content-type": "text/plain", etag: '"v1"' }, + }), + "/file-route": Bun.file(bigFile), + "/static-hop": new Response("hop", { + headers: { connection: "keep-alive", "keep-alive": "timeout=5", te: "gzip", "x-kept": "1" }, + }), + "/file-hop": new Response(Bun.file(bigFile), { headers: { connection: "close", upgrade: "x", "x-kept": "1" } }), }, - "/static": new Response("from-static-route", { - headers: { "content-type": "text/plain", etag: '"v1"' }, - }), - "/file-route": Bun.file(process.env.BIG_FILE), - "/static-hop": new Response("hop", { headers: { connection: "keep-alive", "keep-alive": "timeout=5", te: "gzip", "x-kept": "1" } }), - "/file-hop": new Response(Bun.file(process.env.BIG_FILE), { headers: { connection: "close", upgrade: "x", "x-kept": "1" } }), - }, - async fetch(req) { - const url = new URL(req.url); - if (url.pathname === "/hop-headers") { - return new Response("hi", { headers: { "transfer-encoding": "chunked", connection: "close", "keep-alive": "timeout=5", upgrade: "websocket", "proxy-connection": "x", te: "gzip", "x-kept": "1" } }); - } - if (url.pathname === "/hello") { - return new Response("hello over h3", { - headers: { "x-proto": "h3", "content-type": "text/plain" }, - }); - } - if (url.pathname === "/echo") { - const body = await req.text(); - return new Response(body, { - status: 201, - headers: { - "x-method": req.method, - "x-echo": req.headers.get("x-echo") ?? "", - "x-len": String(body.length), - }, - }); - } - if (url.pathname === "/echo-bytes") { - const body = await req.arrayBuffer(); - return new Response(body, { - status: 200, - headers: { "x-len": String(body.byteLength) }, - }); - } - if (url.pathname === "/transform") { - const body = new Uint8Array(await req.arrayBuffer()); - for (let i = 0; i < body.length; i++) body[i] = (body[i] + 1) & 0xff; - return new Response(body, { headers: { "x-len": String(body.length) } }); - } - if (url.pathname === "/lifetime") { - const mode = url.searchParams.get("d"); - const beforeUrl = req.url; - const beforeMethod = req.method; - const beforeHdr = req.headers.get("x-probe"); - if (mode === "micro") await Promise.resolve(); - else if (mode === "macro") await Bun.sleep(0); - else if (mode === "double") { await Promise.resolve(); await Bun.sleep(0); } - const afterUrl = req.url; - const afterMethod = req.method; - const afterHdr = req.headers.get("x-probe"); - const all = {}; - for (const [k, v] of req.headers) all[k] = v; - const body = await req.text(); - return Response.json({ - ok: beforeUrl === afterUrl && beforeMethod === afterMethod && beforeHdr === afterHdr, - url: afterUrl, method: afterMethod, probe: afterHdr, - headerCount: Object.keys(all).length, bodyLen: body.length, - }); - } - if (url.pathname === "/spawn") { - const p = Bun.spawn({ - cmd: [process.execPath, "-e", "for(let i=0;i<40;i++)process.stdout.write('x'.repeat(1000)+String.fromCharCode(10))"], - stdout: "pipe", - }); - return new Response(p.stdout, { headers: { "content-type": "text/plain" } }); - } - if (url.pathname === "/passthrough") { - return new Response(req.body, { status: 200, headers: { "x-passthrough": "1" } }); - } - if (url.pathname === "/file-stream") { - return new Response(Bun.file(process.env.BIG_FILE).stream()); - } - if (url.pathname === "/headers") { - const out = {}; - for (const [k, v] of req.headers) out[k] = v; - return Response.json(out); - } - if (url.pathname === "/big") { - return new Response(big, { headers: { "content-type": "application/octet-stream" } }); - } - if (url.pathname === "/status") { - return new Response(null, { status: 204 }); - } - if (url.pathname === "/query") { - return new Response(url.searchParams.get("q") ?? ""); - } - if (url.pathname === "/slow") { - await new Promise(r => setTimeout(r, 50)); - return new Response("late"); - } - if (url.pathname === "/stream") { - return new Response( - new ReadableStream({ - async start(ctrl) { - for (const c of ["one ", "two ", "three"]) { - ctrl.enqueue(new TextEncoder().encode(c)); - await new Promise(r => setTimeout(r, 5)); - } - ctrl.close(); + async fetch(req, server) { + const url = new URL(req.url); + if (url.pathname === "/hop-headers") { + return new Response("hi", { + headers: { + "transfer-encoding": "chunked", + connection: "close", + "keep-alive": "timeout=5", + upgrade: "websocket", + "proxy-connection": "x", + te: "gzip", + "x-kept": "1", }, - }), - { headers: { "content-type": "text/plain" } }, - ); - } - if (url.pathname === "/file") { - return new Response(Bun.file(process.env.BIG_FILE)); - } - if (url.pathname === "/huge-file") { - return new Response(Bun.file(process.env.HUGE_FILE)); - } - if (url.pathname === "/remote") { - return Response.json(server.requestIP(req)); - } - return new Response("not found: " + url.pathname, { status: 404 }); - }, -}); - -console.error("PORT=" + server.port); -process.stdin.on("data", () => {}); -${STOP_ON_STDIN_END} -`; - -async function withServer( - fn: (port: number, dir: string) => Promise, - env: Record = {}, -): Promise { - using dir = tempDir("serve-http3", { - "server.mjs": fixture, - "big.bin": Buffer.alloc(200 * 1024, "FILEfile"), - "huge.bin": Buffer.alloc(2 * 1024 * 1024, "0123456789abcdef"), + }); + } + if (url.pathname === "/hello") { + return new Response("hello over h3", { + headers: { "x-proto": "h3", "content-type": "text/plain" }, + }); + } + if (url.pathname === "/echo") { + const body = await req.text(); + return new Response(body, { + status: 201, + headers: { + "x-method": req.method, + "x-echo": req.headers.get("x-echo") ?? "", + "x-len": String(body.length), + }, + }); + } + if (url.pathname === "/echo-bytes") { + const body = await req.arrayBuffer(); + return new Response(body, { + status: 200, + headers: { "x-len": String(body.byteLength) }, + }); + } + if (url.pathname === "/transform") { + const body = new Uint8Array(await req.arrayBuffer()); + for (let i = 0; i < body.length; i++) body[i] = (body[i] + 1) & 0xff; + return new Response(body, { headers: { "x-len": String(body.length) } }); + } + if (url.pathname === "/lifetime") { + const mode = url.searchParams.get("d"); + const beforeUrl = req.url; + const beforeMethod = req.method; + const beforeHdr = req.headers.get("x-probe"); + if (mode === "micro") await Promise.resolve(); + else if (mode === "macro") await Bun.sleep(0); + else if (mode === "double") { + await Promise.resolve(); + await Bun.sleep(0); + } + const afterUrl = req.url; + const afterMethod = req.method; + const afterHdr = req.headers.get("x-probe"); + const all: Record = {}; + for (const [k, v] of req.headers) all[k] = v; + const body = await req.text(); + return Response.json({ + ok: beforeUrl === afterUrl && beforeMethod === afterMethod && beforeHdr === afterHdr, + url: afterUrl, + method: afterMethod, + probe: afterHdr, + headerCount: Object.keys(all).length, + bodyLen: body.length, + }); + } + if (url.pathname === "/spawn") { + const p = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + "const line=Buffer.alloc(1000,'x').toString()+String.fromCharCode(10);for(let i=0;i<40;i++)await Bun.write(Bun.stdout,line)", + ], + env: bunEnv, + stdout: "pipe", + }); + return new Response(p.stdout, { headers: { "content-type": "text/plain" } }); + } + if (url.pathname === "/passthrough") { + return new Response(req.body, { status: 200, headers: { "x-passthrough": "1" } }); + } + if (url.pathname === "/file-stream") { + return new Response(Bun.file(bigFile).stream()); + } + if (url.pathname === "/headers") { + const out: Record = {}; + for (const [k, v] of req.headers) out[k] = v; + return Response.json(out); + } + if (url.pathname === "/big") { + return new Response(BIG_BODY, { headers: { "content-type": "application/octet-stream" } }); + } + if (url.pathname === "/status") { + return new Response(null, { status: 204 }); + } + if (url.pathname === "/query") { + return new Response(url.searchParams.get("q") ?? ""); + } + if (url.pathname === "/slow") { + // Long enough that the client-abort test's 10 ms timer always fires first. + await new Promise(r => setTimeout(r, 500)); + return new Response("late"); + } + if (url.pathname === "/stream") { + return new Response( + new ReadableStream({ + async start(ctrl) { + for (const c of ["one ", "two ", "three"]) { + ctrl.enqueue(new TextEncoder().encode(c)); + await new Promise(r => setTimeout(r, 5)); + } + ctrl.close(); + }, + }), + { headers: { "content-type": "text/plain" } }, + ); + } + if (url.pathname === "/file") { + return new Response(Bun.file(bigFile)); + } + if (url.pathname === "/huge-file") { + return new Response(Bun.file(hugeFile)); + } + if (url.pathname === "/remote") { + return Response.json(server.requestIP(req)); + } + return new Response("not found: " + url.pathname, { status: 404 }); + }, }); - const proc = Bun.spawn({ - cmd: [bunExe(), "server.mjs"], - cwd: String(dir), - env: { ...bunEnv, ...env, BIG_FILE: join(String(dir), "big.bin"), HUGE_FILE: join(String(dir), "huge.bin") }, - stdout: "inherit", - stderr: "pipe", - stdin: "pipe", +} + +// The request/response tests share one server for the whole file. +let fixtureDir: ReturnType | undefined; +let fixture: ReturnType | undefined; +beforeAll(() => { + fixtureDir = tempDir("serve-http3", { + "big.bin": BIG_FILE_BYTES, + "huge.bin": HUGE_FILE_BYTES, }); - let port = 0; - const stderr = proc.stderr.getReader(); - let buffered = ""; - while (true) { - const { value, done } = await stderr.read(); - if (done) break; - buffered += new TextDecoder().decode(value); - const m = buffered.match(/PORT=(\d+)/); - if (m) { - port = Number(m[1]); - break; - } - } - stderr.releaseLock(); - // drain remaining stderr in background so the pipe doesn't fill - (async () => { - for await (const _ of proc.stderr) { - } - })(); - expect(port).toBeGreaterThan(0); + fixture = serveFixture(String(fixtureDir)); +}); +afterAll(async () => { try { - await fn(port, String(dir)); + await fixture?.stop(true); } finally { - proc.stdin?.end(); - await Promise.race([proc.exited, Bun.sleep(2000)]); - proc.kill(); - await proc.exited; + fixtureDir?.[Symbol.dispose](); } +}); + +async function withServer(fn: (port: number, dir: string) => Promise): Promise { + await fn(fixture!.port!, String(fixtureDir!)); } -describe("Bun.serve HTTP/3", () => { +// A test that probes a dead port is test.serial so no other fixture can bind +// that port meanwhile. A test that breaks the connection on purpose needs its +// own server, because the others multiplex over the same pooled connection. +describe.concurrent("Bun.serve HTTP/3", () => { test("basic GET", async () => { await withServer(async port => { const res = await fetchH3(port, "/hello"); expect(res.status).toBe(200); expect(res.headers.get("x-proto")).toBe("h3"); + expect(res.headers.get("content-type")).toBe("text/plain"); expect(await res.text()).toBe("hello over h3"); }); }); @@ -264,11 +285,13 @@ describe("Bun.serve HTTP/3", () => { test("large response body crosses multiple QUIC packets", async () => { await withServer(async port => { - const raw = await fetchH3(port, "/big").then(r => r.bytes()); + const res = await fetchH3(port, "/big"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/octet-stream"); + expect(res.headers.get("content-length")).toBe("524288"); + const raw = await res.bytes(); expect(raw.length).toBe(512 * 1024); - // verify content integrity at both ends - expect(new TextDecoder().decode(raw.subarray(0, 16))).toBe("abcdefghijklmnop"); - expect(new TextDecoder().decode(raw.subarray(-16))).toBe("abcdefghijklmnop"); + expect(md5(raw)).toBe(md5(BIG_BODY)); }); }); @@ -284,30 +307,33 @@ describe("Bun.serve HTTP/3", () => { test("client abort mid-response does not crash the server", async () => { await withServer(async port => { // First request: tiny timeout forces abort during /slow - await expect(fetchH3(port, "/slow", { signal: AbortSignal.timeout(10) })).rejects.toThrow(); + await expect(fetchH3(port, "/slow", { signal: AbortSignal.timeout(10) })).rejects.toMatchObject({ + name: "TimeoutError", + }); // Server must still be alive for a follow-up const ok = await fetchH3(port, "/hello"); + expect(ok.status).toBe(200); + expect(ok.headers.get("x-proto")).toBe("h3"); expect(await ok.text()).toBe("hello over h3"); }); }); - test("http1: false rejects HTTP/1.1 but accepts HTTP/3", async () => { - await withServer( - async port => { - const h3 = await fetchH3(port, "/hello"); - expect(await h3.text()).toBe("hello over h3"); - // TCP listener should not be bound at all - await expect( - fetch(`https://127.0.0.1:${port}/hello`, { tls: { rejectUnauthorized: false } } as RequestInit), - ).rejects.toThrow(); - }, - { H3_ONLY: "1" }, - ); + test.serial("http1: false rejects HTTP/1.1 but accepts HTTP/3", async () => { + await using server = serveFixture(String(fixtureDir!), { http1: false }); + const port = server.port!; + const h3 = await fetchH3(port, "/hello"); + expect(h3.status).toBe(200); + expect(h3.headers.get("x-proto")).toBe("h3"); + expect(await h3.text()).toBe("hello over h3"); + // TCP listener should not be bound at all + await expect( + fetch(`https://127.0.0.1:${port}/hello`, { tls: { rejectUnauthorized: false } } as RequestInit), + ).rejects.toMatchObject({ code: "ECONNREFUSED" }); }); // With http1:false the TCP listen socket is never created, so server.url / // server.address / server.stop() must consult the QUIC listener. - test("http1: false — url/address/stop see the QUIC listener", async () => { + test.serial("http1: false — url/address/stop see the QUIC listener", async () => { const script = ` const tls = ${JSON.stringify(tls)}; const server = Bun.serve({ @@ -318,7 +344,8 @@ describe("Bun.serve HTTP/3", () => { const url = new URL(server.url); console.error("URLPORT=" + url.port); console.error("ADDR=" + JSON.stringify(server.address)); - process.stdin.on("data", async () => { + ${STDIN_HELPERS} + readStdin(async () => { await server.stop(); console.error("STOPPED"); }); @@ -352,7 +379,6 @@ describe("Bun.serve HTTP/3", () => { }, }); console.error("PORT=" + server.port); - process.stdin.on("data", () => {}); ${STOP_ON_STDIN_END} `; await withCustomServer(script, async port => { @@ -370,6 +396,7 @@ describe("Bun.serve HTTP/3", () => { headers: { "content-type": "application/octet-stream" }, }); expect(res.status).toBe(413); + expect(await res.text()).toBe(""); }); }); @@ -393,9 +420,11 @@ describe("Bun.serve HTTP/3", () => { test("routes: per-method handler", async () => { await withServer(async port => { const post = await fetchH3(port, "/route-only", { method: "POST" }); + expect(post.status).toBe(200); expect(await post.text()).toBe("posted"); // GET falls through to fetch() since the route is POST-only const get = await fetchH3(port, "/route-only"); + expect(get.status).toBe(404); expect(await get.text()).toBe("not found: /route-only"); }); }); @@ -411,7 +440,6 @@ describe("Bun.serve HTTP/3", () => { fetch: req => new Response("from-fetch:" + req.method), }); console.error("PORT=" + server.port); - process.stdin.on("data", () => {}); ${STOP_ON_STDIN_END} `; await withCustomServer(script, async port => { @@ -423,16 +451,22 @@ describe("Bun.serve HTTP/3", () => { test("ReadableStream response body", async () => { await withServer(async port => { - expect(await fetchH3(port, "/stream").then(r => r.text())).toBe("one two three"); + const res = await fetchH3(port, "/stream"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("text/plain"); + expect(await res.text()).toBe("one two three"); }); }); test("Bun.file response body", async () => { await withServer(async port => { - const raw = await fetchH3(port, "/file").then(r => r.bytes()); + const res = await fetchH3(port, "/file"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/octet-stream"); + expect(res.headers.get("content-length")).toBe("204800"); + const raw = await res.bytes(); expect(raw.length).toBe(200 * 1024); - expect(new TextDecoder().decode(raw.subarray(0, 8))).toBe("FILEfile"); - expect(new TextDecoder().decode(raw.subarray(-8))).toBe("FILEfile"); + expect(md5(raw)).toBe(md5(BIG_FILE_BYTES)); }); }); @@ -468,20 +502,29 @@ describe("Bun.serve HTTP/3", () => { expect(res.status).toBe(200); expect(await res.text()).toBe("from-static-route"); expect(res.headers.get("etag")).toBe('"v1"'); + expect(res.headers.get("content-type")).toBe("text/plain"); // If-None-Match -> 304 over H3 const second = await fetchH3(port, "/static", { headers: { "if-none-match": '"v1"' } }); expect(second.status).toBe(304); + expect(second.headers.get("etag")).toBe('"v1"'); + expect(await second.text()).toBe(""); }); }); test("file route (Bun.file value) streams over H3", async () => { await withServer(async port => { - const raw = await fetchH3(port, "/file-route").then(r => r.bytes()); + const res = await fetchH3(port, "/file-route"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/octet-stream"); + expect(res.headers.get("content-length")).toBe("204800"); + const raw = await res.bytes(); expect(raw.length).toBe(200 * 1024); - expect(Buffer.from(raw.subarray(0, 8)).toString()).toBe("FILEfile"); + expect(md5(raw)).toBe(md5(BIG_FILE_BYTES)); // Range request over H3 hits the same FileResponseStream path const ranged = await fetchH3(port, "/file-route", { headers: { range: "bytes=4-11" } }); expect(ranged.status).toBe(206); + expect(ranged.headers.get("content-range")).toBe("bytes 4-11/204800"); + expect(ranged.headers.get("content-length")).toBe("8"); expect(await ranged.text()).toBe("file" + "FILE"); }); }); @@ -573,16 +616,51 @@ describe("Bun.serve HTTP/3", () => { expect(stderr).toContain("http3: true with a unix socket — HTTP/3 listener skipped"); expect(exitCode).toBe(0); }); + + test("closes the TCP listener when the UDP port is taken", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + // Retried in case the kernel-chosen UDP port is busy on TCP too. + for (let attempt = 0; ; attempt++) { + const udp = await Bun.udpSocket({ hostname: "127.0.0.1", port: 0 }); + const { port } = udp; + let error; + try { + Bun.serve({ port, hostname: "127.0.0.1", tls: ${JSON.stringify(tls)}, http3: true, fetch: () => new Response("x") }); + } catch (e) { + error = e; + } + if (error?.code === "EADDRINUSE" && attempt < 5) { + udp.close(); + continue; + } + using tcp = Bun.serve({ port, hostname: "127.0.0.1", fetch: () => new Response("rebound") }); + const body = await (await fetch("http://127.0.0.1:" + port + "/")).text(); + console.log(String(error?.message).replace(port, "PORT"), "|", body); + udp.close(); + break; + } + `, + ], + 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: "Failed to listen on UDP port PORT for HTTP/3 | rebound\n", + stderr: "", + exitCode: 0, + }); + }); }); -// Cases ported from h2o t/40http3 and aioquic interop. Each test gets its own -// server (withServer) so they can run concurrently. +// Cases ported from h2o t/40http3 and aioquic interop. Not concurrent: several +// of them flood the pooled QUIC connection on purpose (64 streams, an 8 MB body). describe("Bun.serve HTTP/3 adversarial", () => { - const md5 = (b: Uint8Array | ArrayBuffer) => - createHash("md5") - .update(Buffer.from(b as Uint8Array)) - .digest("hex"); - test("64 concurrent streams on one connection", async () => { // h2o uses 1000; 64 stays inside lsquic's default initial-max-streams // and the debug-build 5s budget while still being 4× the existing @@ -623,6 +701,8 @@ describe("Bun.serve HTTP/3 adversarial", () => { body: payload, headers: { "content-type": "application/octet-stream" }, }); + expect(res.status).toBe(200); + expect(res.headers.get("x-len")).toBe(String(8 * 1024 * 1024)); const raw = await res.bytes(); expect(raw.length).toBe(payload.length); expect(md5(raw)).toBe(md5(payload)); @@ -663,6 +743,7 @@ describe("Bun.serve HTTP/3 adversarial", () => { const res = await fetchH3(port, "/big", { method: "HEAD" }); expect(res.status).toBe(200); expect(res.headers.get("content-length")).toBe("524288"); + expect(res.headers.get("content-type")).toBe("application/octet-stream"); expect((await res.bytes()).length).toBe(0); }); }); @@ -680,6 +761,8 @@ describe("Bun.serve HTTP/3 adversarial", () => { .then(r => r.text()) .catch(() => {}); const res = await fetchH3(port, "/hello"); + expect(res.status).toBe(200); + expect(res.headers.get("x-proto")).toBe("h3"); expect(await res.text()).toBe("hello over h3"); }); }); @@ -698,6 +781,8 @@ describe("Bun.serve HTTP/3 adversarial", () => { }); await p.catch(() => {}); const res = await fetchH3(port, "/hello"); + expect(res.status).toBe(200); + expect(res.headers.get("x-proto")).toBe("h3"); expect(await res.text()).toBe("hello over h3"); }); }); @@ -760,12 +845,13 @@ describe("Bun.serve HTTP/3 adversarial", () => { test("Response(subprocess.stdout) streams over H3", async () => { await withServer(async port => { - const raw = await fetchH3(port, "/spawn").then(r => r.bytes()); + const res = await fetchH3(port, "/spawn"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("text/plain"); + const raw = await res.bytes(); expect(raw.length).toBe(40 * 1001); - const text = Buffer.from(raw).toString(); - const lines = text.split("\n").filter(Boolean); - expect(lines.length).toBe(40); - expect(lines.every(l => l === Buffer.alloc(1000, "x").toString())).toBe(true); + const line = Buffer.alloc(1000, "x").toString() + "\n"; + expect(Buffer.from(raw).toString()).toBe(Buffer.alloc(40 * 1001, line).toString()); }); }); @@ -823,11 +909,13 @@ describe("Bun.serve HTTP/3 adversarial", () => { }); test("Response(Bun.file().stream()) goes through HTTPSResponseSink", async () => { - await withServer(async (port, dir) => { - const raw = await fetchH3(port, "/file-stream").then(r => r.bytes()); + await withServer(async port => { + const res = await fetchH3(port, "/file-stream"); + expect(res.status).toBe(200); + expect(res.headers.get("content-length")).toBe("204800"); + const raw = await res.bytes(); expect(raw.length).toBe(200 * 1024); - const onDisk = await Bun.file(join(dir, "big.bin")).bytes(); - expect(md5(raw)).toBe(md5(onDisk)); + expect(md5(raw)).toBe(md5(BIG_FILE_BYTES)); }); }); @@ -835,9 +923,12 @@ describe("Bun.serve HTTP/3 adversarial", () => { // has no socket fd. A 2 MB file is over the 1 MiB sendfile threshold. test("Bun.file >=1 MiB takes the reader path, not sendfile", async () => { await withServer(async port => { - const raw = await fetchH3(port, "/huge-file").then(r => r.bytes()); + const res = await fetchH3(port, "/huge-file"); + expect(res.status).toBe(200); + expect(res.headers.get("content-length")).toBe("2097152"); + const raw = await res.bytes(); expect(raw.length).toBe(2 * 1024 * 1024); - expect(md5(raw)).toBe(md5(Buffer.alloc(2 * 1024 * 1024, "0123456789abcdef"))); + expect(md5(raw)).toBe(md5(HUGE_FILE_BYTES)); }); }); @@ -929,19 +1020,25 @@ async function withCustomServer( const send = (cmd: string) => proc.stdin!.write(cmd + "\n"); try { await fn(port, send, waitForStderr); - } finally { + } catch (e) { // Give the script a chance to server.stop(true) (CONNECTION_CLOSE) on // stdin end before SIGKILL, so the client's pooled session is released // and can't collide with a later test that reuses the same port. - proc.stdin?.end(); + proc.stdin.end(); await Promise.race([proc.exited, Bun.sleep(1000)]); proc.kill(); await proc.exited; await drain.catch(() => {}); + throw e; } + // Every script exits on stdin end, or already exited on a command. + proc.stdin.end(); + const exitCode = await proc.exited; + await drain; + expect(exitCode, `fixture exited with ${exitCode}; stderr:\n${buf}`).toBe(0); } -describe("Bun.serve HTTP/3 lifecycle", () => { +describe.concurrent("Bun.serve HTTP/3 lifecycle", () => { // bughunt #2: server.reload() must clear the H3 router so removed routes // fall through to the fetch handler instead of dereferencing freed pointers. test("server.reload() clears stale H3 routes", async () => { @@ -953,8 +1050,8 @@ describe("Bun.serve HTTP/3 lifecycle", () => { fetch: () => new Response("fallback", { status: 404 }), }); console.error("PORT=" + server.port); - process.stdin.setEncoding("utf8"); - process.stdin.on("data", line => { + ${STDIN_HELPERS} + stopOnStdinEnd(line => { if (line.includes("reload")) { server.reload({ routes: { "/new": new Response("new-route") }, @@ -963,7 +1060,6 @@ describe("Bun.serve HTTP/3 lifecycle", () => { console.error("RELOADED"); } }); - ${STOP_ON_STDIN_END} `; await withCustomServer(script, async (port, send, waitForStderr) => { expect(await fetchH3(port, "/old").then(r => r.text())).toBe("old-route"); @@ -1002,8 +1098,8 @@ describe("Bun.serve HTTP/3 lifecycle", () => { console.error("PORT=" + server.port); }; start(0); - process.stdin.setEncoding("utf8"); - process.stdin.on("data", async line => { + ${STDIN_HELPERS} + stopOnStdinEnd(async line => { if (line.includes("stop")) { // Let the delayed-ACK timer fire so send_ctl has nothing scheduled // when listen_socket_close runs; that's the state in which the old @@ -1015,7 +1111,6 @@ describe("Bun.serve HTTP/3 lifecycle", () => { console.error("RESTARTED"); } }); - ${STOP_ON_STDIN_END} `; await withCustomServer(script, async (port, send, waitForStderr) => { // Warm the pooled client session. @@ -1074,8 +1169,8 @@ describe("Bun.serve HTTP/3 lifecycle", () => { }, }); console.error("PORT=" + server.port); - process.stdin.resume(); - process.stdin.on("end", () => process.exit(0)); + ${STDIN_HELPERS} + readStdin().then(() => process.exit(0)); `; await withCustomServer(script, async (port, send, waitForStderr) => { expect(await fetchH3(port, "/").then(r => r.text())).toBe("ok"); @@ -1124,7 +1219,7 @@ describe("Bun.serve HTTP/3 lifecycle", () => { fetch("https://127.0.0.1:" + server.port + "/", { protocol: "http3", tls: { rejectUnauthorized: false }, }).catch(() => {}); - process.stdout.write(await rebound.promise); + await Bun.write(Bun.stdout, await rebound.promise); process.exit(0); `; await using proc = Bun.spawn({ @@ -1158,7 +1253,7 @@ describe("Bun.serve HTTP/3 lifecycle", () => { body: new ReadableStream({ start(c) { c.enqueue("payload"); c.close(); } }), }).then(r => r.text(), e => e.code); other.stop(true); - process.stdout.write(outcome); + await Bun.write(Bun.stdout, outcome); `; await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], @@ -1194,7 +1289,7 @@ describe("Bun.serve HTTP/3 lifecycle", () => { await received.promise; return { outcome }; } - process.stdout.write(await (await run()).outcome); + await Bun.write(Bun.stdout, await (await run()).outcome); `; await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], @@ -1237,7 +1332,7 @@ describe("Bun.serve HTTP/3 lifecycle", () => { await inHandler.promise; server.stop(); proceed.resolve(); - process.stdout.write("got:" + await response); + await Bun.write(Bun.stdout, "got:" + await response); `; await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], @@ -1346,7 +1441,7 @@ describe("Bun.serve HTTP/3 lifecycle", () => { // bughunt #3: server.stop() must not leave the lsquic engine pointing at a // freed listen-socket. The follow-up GET should cleanly fail to connect, // and the process must still be alive to exit 0 on its own. - test("server.stop() with live H3 connections does not UAF", async () => { + test.serial("server.stop() with live H3 connections does not UAF", async () => { const script = ` const tls = ${JSON.stringify(tls)}; const server = Bun.serve({ @@ -1354,8 +1449,8 @@ describe("Bun.serve HTTP/3 lifecycle", () => { fetch: () => new Response("alive"), }); console.error("PORT=" + server.port); - process.stdin.setEncoding("utf8"); - process.stdin.on("data", async line => { + ${STDIN_HELPERS} + readStdin(async line => { if (line.includes("stop")) { server.stop(true); // give the timer one tick to prove it doesn't deref freed peer_ctx @@ -1474,7 +1569,7 @@ describe("Bun.serve HTTP/3 lifecycle", () => { // finish before the engine tears down. lsquic_engine_cooldown drops mini // (still-handshaking) conns immediately, so we wait until the server has // actually entered every handler before stopping — no arbitrary sleep. - test("graceful stop: in-flight H3 requests complete after server.stop()", async () => { + test.serial("graceful stop: in-flight H3 requests complete after server.stop()", async () => { const script = ` const tls = ${JSON.stringify(tls)}; let stopping = false, inflight = 0; @@ -1493,8 +1588,8 @@ describe("Bun.serve HTTP/3 lifecycle", () => { }, }); console.error("PORT=" + server.port); - process.stdin.setEncoding("utf8"); - process.stdin.on("data", line => { + ${STDIN_HELPERS} + readStdin(line => { if (line.includes("stop")) { stopping = true; server.stop(); } if (line.includes("exit")) process.exit(0); }); @@ -1528,7 +1623,8 @@ describe("Bun.serve HTTP/3 lifecycle", () => { fetch: () => new Response("ok"), }); console.error("PORT=" + server.port); - process.stdin.once("data", () => server.stop()); + ${STDIN_HELPERS} + readStdin(() => server.stop()); `, }); const proc = Bun.spawn({ @@ -1567,7 +1663,8 @@ describe("Bun.serve HTTP/3 lifecycle", () => { }, }); console.error("PORT=" + server.port); - process.stdin.once("data", () => { + ${STDIN_HELPERS} + readStdin(() => { server.stop(); server.stop(true); }); @@ -1695,7 +1792,8 @@ describe("Bun.serve HTTP/3 lifecycle", () => { async fetch(req) { const url = new URL(req.url); if (url.pathname === "/hang") { - req.signal.addEventListener("abort", () => { aborted++; }); + req.signal.addEventListener("abort", () => { aborted++; console.error("ABORTED"); }); + console.error("HANGING"); await new Promise(r => req.signal.addEventListener("abort", r)); return new Response("never"); } @@ -1704,32 +1802,25 @@ describe("Bun.serve HTTP/3 lifecycle", () => { }, }); console.error("PORT=" + server.port); - process.stdin.on("data", () => {}); ${STOP_ON_STDIN_END} `; - await withCustomServer(script, async port => { - // Warm the QUIC connection so the /hang stream is actually bound - // (qstream != null) before we abort — otherwise abort() is a no-op - // and the server never sees STOP_SENDING. - await fetchH3(port, "/aborted").then(r => r.text()); + await withCustomServer(script, async (port, _send, waitForStderr) => { const ac = new AbortController(); const p = fetchH3(port, "/hang", { signal: ac.signal }); - await Bun.sleep(200); + // Once the handler runs, the /hang stream is bound (qstream != null), so + // abort() sends STOP_SENDING. Before that it's a no-op. + await waitForStderr(/HANGING/); ac.abort(); - await expect(p).rejects.toThrow(); + await expect(p).rejects.toMatchObject({ name: "AbortError" }); // The signal fires from on_stream_close, which runs on the next // process_conns tick after the RST lands. - let count = "0"; - for (let i = 0; i < 60 && count === "0"; i++) { - count = await fetchH3(port, "/aborted").then(r => r.text()); - if (count === "0") await Bun.sleep(50); - } - expect(Number(count)).toBeGreaterThan(0); + await waitForStderr(/ABORTED/); + expect(await fetchH3(port, "/aborted").then(r => r.text())).toBe("1"); }); }); }); -describe("Bun.serve HTTP/3 production", () => { +describe.concurrent("Bun.serve HTTP/3 production", () => { // RFC 9110 §6.6.1: an origin server with a clock MUST send Date. // H1 writes it via HttpResponse::writeMark(); H3 must too. test("Date header is sent on every response shape", async () => { @@ -1771,9 +1862,9 @@ describe("Bun.serve HTTP/3 production", () => { // headers from a different place; all three must advertise h3. for (const path of ["/hello", "/static", "/file-route"]) { const res = await fetch(`https://127.0.0.1:${port}${path}`, { tls: { rejectUnauthorized: false } }); + await res.arrayBuffer(); expect(res.status).toBe(200); - const alt = res.headers.get("alt-svc") ?? ""; - expect(alt).toContain('h3=":' + port + '"'); + expect(res.headers.get("alt-svc")).toBe(`h3=":${port}"; ma=86400`); } }); }); @@ -1797,11 +1888,11 @@ describe("Bun.serve HTTP/3 production", () => { }, }); console.error("PORT=" + server.port); - process.stdin.on("data", () => {}); ${STOP_ON_STDIN_END} `; await withCustomServer(script, async port => { const res = await fetchH3(port, "/"); + expect(res.status).toBe(200); expect(await res.text()).toBe("upgrade=false"); }); }); @@ -2037,7 +2128,6 @@ describe("Bun.serve HTTP/3 request validation", () => { }, }); console.error("PORT=" + server.port); - process.stdin.on("data", () => {}); ${STOP_ON_STDIN_END} `, async port => { @@ -2093,6 +2183,86 @@ describe("Bun.serve HTTP/3 request validation", () => { ); }); + describe("request line", () => { + function serve() { + const calls: string[] = []; + const server = Bun.serve({ + port: 0, + tls, + http3: true, + routes: { + "/guarded": { + GET: () => { + calls.push("the /guarded route"); + return new Response("guarded"); + }, + }, + "/static": new Response("static"), + }, + fetch(req) { + calls.push(`${req.method} ${req.url}`); + return new Response(`${req.method} ${req.url}`); + }, + }); + return { server, calls }; + } + const exchangeAll = async (port: number, rows: Record>) => + Object.fromEntries( + await Promise.all(Object.entries(rows).map(async ([row, headers]) => [row, await h3Exchange(port, headers)])), + ); + + test("answers 501 to a method Bun.serve can't represent", async () => { + const { server, calls } = serve(); + await using _ = server; + const rows: Record> = {}; + for (const path of ["/echo", "/guarded", "/static"]) { + for (const method of ["BREW", "DELETE2", Buffer.alloc(300, "M").toString()]) { + rows[`${method.slice(0, 8)} ${path}`] = requestHeaders(path, { ":method": method }); + } + } + expect({ results: await exchangeAll(server.port!, rows), calls }).toEqual({ + results: Object.fromEntries(Object.keys(rows).map(row => [row, "501 "])), + calls: [], + }); + expect(await h3Exchange(server.port!, requestHeaders("/echo", { ":method": "PATCH" }))).toBe( + "200 PATCH https://localhost/echo", + ); + }); + + test("answers 400 to a malformed request line", async () => { + const { server, calls } = serve(); + await using _ = server; + const { ":method": _method, ...noMethod } = requestHeaders("/echo"); + const { ":path": _path, ...noPath } = requestHeaders("/echo"); + const { ":authority": _authority, ...noAuthority } = requestHeaders("/echo"); + const rows: Record> = { + "no :method": noMethod, + ":method with SP": requestHeaders("/echo", { ":method": "GET /x" }), + ":method with non-token bytes": requestHeaders("/echo", { ":method": "GET{}" }), + "no :path": noPath, + "empty :path": requestHeaders(""), + ":path with SP": requestHeaders("/a b"), + // The URL parser strips HTAB, so fetch() saw /guarded. + ":path with HTAB": requestHeaders("/guar\tded"), + ":path with a control byte": requestHeaders("/echo\x01"), + // The router drops the first byte, so this matched the /static route. + ":path without a leading slash": requestHeaders("xstatic"), + ":path * with GET": requestHeaders("*"), + "absolute-form :path": requestHeaders("https://other.invalid/static"), + "CONNECT with :path": requestHeaders("/static", { ":method": "CONNECT" }), + "no :authority and no host": noAuthority, + }; + expect({ results: await exchangeAll(server.port!, rows), calls }).toEqual({ + results: Object.fromEntries(Object.keys(rows).map(row => [row, "400 "])), + calls: [], + }); + expect(await h3Exchange(server.port!, requestHeaders("*", { ":method": "OPTIONS" }))).toBe("200 OPTIONS *"); + expect(await h3Exchange(server.port!, { ...noAuthority, host: "localhost" })).toBe( + "200 GET https://localhost/echo", + ); + }); + }); + test("request.url falls back to the :path when :authority is not a valid host", async () => { await using server = Bun.serve({ port: 0, @@ -2129,6 +2299,67 @@ describe("Bun.serve HTTP/3 request validation", () => { }); }); + // A QPACK decoder failure closes the connection, so the held response only completes if the block decoded. + test("accepts request trailers with no field lines", async () => { + const release = Promise.withResolvers(); + await using server = Bun.serve({ + port: 0, + tls, + http3: true, + async fetch(req) { + if (new URL(req.url).pathname !== "/held") return new Response("hello" + (await req.text())); + return new Response( + new ReadableStream({ + async start(controller) { + controller.enqueue("first,"); + await release.promise; + controller.enqueue("second"); + controller.close(); + }, + }), + ); + }, + }); + + await using endpoint = new QuicEndpoint(); + await using client = await connect(`127.0.0.1:${server.port}`, { + endpoint, + servername: "localhost", + verifyPeer: "manual", + transportParams: { maxIdleTimeout: 5 }, + onerror() {}, + } as SessionOptions); + await client.opened; + const exchange = async (options: Record, write?: (stream: any) => void) => { + let status = ""; + const stream = await client.createBidirectionalStream({ + ...options, + onheaders(received) { + status = received[":status"] as string; + }, + }); + stream.closed.catch(() => {}); + write?.(stream); + let body = ""; + try { + for await (const batch of stream as AsyncIterable) { + for (const chunk of batch) body += Buffer.from(chunk).toString("latin1"); + } + } catch (error: any) { + return `${error.code}`; + } + return `${status} ${body}`; + }; + + const held = exchange({ headers: requestHeaders("/held") }); + const subject = await exchange({}, stream => { + stream.sendHeaders({ ...requestHeaders("/subject"), ":method": "POST" }); + stream.sendHeaders({}, { terminal: true }); + }); + release.resolve(); + expect({ subject, held: await held }).toEqual({ subject: "200 hello", held: "200 first,second" }); + }); + test("requestCert with rejectUnauthorized only serves QUIC clients whose certificate chains to the configured CA", async () => { const keysDir = join(import.meta.dir, "..", "..", "node", "test", "fixtures", "keys"); const pem = (name: string) => readFileSync(join(keysDir, name), "utf8"); @@ -2268,6 +2499,107 @@ describe.concurrent("Bun.serve HTTP/3 sends the automatic 100 Continue ahead of await using server = serve(); expect(await exchange(server, { handshakeFirst: false })).toEqual(expected); }); + + // https://github.com/oven-sh/bun/issues/33082 + // fetch() doesn't wait for the 100, so the handler answers while lsquic still holds the 100 back. + describe("on the first request of a connection, when the client sends its body right away", () => { + const big = Buffer.alloc(512 * 1024, "abcdefghijklmnop").toString(); + const summarize = (body: string) => (body.length > 64 ? `${body.length} bytes, hash ${Bun.hash(body)}` : body); + const streamed = () => + new Response( + new ReadableStream({ + async pull(controller) { + controller.enqueue(new TextEncoder().encode("streamed")); + controller.close(); + }, + }), + ); + test.each([ + ["a string body", { fetch: () => new Response("sync") }, { status: 200, body: "sync" }], + [ + "a string body after reading the request body", + { fetch: async (req: Request) => new Response("body:" + (await req.bytes()).length) }, + { status: 200, body: "body:15" }, + ], + ["a body larger than one write", { fetch: () => new Response(big) }, { status: 200, body: summarize(big) }], + ["a ReadableStream body", { fetch: streamed }, { status: 200, body: "streamed" }], + ["an empty body", { fetch: () => new Response("") }, { status: 200, body: "" }], + ["a 204", { fetch: () => new Response(null, { status: 204 }) }, { status: 204, body: "" }], + ["a static route", { routes: { "/": new Response("static") } }, { status: 200, body: "static" }], + ])("%s", async (_, handlers, expected) => { + await using server = Bun.serve({ + port: 0, + tls, + http3: true, + http1: false, + ...handlers, + } as Bun.Serve.Options); + const res = await fetchH3(server.port!, "/", { + method: "POST", + body: "request-content", + headers: { expect: "100-continue" }, + }); + expect({ status: res.status, body: summarize(await res.text()) }).toEqual(expected); + }); + }); +}); + +// lsquic_global_init() allocates the SSL ex_data index that every TLS callback uses to find its QUIC +// session, so a second call breaks the sessions still in their handshake. Each case needs a fresh process. +describe.concurrent("lsquic is initialized once per process", () => { + const script = ` +import { connect, QuicEndpoint } from "node:quic"; +const port = Number(process.env.PORT); +const client = await connect("127.0.0.1:" + port, { + endpoint: new QuicEndpoint(), + servername: "localhost", + verifyPeer: "manual", + transportParams: { maxIdleTimeout: 5 }, + onerror() {}, +}); +client.closed.catch(() => {}); +// connect() resolves in a microtask, so no server packet has been processed yet. +const other = + process.env.OTHER === "serve" + ? Bun.serve({ port: 0, tls: ${JSON.stringify(tls)}, http3: true, http1: false, fetch: () => new Response("x") }) + : fetch("https://127.0.0.1:" + port + "/hello", { protocol: "http3", tls: { rejectUnauthorized: false } }).then(r => r.text()); +await client.opened; +let status = ""; +const stream = await client.createBidirectionalStream({ + headers: { ":method": "GET", ":path": "/hello", ":scheme": "https", ":authority": "localhost" }, + onheaders(received) { + status = received[":status"]; + }, +}); +stream.closed.catch(() => {}); +for await (const _ of stream) { +} +client.close().catch(() => {}); +console.log("status=" + status); +if (process.env.OTHER === "serve") other.stop(true); +else console.log("fetch=" + (await other)); +process.exit(0); +`; + + test.each([ + ["an HTTP/3 server", "serve", "status=200\n"], + ["a fetch() over HTTP/3", "fetch", "status=200\nfetch=hello over h3\n"], + ])("a node:quic handshake survives %s starting in the middle of it", async (_, other, expected) => { + await withServer(async port => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: { ...bunEnv, PORT: String(port), OTHER: other }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr: stderr.replace(/^.*ExperimentalWarning.*\n.*\n/, ""), exitCode }).toEqual({ + stdout: expected, + stderr: "", + exitCode: 0, + }); + }); + }); }); // The HTTP/3 twin of the HTTP/1 cases in websocket-server.test.ts: ws.close() @@ -2459,3 +2791,759 @@ describe("Bun.serve HTTP/3 SNI", () => { expect(await exchange("other.example")).toBe("200 served"); }); }); + +// A graceful stop() keeps the UDP socket open for connections that still have a +// response to deliver. Its promise resolves once the last of them is gone and +// the socket is closed. +describe("Bun.serve HTTP/3 stop()", () => { + async function run(script: string) { + 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 }; + } + + // Nothing is awaited at the top level, so only the server keeps the process + // alive. stop() in the microtask runs inside lsquic's process_conns. + test.concurrent.each([ + ["in a microtask of the handler", ""], + ["in a later task", "await new Promise(resolve => setImmediate(resolve));"], + ])("frees the port for a Bun.serve() after the await, called %s", async (_where, beforeStop) => { + const result = await run(` + const inHandler = Promise.withResolvers(); + const proceed = Promise.withResolvers(); + const options = { hostname: "127.0.0.1", tls: ${JSON.stringify(tls)}, http3: true }; + const h3 = { protocol: "http3", tls: { rejectUnauthorized: false } }; + const server = Bun.serve({ + ...options, + port: 0, + async fetch() { + inHandler.resolve(); + await proceed.promise; + return new Response("first"); + }, + }); + const port = server.port; + const first = fetch("https://127.0.0.1:" + port + "/", h3).then(r => r.text()); + inHandler.promise.then(async () => { + ${beforeStop} + const stopped = server.stop(); + proceed.resolve(); + await stopped; + const second = Bun.serve({ ...options, port, fetch: () => new Response("second") }); + const answers = [await first, await fetch("https://127.0.0.1:" + port + "/", h3).then(r => r.text())]; + await second.stop(true); + console.log(answers.join(",")); + }); + `); + expect(result).toEqual({ stdout: "first,second\n", stderr: "", exitCode: 0 }); + }); + + // 1 MB takes several round trips of acknowledgements. + test.concurrent("delivers a response in flight before the process exits", async () => { + const expected = Buffer.alloc(1024 * 1024, "abcdefghijklmnop"); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const body = Buffer.alloc(1024 * 1024, "abcdefghijklmnop"); + const server = Bun.serve({ + port: 0, hostname: "127.0.0.1", tls: ${JSON.stringify(tls)}, http3: true, + fetch() { + setImmediate(async () => { + await server.stop(); + process.exit(0); + }); + return new Response(body); + }, + }); + console.log(server.port); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const { value } = await proc.stdout.getReader().read(); + const body = await fetchH3(Number(new TextDecoder().decode(value)), "/").then(res => res.bytes()); + expect(Buffer.compare(body, expected)).toBe(0); + expect(await proc.exited).toBe(0); + }); + + test("frees the port when only an idle connection is left", async () => { + const options = { hostname: "127.0.0.1", tls, http3: true, http1: false, fetch: () => new Response("ok") }; + await using first = Bun.serve({ ...options, port: 0 }); + const port = first.port!; + for (let i = 0; i < 2; i++) expect(await fetchH3(port, "/").then(res => res.text())).toBe("ok"); + await first.stop(); + await using second = Bun.serve({ ...options, port }); + expect(second.port).toBe(port); + }); + + // Nothing acknowledges the response, so the connection drains until the idle timeout or an abrupt stop. + test("stays pending after the peer vanished, until stop(true)", async () => { + const inHandler = Promise.withResolvers(); + const proceed = Promise.withResolvers(); + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + tls, + http3: true, + http1: false, + idleTimeout: 30, + async fetch() { + inHandler.resolve(); + await proceed.promise; + return new Response("first"); + }, + }); + const port = server.port!; + try { + await using client = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `await fetch("https://127.0.0.1:${port}/", { protocol: "http3", tls: { rejectUnauthorized: false } }).then(r => r.text());`, + ], + env: bunEnv, + stdout: "ignore", + stderr: "inherit", + }); + client.exited.then(code => inHandler.reject(new Error(`the client exited before its request arrived: ${code}`))); + await inHandler.promise; + client.kill("SIGKILL"); + await client.exited; + const stopped = server.stop(); + proceed.resolve(); + while (server.pendingRequests !== 0) await new Promise(resolve => setImmediate(resolve)); + expect({ same: server.stop() === stopped, status: Bun.peek.status(stopped) }).toEqual({ + same: true, + status: "pending", + }); + server.stop(true); + await stopped; + (await Bun.udpSocket({ port, hostname: "127.0.0.1" })).close(); + } finally { + server.stop(true); + } + }); + + // The event loop closes a UDP socket whose receive fails. Its connections can't + // reach their peers any more, so stop() must not wait out their idle timeout. + test.skipIf(!socketFaultInjection.available())("doesn't wait for connections whose UDP socket closed", async () => { + const script = ` + import { socketFaultInjection as fault } from "bun:internal-for-testing"; + const armed = Promise.withResolvers(); + const server = Bun.serve({ + port: 0, hostname: "127.0.0.1", tls: ${JSON.stringify(tls)}, http3: true, http1: false, + fetch(req) { + if (new URL(req.url).pathname !== "/arm") return new Response("ok"); + fault.set({ syscall: "recvmsg", action: "errno", errno: "ENOBUFS" }); + armed.resolve(); + return new Response("armed"); + }, + }); + const port = server.port; + console.error("PORT=" + port); + await armed.promise; + // The next datagram from a client fails the receive. Wait until the port is free. + while (true) { + try { + (await Bun.udpSocket({ port, hostname: "127.0.0.1" })).close(); + break; + } catch (e) { + if (e.code !== "EADDRINUSE") throw e; + } + await new Promise(resolve => setImmediate(resolve)); + } + server.stop(); + console.error("STOPPED " + Bun.peek.status(server.stop())); + `; + await withCustomServer(script, async (port, _send, waitForStderr) => { + using sessions = new DisposableStack(); + // More connections than lsquic's first send batch holds. + const open = Array.from({ length: 32 }, () => sessions.use(new Bun.FetchSession())); + const get = (session: Bun.FetchSession, path: string) => + fetchH3(port, path, { session } as RequestInit).then(res => res.text()); + for (const session of open) expect(await get(session, "/")).toBe("ok"); + expect(await get(open[0], "/arm")).toBe("armed"); + expect((await waitForStderr(/STOPPED (\w+)/))[1]).toBe("fulfilled"); + }); + }); +}); + +// Bun.serve pauses a request body nobody reads by holding the stream's +// flow-control window (1 MiB). It keeps reading the stream, because data left in +// lsquic uses up the connection's window (1.5 MiB) and is dropped with the +// connection. +describe("Bun.serve HTTP/3 request body that isn't read yet", () => { + test("leaves the connection's flow-control window to other requests", async () => { + // Two of these, unread, are more than the connection's window. + const body = Buffer.alloc(2 * 1024 * 1024, "abcdefghijklmnop"); + const gate = Promise.withResolvers(); + await using server = Bun.serve({ + port: 0, + tls, + http3: true, + http1: false, + routes: { + "/hello": () => new Response("hello"), + "/read": async req => new Response(String((await req.bytes()).length)), + "/held": async req => { + await gate.promise; + return new Response(String((await req.bytes()).length)); + }, + }, + }); + const post = (path: string) => fetchH3(server.port!, path, { method: "POST", body }).then(r => r.text()); + + const held = [post("/held"), post("/held")]; + // Same size, same connection: once this one is through, the held uploads have sent all they may. + const read = await post("/read"); + const sibling = await fetchH3(server.port!, "/hello").then(r => r.text()); + gate.resolve(); + expect({ read, sibling, held: await Promise.all(held) }).toEqual({ + read: String(body.length), + sibling: "hello", + held: [String(body.length), String(body.length)], + }); + }); + + test("arrives whole after its connection closed", async () => { + // Over the 1 MiB at which a stalled reader pauses the stream. + const body = Buffer.alloc(1408 * 1024, "abcdefghijklmnop"); + const sha = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex"); + const seen = Promise.withResolvers<{ length: number; sha: string } | { name: string }>(); + await using server = Bun.serve({ + port: 0, + tls, + http3: true, + http1: false, + async fetch(req) { + const reader = req.body!.getReader(); + const chunks = [(await reader.read()).value!]; + // Stall until the client is gone. + await new Promise(resolve => req.signal.addEventListener("abort", resolve)); + try { + for (let next; !(next = await reader.read()).done;) chunks.push(next.value); + const bytes = Buffer.concat(chunks); + seen.resolve({ length: bytes.length, sha: sha(bytes) }); + } catch (e) { + seen.resolve({ name: (e as Error).name }); + } + return new Response(); + }, + }); + + await using endpoint = new QuicEndpoint(); + const client = await connect(`127.0.0.1:${server.port}`, { + endpoint, + servername: "localhost", + verifyPeer: "manual", + transportParams: { maxIdleTimeout: 5 }, + onerror() {}, + } as SessionOptions); + client.closed.catch(() => {}); + const stream = await client.createBidirectionalStream(); + stream.closed.catch(() => {}); + stream.sendHeaders(requestHeaders("/", { ":method": "POST" })); + // One piece per turn of the event loop, which the server shares. A burst + // would overflow the loopback socket and wait for retransmissions. + for (let offset = 0; offset < body.length;) { + await new Promise(resolve => setImmediate(resolve)); + if (stream.writer.writeSync(body.subarray(offset, offset + 16 * 1024))) offset += 16 * 1024; + } + stream.writer.endSync(); + // An application close waits until the server has acknowledged the whole stream. + client.close({ type: "application", code: 0x100 }).catch(() => {}); + + expect(await seen.promise).toEqual({ length: body.length, sha: sha(body) }); + }); +}); + +// Only a FIN completes an HTTP/3 message, so a body that fails after the status +// was sent has to end with RESET_STREAM. HTTP/1 closes the socket without the +// last chunk. +describe("Bun.serve HTTP/3 response body that fails after its first chunk", () => { + // The body fails once the client, which has the first chunk by then, requests /release. + const script = ` + const { promise: released, resolve: release } = Promise.withResolvers(); + const server = Bun.serve({ + port: 0, + tls: ${JSON.stringify(tls)}, + http3: true, + http1: false, + routes: { + "/ok": () => new Response("ok"), + "/release": () => { + release(); + return new Response("released"); + }, + "/fails": () => { + let pulls = 0; + return new Response(new ReadableStream({ + async pull(c) { + if (pulls++ === 0) return c.enqueue(new Uint8Array(1024).fill(65)); + await released; + c.error(new Error("boom")); + }, + })); + }, + }, + }); + console.error("PORT=" + server.port); + ${STOP_ON_STDIN_END} + `; + const release = (port: number) => fetchH3(port, "/release").then(r => r.text()); + + test("resets the stream with H3_INTERNAL_ERROR", async () => { + await withCustomServer(script, async port => { + await using endpoint = new QuicEndpoint(); + const client = await connect(`127.0.0.1:${port}`, { + endpoint, + servername: "localhost", + verifyPeer: "manual", + transportParams: { maxIdleTimeout: 5 }, + onerror() {}, + } as SessionOptions); + await client.opened; + const stream = await client.createBidirectionalStream({ headers: requestHeaders("/fails") }); + const closed = stream.closed.then( + () => "fin", + (e: Error & { code?: string; errorCode?: bigint }) => `${e.code} ${e.errorCode}`, + ); + let chunks = 0; + try { + for await (const _ of stream as AsyncIterable) { + if (chunks++ === 0) await release(port); + } + } catch {} + const result = { chunks, closed: await closed }; + client.close().catch(() => {}); + expect(result).toEqual({ chunks: 1, closed: "ERR_QUIC_APPLICATION_ERROR 258" }); + }); + }); + + test("rejects fetch()'s body read and keeps the connection", async () => { + await withCustomServer(script, async port => { + const res = await fetchH3(port, "/fails"); + const reader = res.body!.getReader(); + const first = await reader.read(); + await release(port); + const rest = await reader.read().then( + ({ done }) => (done ? "done" : "chunk"), + (e: Error & { code?: string }) => e.code, + ); + const after = await fetchH3(port, "/ok").then(r => r.text()); + expect({ status: res.status, first: first.value?.length, rest, after }).toEqual({ + status: 200, + first: 1024, + rest: "HTTP3StreamReset", + after: "ok", + }); + }); + }); +}); + +// After the client's STOP_SENDING, lsquic answers every write with -1. The stream doesn't +// close on its own while the request side is open, so the server has to give it up. +describe("Bun.serve HTTP/3 response that the client stopped while its request is still open", () => { + const taskHop = () => new Promise(resolve => setImmediate(resolve)); + + // Resolves to `late` if `event` doesn't settle before the connection's idle timeout ends every stream anyway. + async function settledWithin(ms: number, event: Promise, late: T): Promise { + let settled: { value: T } | undefined; + event.then(value => (settled = { value })); + const deadline = performance.now() + ms; + while (!settled && performance.now() < deadline) await taskHop(); + return settled ? settled.value : late; + } + + // Sends a POST, leaves its request side open, then sends STOP_SENDING for the response. + async function stoppedRequest(port: number, stopWhen: () => Promise, onheaders?: () => void) { + const endpoint = new QuicEndpoint(); + const client = await connect(`127.0.0.1:${port}`, { + endpoint, + servername: "localhost", + verifyPeer: "manual", + transportParams: { maxIdleTimeout: 5 }, + onerror() {}, + } as SessionOptions); + await client.opened; + const stream: any = await client.createBidirectionalStream({ onheaders }); + stream.closed.catch(() => {}); + stream.sendHeaders(requestHeaders("/", { ":method": "POST" })); + stream.writer.writeSync(new TextEncoder().encode("the start of an upload")); + (async () => { + for await (const _ of stream as AsyncIterable); + })().catch(() => {}); + await stopWhen(); + stream.stopSending(0x10cn); + return { + client, + stream, + async [Symbol.asyncDispose]() { + client.destroy(); + await endpoint.close(); + }, + }; + } + + test("aborts the request when the response writes again", async () => { + const started = Promise.withResolvers(); + const ended = Promise.withResolvers(); + await using server = Bun.serve({ + port: 0, + tls, + http3: true, + fetch(req) { + return new Response( + new ReadableStream({ + type: "direct", + // Writes on every turn without waiting for the sink to drain, like a timer-driven event stream. + async pull(controller) { + const giveUpAt = performance.now() + 3000; + for (let writes = 0; !req.signal.aborted && performance.now() < giveUpAt; writes++) { + controller.write("event\n"); + if (writes === 8) started.resolve(); + await taskHop(); + } + ended.resolve(req.signal.aborted ? "aborted" : "never aborted"); + }, + }), + ); + }, + }); + + await using _ = await stoppedRequest(server.port!, () => started.promise); + + expect(await ended.promise).toBe("aborted"); + }); + + // With bytes already queued, a later write only appends to them and asks for a writable callback. lsquic + // refuses that once its RESET_STREAM is out. In the flight that carried the STOP_SENDING it isn't out yet, + // so the callback runs and its write gets the -1. The response writes once, so each row tests one path. + test.each([ + { when: "a later flight", turnsBetween: 2 }, + { when: "the same flight", turnsBetween: 0 }, + ])( + "aborts the request when bytes were already queued and the response writes again in $when", + async ({ turnsBetween }) => { + const queued = Promise.withResolvers(); + const sawHeaders = Promise.withResolvers(); + const writeAgain = Promise.withResolvers(); + const aborted = Promise.withResolvers(); + await using server = Bun.serve({ + port: 0, + tls, + http3: true, + routes: { + "/write-again": () => { + writeAgain.resolve(); + return new Response("ok"); + }, + }, + fetch(req) { + req.signal.addEventListener("abort", () => aborted.resolve("aborted")); + return new Response( + new ReadableStream({ + type: "direct", + async pull(controller) { + // Far more than the client's window. + controller.write(Buffer.alloc(8 * 1024 * 1024, "q")); + controller.flush(); + queued.resolve(); + await writeAgain.promise; + controller.write("tail"); + controller.flush(); + await aborted.promise; + }, + }), + ); + }, + }); + + await using stopped = await stoppedRequest( + server.port!, + () => Promise.all([queued.promise, sawHeaders.promise]), + sawHeaders.resolve, + ); + for (let i = 0; i < turnsBetween; i++) await taskHop(); + const second = await stopped.client.createBidirectionalStream({ headers: requestHeaders("/write-again") }); + for await (const _ of second as AsyncIterable); + + expect(await settledWithin(3000, aborted.promise, "no abort within 3 s")).toBe("aborted"); + }, + ); + + // end() completes the response even if its bytes can't go out, as over HTTP/1, so there's no abort event. + test("closes the stream when the response ends after the STOP_SENDING", async () => { + const started = Promise.withResolvers(); + const finish = Promise.withResolvers(); + await using server = Bun.serve({ + port: 0, + tls, + http3: true, + routes: { + "/finish": () => { + finish.resolve(); + return new Response("ok"); + }, + }, + fetch() { + return new Response( + new ReadableStream({ + type: "direct", + async pull(controller) { + controller.write("head"); + await controller.flush(); + started.resolve(); + await finish.promise; + // The sink passes this tail to end(), not to write(). + controller.write("tail"); + Promise.resolve(controller.end()).catch(() => {}); + }, + }), + ); + }, + }); + + await using stopped = await stoppedRequest(server.port!, () => started.promise); + // Two turns put the STOP_SENDING on the wire, so the server has it before the second request. + await taskHop(); + await taskHop(); + const second = await stopped.client.createBidirectionalStream({ headers: requestHeaders("/finish") }); + for await (const _ of second as AsyncIterable); + + const closed = stopped.stream.closed.then( + () => "closed", + () => "closed", + ); + const upload = await settledWithin(3000, closed, "still open after 3 s"); + expect({ upload, pendingRequests: server.pendingRequests }).toEqual({ upload: "closed", pendingRequests: 0 }); + }); +}); + +// A handler runs while lsquic processes that handler's connection. A write it +// makes to another connection's stream has to go out right away, not with that +// connection's next packet or alarm. +describe("Bun.serve HTTP/3 writes to another connection's stream from a handler", () => { + type Session = Awaited>; + + async function open(port: number, endpoint: QuicEndpoint) { + const session = await connect(`127.0.0.1:${port}`, { + endpoint, + servername: "localhost", + verifyPeer: "manual", + transportParams: { maxIdleTimeout: 30 }, + onerror() {}, + } as SessionOptions); + session.closed.catch(() => {}); + await session.opened; + return session; + } + + // Sends a GET. The returned function resolves once the body ends with `text`. + async function request(session: Session, path: string) { + const stream = await session.createBidirectionalStream({ headers: requestHeaders(path) }); + stream.closed.catch(() => {}); + let body = ""; + let ended = false; + let more = Promise.withResolvers(); + (async () => { + try { + for await (const batch of stream as AsyncIterable) { + for (const chunk of batch) body += Buffer.from(chunk).toString("latin1"); + more.resolve(); + more = Promise.withResolvers(); + } + } finally { + ended = true; + more.resolve(); + } + })().catch(() => {}); + return async (text: string) => { + while (!body.endsWith(text)) { + if (ended) throw new Error(`the response ended before "${text}"`); + await more.promise; + } + return text; + }; + } + const get = async (session: Session, path: string, body: string) => (await request(session, path))(body); + + // `hold` sends a request on its own connection. `released` resolves with what + // "/kick", sent on a second connection, makes the server write to it. That + // write must reach the client before the response to a later "/marker". + async function run( + routes: Record Response | Promise>, + hold: (holder: Session) => Promise<{ released: Promise }>, + ) { + await using kickerEndpoint = new QuicEndpoint(); + await using holderEndpoint = new QuicEndpoint(); + const server = Bun.serve({ + port: 0, + tls, + http3: true, + http1: false, + routes: { ...routes, "/marker": () => new Response("marker") }, + }); + const sessions: Session[] = []; + try { + const kicker = await open(server.port!, kickerEndpoint); + sessions.push(kicker); + const holder = await open(server.port!, holderEndpoint); + sessions.push(holder); + const events: string[] = []; + const released = (await hold(holder)).released.then(text => events.push(text)); + // Any packet on the holder's connection gets it ticked, which would hide + // the bug. Wait until it's been quiet for 20 requests on the other one. + const stats = holder.stats as unknown as { pktSent: bigint; pktRecv: bigint }; + for (let quiet = 0, last = -1n, requests = 0; quiet < 20 && requests < 60; requests++) { + const packets = stats.pktSent + stats.pktRecv; + quiet = packets === last ? quiet + 1 : 0; + last = packets; + await get(kicker, "/marker", "marker"); + } + await get(kicker, "/kick", "kicked"); + events.push(await get(kicker, "/marker", "marker")); + await released; + return events; + } finally { + // Before the sessions close, because they wait for open responses. + await server.stop(true); + for (const session of sessions) if (!session.destroyed) session.close().catch(() => {}); + } + } + + test("sends a response right away", async () => { + const arrived = Promise.withResolvers(); + const gate = Promise.withResolvers(); + const events = await run( + { + "/held": async () => { + arrived.resolve(); + await gate.promise; + return new Response("held response"); + }, + "/kick": () => { + gate.resolve(); + return new Response("kicked"); + }, + }, + async holder => { + const response = await request(holder, "/held"); + await arrived.promise; + return { released: response("held response") }; + }, + ); + expect(events).toEqual(["held response", "marker"]); + }); + + test("sends a chunk of a streamed response right away", async () => { + let controller!: ReadableStreamDefaultController; + const events = await run( + { + "/held": () => + new Response( + new ReadableStream({ + start(c) { + controller = c; + c.enqueue(new TextEncoder().encode("first chunk")); + }, + }), + ), + "/kick": () => { + controller.enqueue(new TextEncoder().encode("second chunk")); + return new Response("kicked"); + }, + }, + async holder => { + const response = await request(holder, "/held"); + await response("first chunk"); + return { released: response("second chunk") }; + }, + ); + expect(events).toEqual(["second chunk", "marker"]); + }); +}); + +// Bun.build() runs the event loop while it waits for an async plugin setup(), +// so a handler that calls it can have a packet for its own connection read in +// the middle of lsquic's pass. The server gets its own process because it hangs +// or aborts when this breaks. +test("Bun.serve HTTP/3 accepts a packet for a connection whose handler is running the event loop", async () => { + const script = /* js */ ` + import { connect, QuicEndpoint } from "node:quic"; + + let session; + async function request(path) { + const stream = await session.createBidirectionalStream({ + headers: { ":method": "GET", ":path": path, ":scheme": "https", ":authority": "localhost" }, + }); + stream.closed.catch(() => {}); + let body = ""; + for await (const batch of stream) for (const chunk of batch) body += Buffer.from(chunk).toString("latin1"); + return body; + } + + let second; + const server = Bun.serve({ + port: 0, + tls: ${JSON.stringify(tls)}, + http3: true, + http1: false, + routes: { + "/build": () => { + Bun.build({ + entrypoints: ["./entry.js"], + plugins: [ + { + name: "second request", + async setup() { + const sent = session.stats.pktSent; + second = request("/second"); + // Wait until the request is in the server's socket, then + // one more turn of the event loop so the server reads it. + while (session.stats.pktSent === sent) await new Promise(setImmediate); + await new Promise(setImmediate); + }, + }, + ], + }).catch(() => {}); + return new Response("built"); + }, + "/second": () => new Response("second"), + }, + }); + + session = await connect("127.0.0.1:" + server.port, { + endpoint: new QuicEndpoint(), + servername: "localhost", + verifyPeer: "manual", + transportParams: { maxIdleTimeout: 30 }, + onerror() {}, + }); + session.closed.catch(() => {}); + await session.opened; + await request("/second"); + console.log(JSON.stringify([await request("/build"), await second])); + process.exit(0); + `; + using dir = tempDir("serve-http3-nested-loop", { "entry.js": "export default 1;\n", "server.mjs": script }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "server.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + // Kills a server that hangs, before the test's own timeout. + timeout: 20_000, + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), exitCode, signalCode: proc.signalCode }).toEqual({ + stdout: JSON.stringify(["built", "second"]), + exitCode: 0, + signalCode: null, + }); +}, 30_000); diff --git a/test/js/bun/http/serve-listen.test.ts b/test/js/bun/http/serve-listen.test.ts index ee635d88f979..73271cf2335b 100644 --- a/test/js/bun/http/serve-listen.test.ts +++ b/test/js/bun/http/serve-listen.test.ts @@ -1,9 +1,11 @@ import { file, serve } from "bun"; import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isLinux, isWindows, tmpdirSync } from "harness"; +import { readFileSync } from "node:fs"; import type { NetworkInterfaceInfo } from "node:os"; import { networkInterfaces } from "node:os"; import { join } from "node:path"; +import { getSystemErrorName } from "node:util"; const networks = Object.values(networkInterfaces()).flat() as NetworkInterfaceInfo[]; const hasIPv4 = networks.some(({ family }) => family === "IPv4"); @@ -116,7 +118,8 @@ describe.each([ ? { protocol: "unix:", pathname: unix.substring(unix.indexOf(":") + 1), - hostname: unix.substring(0, unix.indexOf(":")), + // "C:" is the URL's host, where a raw ":" would start a port. + hostname: unix.substring(0, unix.indexOf(":")) + "%3A", port: "", } : { @@ -264,3 +267,78 @@ test.skipIf(!isLinux)("server.address / server.port do not panic when getsocknam expect(out.url).not.toContain("65535"); expect(out.url).not.toContain(":-1"); }); + +describe("Bun.serve() throws the error of the call that failed", () => { + // `errno` is the negative libuv number, as in Node. + const shape = ({ code, syscall, errno, message }: any) => ({ + code, + syscall, + errno: errno < 0 ? getSystemErrorName(errno) : errno, + message, + }); + + test.skipIf(!hasIPv4).each([ + ["127.0.0.1", { hostname: "127.0.0.1" }], + ["every address", {}], + ])("EADDRINUSE for a port that is in use on %s", (_, address) => { + using occupant = serve({ ...address, port: 0, fetch: () => new Response() }); + let error: unknown; + try { + serve({ ...address, port: occupant.port, fetch: () => new Response() }).stop(true); + } catch (e) { + error = shape(e); + } + expect(error).toEqual({ + code: "EADDRINUSE", + syscall: "listen", + errno: "EADDRINUSE", + message: `Failed to start server. Is port ${occupant.port} in use?`, + }); + }); + + // https://github.com/oven-sh/bun/issues/30363 + const isRoot = process.getuid?.() === 0; + // root can bind any port, so the child drops to nobody. + const asNobody = isRoot ? { uid: 65534, gid: 65534 } : {}; + const refusesPort80 = (() => { + if (isWindows) return false; + if (isLinux) { + try { + if (Number(readFileSync("/proc/sys/net/ipv4/ip_unprivileged_port_start", "utf8")) <= 80) return false; + } catch {} + } + if (!isRoot) return true; + try { + return Bun.spawnSync({ cmd: [bunExe(), "--version"], env: bunEnv, ...asNobody }).exitCode === 0; + } catch { + return false; + } + })(); + test.skipIf(!refusesPort80)("EACCES for a privileged port", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `try { + Bun.serve({ hostname: "127.0.0.1", port: 80, fetch: () => new Response() }).stop(true); + console.log("listening"); + } catch ({ code, syscall, errno, message }) { + console.log(JSON.stringify({ code, syscall, errno, message })); + }`, + ], + env: bunEnv, + ...asNobody, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(shape(JSON.parse(stdout))).toEqual({ + code: "EACCES", + syscall: "listen", + errno: "EACCES", + message: "permission denied 127.0.0.1:80", + }); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/bun/http/serve-pending-promise-abort-leak-fixture.ts b/test/js/bun/http/serve-pending-promise-abort-leak-fixture.ts deleted file mode 100644 index 3cb95c7f07c0..000000000000 --- a/test/js/bun/http/serve-pending-promise-abort-leak-fixture.ts +++ /dev/null @@ -1,95 +0,0 @@ -// Fixture: server returns a Promise that never resolves, then the -// client disconnects. Previously the RequestContext leaked because the -// pending-promise ref was only balanced by onResolve/onReject, which never -// fire when the promise is never settled. -// -// We verify the fix by checking server.pendingRequests returns to 0 after -// aborted requests complete their cleanup cycle. -import { connect } from "node:net"; -import { tls } from "harness"; - -const http2 = process.argv.includes("--http2"); - -let abortCount = 0; -// Resolved from inside fetch() once the abort listener is installed, so the -// client knows it's safe to destroy the socket without racing the setup. -const readySignals: Array<() => void> = []; - -const server = Bun.serve({ - port: 0, - idleTimeout: 0, - ...(http2 ? { tls, http2: true } : {}), - fetch(req) { - req.signal.addEventListener("abort", () => abortCount++, { once: true }); - readySignals.shift()?.(); - // Never resolves - this is the leak scenario - return new Promise(() => {}); - }, -}); - -const port = server.port!; - -function makeAbortedTcpRequest(): Promise { - return new Promise(resolve => { - const { promise: ready, resolve: signalReady } = Promise.withResolvers(); - readySignals.push(signalReady); - - const socket = connect(port, "127.0.0.1", () => { - socket.write("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); - // Wait for the server to reach fetch() and install the abort listener - // before destroying. Without this, a slow CI could destroy the socket - // before addEventListener runs, and the abort would never be observed. - ready.then(() => { - socket.destroy(); - resolve(); - }); - }); - socket.on("error", () => resolve()); - }); -} - -/** Same scenario over HTTP/2: RST_STREAM the request once fetch() is reached. */ -function makeAbortedH2Request(): Promise { - const { promise: ready, resolve: signalReady } = Promise.withResolvers(); - readySignals.push(signalReady); - const ac = new AbortController(); - const done = fetch(server.url, { protocol: "http2", tls: { rejectUnauthorized: false }, signal: ac.signal }).then( - () => { - throw new Error("request should have been aborted"); - }, - () => {}, - ); - return ready.then(() => { - ac.abort(); - return done; - }); -} - -const ITERATIONS = 100; -for (let i = 0; i < ITERATIONS; i++) { - await (http2 ? makeAbortedH2Request() : makeAbortedTcpRequest()); -} - -// Force GC to collect the never-settled Promises. The NativePromiseContext -// cell destructor releases the ref on each RequestContext when the Promise's -// reaction is collected. -for (let i = 0; i < 10 && (server.pendingRequests > 0 || abortCount < ITERATIONS); i++) { - Bun.gc(true); - await Bun.sleep(10); -} - -const pending = server.pendingRequests; - -console.log(JSON.stringify({ pending, abortCount, iterations: ITERATIONS })); - -server.stop(true); - -if (pending !== 0) { - console.error(`LEAK: ${pending} RequestContexts were never freed`); - process.exit(1); -} -if (abortCount !== ITERATIONS) { - console.error(`Expected ${ITERATIONS} abort events, got ${abortCount}`); - process.exit(1); -} -process.exit(0); diff --git a/test/js/bun/http/serve-pending-promise-abort-leak.test.ts b/test/js/bun/http/serve-pending-promise-abort-leak.test.ts index 9e6810a663ae..c3857f88612c 100644 --- a/test/js/bun/http/serve-pending-promise-abort-leak.test.ts +++ b/test/js/bun/http/serve-pending-promise-abort-leak.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; -import { bunEnv, bunExe, bunRun } from "harness"; -import { connect } from "node:net"; +import { bunEnv, bunExe, bunRun, tls } from "harness"; +import { connect, type Socket } from "node:net"; import { join } from "node:path"; // The teardown condition, with no timers and no GC: server.stop() resolves @@ -16,31 +16,99 @@ async function stopAndAssertDrained(server: ReturnType) { // spending seconds in llvm-symbolizer. const asanOptionsWithoutSymbolizer = [bunEnv.ASAN_OPTIONS, "symbolize=0"].filter(Boolean).join(":"); -test.each([false, true])( +// Ignores errors once connected. The server closes or resets the connection while the client is still writing. +async function openRawSocket(port: number): Promise { + const socket = connect(port, "127.0.0.1"); + await new Promise((resolve, reject) => { + socket.on("connect", resolve); + socket.on("error", reject); + }); + socket.removeAllListeners("error"); + socket.on("error", () => {}); + return socket; +} + +// Resolves with everything the server wrote once it closes the connection. +function readUntilClose(socket: Socket): Promise { + let received = ""; + const { promise, resolve } = Promise.withResolvers(); + socket.on("data", d => (received += d.toString("latin1"))); + socket.on("close", () => resolve(received)); + return promise; +} + +function chunkedPostHead(port: number): string { + return "POST / HTTP/1.1\r\n" + `Host: 127.0.0.1:${port}\r\n` + "Transfer-Encoding: chunked\r\n" + "\r\n"; +} + +test.concurrent.each([false, true])( "RequestContext is freed when client aborts before Promise settles (http2: %p)", async http2 => { - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - join(import.meta.dir, "serve-pending-promise-abort-leak-fixture.ts"), - ...(http2 ? ["--http2"] : []), - ], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", + // `held` keeps every handler promise reachable, so GC can't collect its NativePromiseContext cell and + // release the context. Only the abort can, and stop() below resolves once every abort did. + let abortCount = 0; + const held: Promise[] = []; + const handlerEnteredSignals: Array<() => void> = []; + using server = Bun.serve({ + port: 0, + idleTimeout: 0, + ...(http2 ? { tls, http2: true } : {}), + fetch(req) { + req.signal.addEventListener("abort", () => abortCount++, { once: true }); + handlerEnteredSignals.shift()?.(); + const parked = new Promise(() => {}); + held.push(parked); + return parked; + }, }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // Aborts after the handler installed its abort listener, so every abort is observed. HTTP/1.1 destroys + // a raw socket. HTTP/2 aborts a fetch(), which sends RST_STREAM on the shared connection. + async function abortOnce() { + const { promise: handlerEntered, resolve: signalHandler, reject: failHandler } = Promise.withResolvers(); + handlerEnteredSignals.push(signalHandler); + if (http2) { + const ac = new AbortController(); + const outcome = fetch(`https://127.0.0.1:${server.port}/`, { + protocol: "http2", + tls: { ca: tls.cert }, + signal: ac.signal, + }).then( + () => "resolved", + (e: Error) => e.name, + ); + // The handler never responds, so a fetch that settles before the handler ran has failed. + outcome.then(name => failHandler(new Error(`fetch settled before the handler ran: ${name}`))); + await handlerEntered; + ac.abort(); + expect(await outcome).toBe("AbortError"); + return; + } + const socket = connect(Number(server.port), "127.0.0.1", () => { + socket.write("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + }); + // destroy() emits no error, so any error here happened before the handler ran. + socket.on("error", failHandler); + await handlerEntered; + socket.destroy(); + } + + const iterations = 100; + for (let i = 0; i < iterations; i++) { + await abortOnce(); + } - expect(stderr).toBe(""); - const result = JSON.parse(stdout.trim()); - expect(result.pending).toBe(0); - expect(result.abortCount).toBe(result.iterations); - expect(exitCode).toBe(0); + await stopAndAssertDrained(server); + expect({ abortCount, held: held.length, pending: server.pendingRequests }).toEqual({ + abortCount: iterations, + held: iterations, + pending: 0, + }); + held.length = 0; }, ); -test("Promise still works normally when not aborted", async () => { +test.concurrent("Promise still works normally when not aborted", async () => { using server = Bun.serve({ port: 0, fetch() { @@ -51,12 +119,11 @@ test("Promise still works normally when not aborted", async () => { }); const res = await fetch(server.url); - expect(await res.text()).toBe("hello"); - expect(res.status).toBe(200); + expect([res.status, await res.text()]).toEqual([200, "hello"]); expect(server.pendingRequests).toBe(0); }); -test("resolve() inside abort handler is handled safely", async () => { +test.concurrent("resolve() inside abort handler is handled safely", async () => { let aborted = false; const { promise: handlerEntered, resolve: signalHandler } = Promise.withResolvers(); using server = Bun.serve({ @@ -80,56 +147,24 @@ test("resolve() inside abort handler is handled safely", async () => { }); const ac = new AbortController(); - const p = fetch(server.url, { signal: ac.signal }).catch(() => {}); + const outcome = fetch(server.url, { signal: ac.signal }).then( + () => "resolved", + (e: Error) => e.name, + ); await handlerEntered; ac.abort(); - await p; + expect(await outcome).toBe("AbortError"); await stopAndAssertDrained(server); expect(aborted).toBe(true); }); -test("streaming 413 detaches the response so a late resolve/reject is a no-op", async () => { - // Run in a subprocess: without the fix this is a heap-use-after-free under - // ASAN (render() corks a uWS socket that was freed when the 413 closed the - // connection — markDone() cleared onAborted so no abort ever detached - // ctx.resp). - await using proc = Bun.spawn({ - cmd: [bunExe(), join(import.meta.dir, "serve-413-streaming-late-resolve-fixture.ts")], - 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 lines = stdout - .trim() - .split("\n") - .map(l => JSON.parse(l)); - expect(lines).toEqual([ - { - case: "resolve", - status: "HTTP/1.1 413 Payload Too Large", - bodyErr: "Request body exceeded maxRequestBodySize", - pendingAfterResolve: 0, - followUp: { status: 200, text: "follow-up" }, - }, - { - case: "reject", - status: "HTTP/1.1 413 Payload Too Large", - pendingAfterReject: 0, - }, - ]); - expect(exitCode).toBe(0); -}, 30_000); - -test("chunked request body consumed as a ReadableStream is capped at maxRequestBodySize", async () => { +test.concurrent("chunked request body consumed as a ReadableStream is capped at maxRequestBodySize", async () => { // The up-front maxRequestBodySize check only sees Content-Length, and the // buffering branch of onBufferedBodyChunk only caps req.text()/.arrayBuffer(). // A chunked (no Content-Length) body consumed as a ReadableStream goes // through the streaming branch, which must also count and cap forwarded - // bytes — otherwise a single request streams unbounded data past the limit. + // bytes, otherwise a single request streams unbounded data past the limit. const limit = 1024; let streamed = 0; @@ -167,16 +202,7 @@ test("chunked request body consumed as a ReadableStream is capped at maxRequestB firstChunk = Promise.withResolvers(); handlerDone = Promise.withResolvers(); - const sock = connect(Number(server.port), "127.0.0.1"); - await new Promise((resolve, reject) => { - sock.on("connect", resolve); - sock.on("error", reject); - }); - // Once the limit trips the server ends the connection while the client is - // still writing chunks; EPIPE/ECONNRESET here is the expected outcome. - sock.removeAllListeners("error"); - sock.on("error", () => {}); - + const sock = await openRawSocket(server.port!); let received = ""; const { promise: gotResponse, resolve: doneReceiving } = Promise.withResolvers(); sock.on("data", d => { @@ -185,12 +211,7 @@ test("chunked request body consumed as a ReadableStream is capped at maxRequestB }); sock.on("close", () => doneReceiving()); - sock.write( - "POST / HTTP/1.1\r\n" + // - `Host: 127.0.0.1:${server.port}\r\n` + - "Transfer-Encoding: chunked\r\n" + - "\r\n", - ); + sock.write(chunkedPostHead(server.port!)); const piece = Buffer.alloc(256, "A").toString("latin1"); const writeChunk = () => @@ -214,22 +235,25 @@ test("chunked request body consumed as a ReadableStream is capped at maxRequestB // A chunked body under the limit still streams fully to the handler. const okStatus = await sendChunked(512); - expect(streamError).toBe(""); - expect(streamed).toBe(512); - expect(okStatus).toBe("HTTP/1.1 200 OK"); + expect({ status: okStatus, streamed, streamError }).toEqual({ + status: "HTTP/1.1 200 OK", + streamed: 512, + streamError: "", + }); // A chunked body over the limit is rejected: the stream read errors, the - // handler never sees the full payload, and the client gets a 413. - const overflowTotal = limit * 16; - const overflowStatus = await sendChunked(overflowTotal); - expect(overflowStatus).toBe("HTTP/1.1 413 Payload Too Large"); - expect(streamError).toBe("Request body exceeded maxRequestBodySize"); - expect(streamed).toBeLessThan(overflowTotal); + // handler never sees more than the limit, and the client gets a 413. + const overflowStatus = await sendChunked(limit * 16); + expect({ status: overflowStatus, streamError }).toEqual({ + status: "HTTP/1.1 413 Payload Too Large", + streamError: "Request body exceeded maxRequestBodySize", + }); + expect(streamed).toBeLessThanOrEqual(limit); await stopAndAssertDrained(server); -}, 15_000); +}); -test("client abort frees the context even while the resolve function stays reachable", async () => { +test.concurrent("client abort frees the context even while the resolve function stays reachable", async () => { // The held resolve function keeps the handler Promise (and so the // NativePromiseContext cell) alive forever, so GC can never release the // cell's ref on the RequestContext. The abort itself must reclaim it. @@ -252,10 +276,13 @@ test("client abort frees the context even while the resolve function stays reach }); const ac = new AbortController(); - const p = fetch(server.url, { signal: ac.signal }).catch(() => {}); + const outcome = fetch(server.url, { signal: ac.signal }).then( + () => "resolved", + (e: Error) => e.name, + ); await handlerEntered; ac.abort(); - await p; + expect(await outcome).toBe("AbortError"); await abortObserved; // The context is torn down on abort, not when GC collects the promise. @@ -317,7 +344,7 @@ const bodyConsumers: Array<[string, (req: Request, done: (v: unknown) => void) = ], ]; -test.each(bodyConsumers)( +test.concurrent.each(bodyConsumers)( "client abort while a direct stream pull() is parked frees the context and rejects a pending %s read", async (_name, consume) => { let pumpHold: Promise | undefined; @@ -346,97 +373,131 @@ test.each(bodyConsumers)( // Raw socket: send a chunked POST, deliver one partial chunk so the read // stays pending, then destroy the socket mid-stream. - const socket = connect(Number(server.port), "127.0.0.1"); - await new Promise((resolve, reject) => { - socket.on("connect", resolve); - socket.on("error", reject); - }); - socket.removeAllListeners("error"); - socket.on("error", () => {}); - socket.write( - "POST / HTTP/1.1\r\n" + // - `Host: 127.0.0.1:${server.port}\r\n` + - "Transfer-Encoding: chunked\r\n" + - "\r\n" + - "7\r\npartial\r\n", - ); + const socket = await openRawSocket(server.port!); + socket.write(chunkedPostHead(server.port!) + "7\r\npartial\r\n"); await firstWrite; socket.destroy(); // The cut-off upload rejects the pending read at abort time. - const err = await bodyRead; - expect(err).toBeInstanceOf(Error); - expect((err as Error).name).toBe("AbortError"); + const err = (await bodyRead) as DOMException; + expect(err).toBeInstanceOf(DOMException); + expect({ name: err.name, message: err.message }).toEqual({ + name: "AbortError", + message: "The connection was closed.", + }); await stopAndAssertDrained(server); pumpHold = undefined; }, ); -test("pendingRequests drops when the client aborts a parked direct-stream pull(), and the late pull() settle is a no-op", async () => { - // Same parked pull() scenario, driven through fetch() and an AbortController, - // with the pull() resolvers stashed in user state. Releasing them afterwards - // settles the pump promise of a context that is already gone: the stream - // reaction's take() returns null, and pendingRequests must not move. - const parked: Array<() => void> = []; - const pullEntered: Array<() => void> = []; - const pullSettled: Array<() => void> = []; +test.concurrent.each(["the handler responded", "the client disconnected"])( + "req.body of a complete upload is still readable after %s", + async ending => { + const upload = Buffer.alloc(1000, "u").toString(); + const { promise: ended, resolve: signalEnded } = Promise.withResolvers(); + const { promise: tookBody, resolve: signalTookBody } = Promise.withResolvers(); + const { promise: bodyRead, resolve: signalBodyRead } = Promise.withResolvers(); - using server = Bun.serve({ - port: 0, - idleTimeout: 0, - fetch() { - return new Response( - new ReadableStream({ - type: "direct", - async pull(c) { - c.write("x"); - await c.flush(); - pullEntered.shift()?.(); - await new Promise(r => parked.push(r)); - pullSettled.shift()?.(); - }, - }), - { headers: { "Content-Length": "100000" } }, - ); - }, - }); + using server = Bun.serve({ + port: 0, + async fetch(req) { + req.signal.addEventListener("abort", () => signalEnded()); + // The head and the upload arrive in one write, so the body is complete one loop turn later. + await new Promise(resolve => setImmediate(resolve)); + const body = req.body!; + signalBodyRead(ended.then(() => new Response(body).text())); + signalTookBody(); + if (ending === "the client disconnected") await ended; + return new Response(null, { status: 202 }); + }, + }); - async function abortWhileParked() { - const { promise: entered, resolve: markEntered } = Promise.withResolvers(); - pullEntered.push(markEntered); - const ac = new AbortController(); - const res = await fetch(server.url, { signal: ac.signal }); - expect(res.status).toBe(200); - const reader = res.body!.getReader(); - await reader.read(); - await entered; - ac.abort(); - await reader.closed.catch(() => {}); - } + const socket = await openRawSocket(server.port!); + const { promise: responded, resolve: signalResponded } = Promise.withResolvers(); + socket.once("data", () => signalResponded()); + socket.write( + `POST / HTTP/1.1\r\nHost: 127.0.0.1:${server.port}\r\nContent-Length: ${upload.length}\r\n\r\n${upload}`, + ); + await tookBody; + if (ending === "the handler responded") { + await responded; + signalEnded(); + } + socket.destroy(); - const iterations = 4; - for (let i = 0; i < iterations; i++) { - await abortWhileParked(); - } + expect(await bodyRead).toBe(upload); + await stopAndAssertDrained(server); + }, +); - // stop() resolves only once every abort tore its context down. - await stopAndAssertDrained(server); +test.concurrent( + "pendingRequests drops when the client aborts a parked direct-stream pull(), and the late pull() settle is a no-op", + async () => { + // Same parked pull() scenario, driven through fetch() and an AbortController, + // with the pull() resolvers stashed in user state. Releasing them afterwards + // settles the pump promise of a context that is already gone: the stream + // reaction's take() returns null, and pendingRequests must not move. + const parked: Array<() => void> = []; + const pullEntered: Array<() => void> = []; + const pullSettled: Array<() => void> = []; - // Release the parked pulls: each settle targets a context that is already - // gone. The stream reaction's take() returns null and the counter stays 0. - // Awaiting the settle signals orders the assertion after those reactions. - const resolvers = parked.splice(0); - expect(resolvers.length).toBe(iterations); - const settled = resolvers.map(() => { - const { promise, resolve } = Promise.withResolvers(); - pullSettled.push(resolve); - return promise; - }); - for (const r of resolvers) r(); - await Promise.all(settled); - Bun.gc(true); - expect(server.pendingRequests).toBe(0); -}); + using server = Bun.serve({ + port: 0, + idleTimeout: 0, + fetch() { + return new Response( + new ReadableStream({ + type: "direct", + async pull(c) { + c.write("x"); + await c.flush(); + pullEntered.shift()?.(); + await new Promise(r => parked.push(r)); + pullSettled.shift()?.(); + }, + }), + { headers: { "Content-Length": "100000" } }, + ); + }, + }); + + async function abortWhileParked() { + const { promise: entered, resolve: markEntered } = Promise.withResolvers(); + pullEntered.push(markEntered); + const ac = new AbortController(); + const res = await fetch(server.url, { signal: ac.signal }); + expect(res.status).toBe(200); + const reader = res.body!.getReader(); + await reader.read(); + await entered; + ac.abort(); + await reader.closed.catch(() => {}); + } + + const iterations = 4; + for (let i = 0; i < iterations; i++) { + await abortWhileParked(); + } + + // stop() resolves only once every abort tore its context down. + await stopAndAssertDrained(server); + + // Release the parked pulls: each settle targets a context that is already + // gone. The stream reaction's take() returns null and the counter stays 0. + // Awaiting the settle signals orders the assertion after those reactions. + const resolvers = parked.splice(0); + expect(resolvers.length).toBe(iterations); + const settled = resolvers.map(() => { + const { promise, resolve } = Promise.withResolvers(); + pullSettled.push(resolve); + return promise; + }); + for (const r of resolvers) r(); + await Promise.all(settled); + Bun.gc(true); + expect(server.pendingRequests).toBe(0); + }, +); // The abort tears the context down before the stream pump settles, so the // pump's cleanup of the Response body never runs. The body kept a strong ref @@ -508,14 +569,17 @@ test.each(["sync", "async"])( // Between the abort and the late settle, the server itself goes away: stop() // (issued before or after the abort) plus pendingRequests reaching 0 lets the // server release its JS wrapper, and dropping the last reference lets GC free -// it. Releasing the parked pull() after that must still be a no-op. +// it. Releasing the parked pull() after that must still be a no-op. A regression +// here is a crash, so this runs in a child process. for (const stopFirst of [true, false]) { - test(`releasing a parked pull() after the abort tore down the context and the server is a no-op (${stopFirst ? "stop-then-abort" : "abort-then-stop"})`, async () => { - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - ` + test.concurrent( + `releasing a parked pull() after the abort tore down the context and the server is a no-op (${stopFirst ? "stop-then-abort" : "abort-then-stop"})`, + async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` let release; const gate = new Promise(r => (release = r)); let pullDone; @@ -544,33 +608,35 @@ for (const stopFirst of [true, false]) { // The stop() promise resolves only once the abort tears the parked // context down. No Bun.gc before it: the abort itself has to do it. ${stopFirst ? "await stopped;" : "await server.stop();"} - if (server.pendingRequests !== 0) throw new Error("pendingRequests=" + server.pendingRequests); + const pendingAfterStop = server.pendingRequests; server = undefined; Bun.gc(true); release(); await pullExited; Bun.gc(true); - console.log("ok"); + console.log(JSON.stringify({ pendingAfterStop, pullExited: true })); `, - ], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + ], + 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.trim()).toBe("ok"); - expect(exitCode).toBe(0); - }); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ pendingAfterStop: 0, pullExited: true }); + expect({ exitCode, signalCode: proc.signalCode }).toEqual({ exitCode: 0, signalCode: null }); + }, + ); } -test("async server.upgrade() frees the context while the handler promise stays parked", async () => { +test.concurrent("async server.upgrade() frees the context while the handler promise stays parked", async () => { // The upgrade detaches the response and disarms onAborted, so neither // on_abort nor an end path can run afterwards. The upgrade itself must // reclaim the cell's ref, or the held resolve parks the context forever. let capturedResolve: ((r: Response) => void) | undefined; let capturedPromise: Promise | undefined; + const events: string[] = []; using server = Bun.serve({ port: 0, @@ -580,29 +646,38 @@ test("async server.upgrade() frees the context while the handler promise stays p capturedResolve = resolve; // Upgrade from a macrotask, so on_response parks the promise and // takes the cell ref before the upgrade runs. - setImmediate(() => srv.upgrade(req)); + setImmediate(() => events.push(`upgrade ${srv.upgrade(req)}`)); }); return capturedPromise; }, websocket: { + open() { + events.push("open"); + }, message() {}, + close(_ws, code) { + events.push(`close ${code}`); + }, }, }); const ws = new WebSocket(`ws://localhost:${server.port}/`); try { const { promise: opened, resolve: signalOpen, reject: failOpen } = Promise.withResolvers(); - const { promise: closed, resolve: signalClose } = Promise.withResolvers(); + const { promise: closed, resolve: signalClose } = Promise.withResolvers(); ws.onopen = () => signalOpen(); - ws.onclose = () => signalClose(); + ws.onclose = e => signalClose(e); ws.onerror = () => failOpen(new Error("websocket upgrade failed")); await opened; // Close the socket first: stop() also waits for open WebSockets. After // that, its resolution is exactly the context teardown the upgrade owes. - ws.close(); - await closed; + ws.close(1000); + const closeEvent = await closed; + expect([closeEvent.code, closeEvent.wasClean]).toEqual([1000, true]); await stopAndAssertDrained(server); + // open() runs inside upgrade(), before it returns. + expect(events).toEqual(["open", "upgrade true", "close 1000"]); // Resolving after the context is gone is a safe no-op: the reaction's // take() returns null. Awaiting the handler promise orders the assertion @@ -616,7 +691,53 @@ test("async server.upgrade() frees the context while the handler promise stays p } }); -test("413 on a chunked upload frees the context while the handler promise stays parked", async () => { +test.concurrent("streaming 413 detaches the response so a late resolve/reject is a no-op", async () => { + // A regression is a heap-use-after-free: the late settle corks a uWS response the 413 already freed. + // The child process keeps that crash out of the test runner. + await using proc = Bun.spawn({ + cmd: [bunExe(), join(import.meta.dir, "serve-413-streaming-late-resolve-fixture.ts")], + 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 + .trim() + .split("\n") + .map(line => JSON.parse(line)), + ).toEqual([ + { + settle: "resolve", + response: expect.stringMatching( + /^HTTP\/1\.1 413 Payload Too Large\r\nDate: [^\r]+\r\nConnection: close\r\n\r\n$/, + ), + bodyError: "Request body exceeded maxRequestBodySize", + pendingAfter413: 0, + outcome: "resolved", + pendingAfterSettle: 0, + errorCalls: 0, + followUp: [200, "follow-up"], + }, + { + settle: "reject", + response: expect.stringMatching( + /^HTTP\/1\.1 413 Payload Too Large\r\nDate: [^\r]+\r\nConnection: close\r\n\r\n$/, + ), + bodyError: "Request body exceeded maxRequestBodySize", + pendingAfter413: 0, + outcome: "rejected: late reject", + pendingAfterSettle: 0, + errorCalls: 0, + followUp: [200, "follow-up"], + }, + ]); + expect({ exitCode, signalCode: proc.signalCode }).toEqual({ exitCode: 0, signalCode: null }); +}); + +test.concurrent("413 on a chunked upload frees the context while the handler promise stays parked", async () => { // The 413 path ends the request through end_without_body, and uWS markDone() // clears onAborted, so on_abort can never run for this request. The held // resolve keeps the promise alive forever, so only the end path itself can @@ -636,55 +757,84 @@ test("413 on a chunked upload frees the context while the handler promise stays }, }); - const socket = connect(Number(server.port), "127.0.0.1"); - try { - await new Promise((resolve, reject) => { - socket.on("connect", resolve); - socket.on("error", reject); - }); - // The server closes the connection after the 413; EPIPE/ECONNRESET while - // we are still writing chunks is the expected outcome. - socket.removeAllListeners("error"); - socket.on("error", () => {}); + const socket = await openRawSocket(server.port!); + const response = readUntilClose(socket); + socket.write(chunkedPostHead(server.port!)); + await handlerEntered; - let received = ""; - const { promise: gotResponse, resolve: signalResponse } = Promise.withResolvers(); - socket.on("data", d => { - received += d.toString("latin1"); - if (received.includes("\r\n\r\n")) signalResponse(); + // Keep writing chunks until the server closes the connection. + const piece = Buffer.alloc(512, "A").toString("latin1"); + for (let sent = 0; sent < 4096 && !socket.destroyed; sent += piece.length) { + await new Promise(resolve => { + if (socket.destroyed) return resolve(); + socket.write(piece.length.toString(16) + "\r\n" + piece + "\r\n", () => resolve()); }); - socket.on("close", () => signalResponse()); + } - socket.write( - "POST / HTTP/1.1\r\n" + // - `Host: 127.0.0.1:${server.port}\r\n` + - "Transfer-Encoding: chunked\r\n" + - "\r\n", - ); - await handlerEntered; + // The 413 tears the context down before its bytes reach the client, with the resolve still held. + expect(await response).toMatch(/^HTTP\/1\.1 413 Payload Too Large\r\nDate: [^\r]+\r\nConnection: close\r\n\r\n$/); + expect(server.pendingRequests).toBe(0); - const piece = Buffer.alloc(512, "A").toString("latin1"); - for (let sent = 0; sent < 4096 && !socket.destroyed; sent += piece.length) { - await new Promise(resolve => { - if (socket.destroyed) return resolve(); - socket.write(piece.length.toString(16) + "\r\n" + piece + "\r\n", () => resolve()); - }); - } + // The 413 closed the connection, so stop() waits only on the teardown. + await stopAndAssertDrained(server); + capturedResolve = undefined; +}); - await gotResponse; - // An early close resolves gotResponse too; fail on the missing response - // before the status-line comparison so the cause is visible. - expect(received).not.toBe(""); - expect(received.split("\r\n")[0]).toBe("HTTP/1.1 413 Payload Too Large"); +const responsesInFlight: Array<[string, (upstream: URL, onCancel: (reason: unknown) => void) => unknown]> = [ + ["a stream that has written nothing", (_upstream, cancel) => new Response(new ReadableStream({ cancel }))], + ["a proxied fetch() body", upstream => fetch(upstream)], +]; +test.concurrent.each(responsesInFlight)( + "a chunked upload that crosses maxRequestBodySize under %s aborts the request", + async (_name, respond) => { + const { promise: upstreamAborted, resolve: signalUpstreamAborted } = Promise.withResolvers(); + await using upstream = Bun.serve({ + port: 0, + idleTimeout: 0, + fetch(req) { + req.signal.addEventListener("abort", () => signalUpstreamAborted()); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("never ends")); + }, + }), + ); + }, + }); + + const { promise: aborted, resolve: signalAborted } = Promise.withResolvers(); + const { promise: bodyRead, resolve: signalBodyRead } = Promise.withResolvers(); + const { promise: cancelled, resolve: signalCancelled } = Promise.withResolvers(); + using server = Bun.serve({ + port: 0, + idleTimeout: 0, + maxRequestBodySize: 32, + fetch(req) { + req.signal.addEventListener("abort", () => signalAborted(req.signal.reason)); + req.text().then(signalBodyRead, signalBodyRead); + return respond(upstream.url, signalCancelled) as Response; + }, + }); - // The context is torn down by the 413, not by GC collecting the promise. - // The 413 closed the connection, so stop() waits only on the teardown. + const socket = connect(server.port!, "127.0.0.1"); + const { promise: closed, resolve: signalClosed } = Promise.withResolvers(); + let ending = "FIN"; + socket.on("error", (err: NodeJS.ErrnoException) => (ending = err.code!)); + socket.on("close", () => signalClosed(ending)); + // The response has started once its status line arrives. + socket.once("data", () => socket.write(`40\r\n${Buffer.alloc(64, "A")}\r\n`)); + socket.write(chunkedPostHead(server.port!) + "7\r\npartial\r\n"); + + // A FIN would end a body that has no length yet as if it were complete. + expect(await closed).toBe("ECONNRESET"); + expect(await aborted).toMatchObject({ name: "AbortError", message: "Request body exceeded maxRequestBodySize" }); + expect(await bodyRead).toMatchObject({ name: "Error", message: "Request body exceeded maxRequestBodySize" }); + if (respond.length === 2) expect(await cancelled).toMatchObject({ name: "AbortError" }); + else await upstreamAborted; await stopAndAssertDrained(server); - capturedResolve = undefined; - } finally { - socket.destroy(); - } -}); + }, +); // A request subscribes to its connection's close only once its dispatch is // over (to_async), so a close that lands before that was lost. server.stop(true) @@ -702,44 +852,47 @@ const stoppedRequests: Array<[string, string, string[]]> = [ ["abort http://example.com/stopped example.com", "text rejected: AbortError"], ], ]; -test.each(stoppedRequests)("server.stop(true) inside the handler of %s aborts it", async (_what, head, expected) => { - const events: string[] = []; - const { promise: reached, resolve: signalReached, reject: failReached } = Promise.withResolvers(); - let stopped: Promise; - using server = Bun.serve({ - port: 0, - idleTimeout: 0, - fetch(req, srv) { - // url and headers are read lazily from inside the listener: an abort - // delivered this way must still see them, like any other abort. - req.signal.addEventListener("abort", () => events.push(`abort ${req.url} ${req.headers.get("host")}`), { - once: true, - }); - if (req.method === "POST") { - req.text().then( - () => events.push("text resolved"), - e => events.push(`text rejected: ${(e as Error).name}`), - ); - } - stopped = srv.stop(true); - signalReached(); - return new Promise(() => {}); - }, - }); +test.concurrent.each(stoppedRequests)( + "server.stop(true) inside the handler of %s aborts it", + async (_what, head, expected) => { + const events: string[] = []; + const { promise: reached, resolve: signalReached, reject: failReached } = Promise.withResolvers(); + let stopped: Promise; + using server = Bun.serve({ + port: 0, + idleTimeout: 0, + fetch(req, srv) { + // url and headers are read lazily from inside the listener: an abort + // delivered this way must still see them, like any other abort. + req.signal.addEventListener("abort", () => events.push(`abort ${req.url} ${req.headers.get("host")}`), { + once: true, + }); + if (req.method === "POST") { + req.text().then( + () => events.push("text resolved"), + e => events.push(`text rejected: ${(e as Error).name}`), + ); + } + stopped = srv.stop(true); + signalReached(); + return new Promise(() => {}); + }, + }); - const client = connect(Number(server.port), "127.0.0.1", () => client.write(head)); - // A reset after the server closed the connection is expected; a failure - // before the handler ran is not. - client.on("error", failReached); + const client = connect(Number(server.port), "127.0.0.1", () => client.write(head)); + // A reset after the server closed the connection is expected; a failure + // before the handler ran is not. + client.on("error", failReached); - await reached; - // The abort is delivered as the dispatch finishes; an immediate queued from - // inside it runs after that. - await new Promise(resolve => setImmediate(resolve)); - expect(events).toEqual(expected); - expect(server.pendingRequests).toBe(0); - await stopped!; -}); + await reached; + // The abort is delivered as the dispatch finishes; an immediate queued from + // inside it runs after that. + await new Promise(resolve => setImmediate(resolve)); + expect(events).toEqual(expected); + expect(server.pendingRequests).toBe(0); + await stopped!; + }, +); // A request head that arrives split over two reads is parsed out of the HTTP // parser's per-socket fallback buffer, and the uWS request the dispatch holds diff --git a/test/js/bun/http/serve-response-stream-sink-leak-fixture.ts b/test/js/bun/http/serve-response-stream-sink-leak-fixture.ts index 01626d5cfaa0..37d1396028f8 100644 --- a/test/js/bun/http/serve-response-stream-sink-leak-fixture.ts +++ b/test/js/bun/http/serve-response-stream-sink-leak-fixture.ts @@ -27,6 +27,7 @@ const server = Bun.serve({ }); const url = server.url.href; +let ok = 0; async function once() { pulled = Promise.withResolvers(); @@ -34,9 +35,23 @@ async function once() { await pulled.promise; controller.end(); const res = await resPromise; - await res.arrayBuffer(); + if ((await res.text()) === "x") ok++; } +// Run N requests and exit, so LeakSanitizer reports the unfreed sinks. +if (process.env.SINK_LEAK_MODE === "lsan") { + const iterations = Number(process.env.SINK_LEAK_ITERATIONS); + for (let i = 0; i < iterations; i++) await once(); + server.stop(true); + Bun.gc(true); + await Bun.sleep(0); + Bun.gc(true); + process.stdout.write(JSON.stringify({ ok, iterations })); + process.exit(0); +} + +const iterations = 10000; + // RSS, not currentCommit (which ratchets under hole purging, a discarded hole // stays "committed"); a leaked sink keeps its page resident, so RSS sees it. // Floor over rounds spanning the 100ms sweep rate limit drops transient peaks. @@ -50,8 +65,6 @@ async function settledRss() { return min; } -const iterations = 10000; - async function round() { for (let i = 0; i < iterations; i++) { await once(); @@ -80,4 +93,4 @@ for (let r = 0; r < 3; r++) { server.stop(true); const delta = [...deltas].sort((a, b) => a - b)[Math.floor(deltas.length / 2)]; -process.stdout.write(JSON.stringify({ delta, deltas, iterations })); +process.stdout.write(JSON.stringify({ delta, deltas, iterations, ok })); diff --git a/test/js/bun/http/serve-response-stream-sink-leak.test.ts b/test/js/bun/http/serve-response-stream-sink-leak.test.ts index b360f25fd73b..0fc60bb0ec6f 100644 --- a/test/js/bun/http/serve-response-stream-sink-leak.test.ts +++ b/test/js/bun/http/serve-response-stream-sink-leak.test.ts @@ -2,6 +2,9 @@ import { expect, test } from "bun:test"; import { bunEnv, bunExe, isASAN } from "harness"; import { join } from "node:path"; +const fixture = join(import.meta.dir, "serve-response-stream-sink-leak-fixture.ts"); +const leaksanSupp = join(import.meta.dir, "..", "..", "..", "leaksan.supp"); + // Regression: doRenderStream allocates a ResponseStream.JSSink on the heap // and stores it in RequestContext.sink. A direct stream whose pull() returns // synchronously without ending the sink keeps the request alive until @@ -10,8 +13,42 @@ import { join } from "node:path"; // RequestContext.sink), otherwise the allocation plus its pooled buffer // leaks on every such request. test("HTTPResponseSink is destroyed after a sync pull() that ends later", async () => { + if (isASAN) { + // LeakSanitizer reports the unfreed Box directly. RSS needs ~60k requests to + // settle under ASAN's quarantine. Comparing two run sizes cancels one-time leaks at exit. + async function run(iterations: number) { + await using proc = Bun.spawn({ + cmd: [bunExe(), fixture], + env: { + ...bunEnv, + SINK_LEAK_MODE: "lsan", + SINK_LEAK_ITERATIONS: String(iterations), + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=1"].filter(Boolean).join(":"), + // One-time leaks that aren't suppressed would make LSan exit 1 without exitcode=0. + LSAN_OPTIONS: `suppressions=${leaksanSupp}:exitcode=0`, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, exitCode }, stderr).toEqual({ + stdout: JSON.stringify({ ok: iterations, iterations }), + exitCode: 0, + }); + const m = /SUMMARY: AddressSanitizer: (\d+) byte\(s\) leaked/.exec(stderr); + return { bytes: m ? Number(m[1]) : 0, stderr }; + } + const [small, large] = await Promise.all([run(10), run(100)]); + const perRequestLeak = large.bytes - small.bytes; + console.log({ small: small.bytes, large: large.bytes, perRequestLeak }); + // A leaked ResponseStreamJSSink is ~176 B, so 90 extra requests would leak ~15840 B. + expect(perRequestLeak, large.stderr).toBeLessThan(2000); + return; + } + + // Without LeakSanitizer, measure RSS growth. await using proc = Bun.spawn({ - cmd: [bunExe(), join(import.meta.dir, "serve-response-stream-sink-leak-fixture.ts")], + cmd: [bunExe(), fixture], env: bunEnv, stdout: "pipe", stderr: "pipe", @@ -25,11 +62,12 @@ test("HTTPResponseSink is destroyed after a sync pull() that ends later", async printedResult: true, exitCode: 0, }); - const { delta, deltas, iterations } = JSON.parse(stdout); + const { delta, deltas, iterations, ok } = JSON.parse(stdout); console.log({ deltas, iterations, perRequest: (delta / iterations).toFixed(1) }); + expect(ok).toBe(iterations * 6); // `delta` is the median RSS growth per 10k requests (settledRss in the fixture // explains RSS over currentCommit). macOS debug: 1.0 MB fixed vs 3.5 MB leaking // (~350 B/req); Linux release: flat fixed vs +4.1 MB on the original #29877 leak. - expect(delta).toBeLessThan(2 * 1024 * 1024 * (isASAN ? 2 : 1)); -}, 300_000); + expect(delta).toBeLessThan(2 * 1024 * 1024); +}, 60_000); diff --git a/test/js/bun/http/serve-reused-response.test.ts b/test/js/bun/http/serve-reused-response.test.ts index d05c05fbd389..9b5116a2f8fc 100644 --- a/test/js/bun/http/serve-reused-response.test.ts +++ b/test/js/bun/http/serve-reused-response.test.ts @@ -5,7 +5,7 @@ import { bunEnv, bunExe } from "harness"; // A fetch handler that returns a Response whose body has already been used // (most often the same Response object returned for every request) must invoke // the error handler instead of silently sending a 200 with an empty body. -describe("returning a Response with an already-used body", () => { +describe.concurrent("returning a Response with an already-used body", () => { const alreadyUsedError = { code: "ERR_BODY_ALREADY_USED", name: "TypeError", @@ -193,6 +193,30 @@ describe("returning a Response with an already-used body", () => { expect(exitCode).toBe(0); }); + // getReader() starts a native-backed stream, which sets bodyUsed before anything is read. + it.each([ + ["string", () => new Response("whole body")], + ["Blob", () => new Response(new Blob(["whole body"]))], + ["fetch()", (upstream: string) => fetch(upstream)], + ])("a %s body is sent whole after getReader() and releaseLock()", async (_, makeResponse) => { + const error = jest.fn(); + await using upstream = serve({ port: 0, fetch: () => new Response("whole body") }); + await using server = serve({ + port: 0, + async fetch() { + const response = await makeResponse(`http://127.0.0.1:${upstream.port}/`); + response.body!.getReader().releaseLock(); + return response; + }, + error, + }); + + const response = await fetch(`http://127.0.0.1:${server.port}/`); + expect(await response.text()).toBe("whole body"); + expect(response.status).toBe(200); + expect(error).not.toHaveBeenCalled(); + }); + it("a Response that is not reused keeps working", async () => { const error = jest.fn(); await using server = serve({ @@ -213,6 +237,7 @@ describe("returning a Response with an already-used body", () => { it("a Response reused through a static route keeps working", async () => { // Static routes snapshot the body at registration, so reuse is allowed there. + const error = jest.fn(); await using server = serve({ port: 0, routes: { @@ -221,6 +246,7 @@ describe("returning a Response with an already-used body", () => { fetch() { return new Response("fallback"); }, + error, }); for (let i = 0; i < 3; i++) { @@ -228,6 +254,7 @@ describe("returning a Response with an already-used body", () => { expect(await response.text()).toBe("static-body"); expect(response.status).toBe(200); } + expect(error).not.toHaveBeenCalled(); }); it("without an error handler, logs the error and responds with 500", async () => { @@ -261,3 +288,139 @@ describe("returning a Response with an already-used body", () => { expect(exitCode).toBe(1); }); }); + +describe.concurrent("a Response without a body returned for a request the server dropped", () => { + const bodiless = { + "null body": () => new Response(null, { status: 404 }), + "empty string body": () => new Response(""), + }; + const kinds = Object.keys(bodiless) as (keyof typeof bodiless)[]; + + async function expectReusable(url: URL, kept: Response) { + const res = await fetch(url); + expect({ status: res.status, body: await res.text(), bodyUsed: kept.bodyUsed }).toEqual({ + status: kept.status, + body: "", + bodyUsed: false, + }); + } + + it.each(kinds)("%s can be returned again after the client aborted", async kind => { + const kept = bodiless[kind](); + const started = Promise.withResolvers(); + const returned = Promise.withResolvers(); + await using server = serve({ + port: 0, + async fetch(req) { + if (!req.url.endsWith("/drop")) return kept; + const aborted = Promise.withResolvers(); + req.signal.addEventListener("abort", () => aborted.resolve()); + started.resolve(); + await aborted.promise; + returned.resolve(); + return kept; + }, + error: (err: any) => new Response(err.code, { status: 500 }), + }); + + const controller = new AbortController(); + const dropped = fetch(new URL("/drop", server.url), { signal: controller.signal }); + await started.promise; + controller.abort(); + await expect(dropped).rejects.toMatchObject({ name: "AbortError" }); + await returned.promise; + await expectReusable(server.url, kept); + }); + + it.each(kinds)("%s can be returned again after server.upgrade()", async kind => { + const kept = bodiless[kind](); + await using server = serve({ + port: 0, + fetch(req, server) { + server.upgrade(req); + return kept; + }, + websocket: { message() {} }, + error: (err: any) => new Response(err.code, { status: 500 }), + }); + + const closed = Promise.withResolvers(); + const ws = new WebSocket(server.url.href.replace("http:", "ws:")); + ws.onopen = () => ws.close(); + ws.onerror = () => closed.reject(new Error("the WebSocket did not open")); + ws.onclose = () => closed.resolve(); + await closed.promise; + await expectReusable(server.url, kept); + }); +}); + +describe.concurrent("a Response around a stream that a reader holds", () => { + it("HEAD does not cancel a stream that another response is sending", async () => { + let controller!: ReadableStreamDefaultController; + const cancel = jest.fn(); + const stream = new ReadableStream({ + start(c) { + controller = c; + c.enqueue(new TextEncoder().encode("part1;")); + }, + cancel, + }); + const responses: Record = { "/a": new Response(stream), "/b": new Response(stream) }; + await using server = serve({ + port: 0, + fetch: req => responses[new URL(req.url).pathname], + }); + + const download = await fetch(new URL("/a", server.url)); + const reader = download.body!.getReader(); + expect(new TextDecoder().decode((await reader.read()).value)).toBe("part1;"); + + const head = await fetch(new URL("/b", server.url), { method: "HEAD" }); + expect(head.status).toBe(200); + + controller.enqueue(new TextEncoder().encode("part2;")); + controller.close(); + expect(new TextDecoder().decode((await reader.read()).value)).toBe("part2;"); + expect((await reader.read()).done).toBe(true); + expect(cancel).not.toHaveBeenCalled(); + }); + + it("refusing the Response leaves the stream open for its reader", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const encoder = new TextEncoder(); + let controller; + const upstream = Bun.serve({ + port: 0, + fetch: () => new Response(new ReadableStream({ start(c) { controller = c; c.enqueue(encoder.encode("one;")); } })), + }); + const text = Promise.withResolvers(); + const server = Bun.serve({ + port: 0, + development: false, + async fetch() { + const response = await fetch(upstream.url); + text.resolve(new Response(response.body).text()); + return response; + }, + }); + console.log((await fetch(server.url)).status); + controller.enqueue(encoder.encode("two;")); + controller.close(); + console.log(await text.promise); + upstream.stop(true); + server.stop(true);`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain("ERR_STREAM_CANNOT_PIPE"); + expect(stdout).toBe("500\none;two;\n"); + expect(exitCode).toBe(1); + }); +}); diff --git a/test/js/bun/http/serve-stream-body-error-fixture.ts b/test/js/bun/http/serve-stream-body-error-fixture.ts index 95d39e1cfcd2..6ae653a46dda 100644 --- a/test/js/bun/http/serve-stream-body-error-fixture.ts +++ b/test/js/bun/http/serve-stream-body-error-fixture.ts @@ -38,9 +38,9 @@ function chunkReachedClient(): Promise { // Set by the variants that fail after the status line but before any body byte // (pending-error-after-headers, proxied-fetch-after-status) so they only error -// once the status line has provably reached the client socket. (uWS terminates -// the header block only with the first body byte, so a blank line never -// arrives for these variants.) The forced close that follows sends a RST, and +// once the status line has provably reached the client socket. (The server +// sends the whole header section for a body that has no bytes yet, so the +// status line arrives with it.) The forced close that follows sends a RST, and // a RST can discard data the peer has not read yet (Windows always does), so // the error must not race the client's read. let statusLineReceivedResolve: (() => void) | undefined; diff --git a/test/js/bun/http/serve-stream-reject-flush-leak-fixture.ts b/test/js/bun/http/serve-stream-reject-flush-leak-fixture.ts index 19071914c6fd..95bbcfc8d4a8 100644 --- a/test/js/bun/http/serve-stream-reject-flush-leak-fixture.ts +++ b/test/js/bun/http/serve-stream-reject-flush-leak-fixture.ts @@ -5,17 +5,20 @@ // lifetime of the VM. // // Repro: -// 1. Go async first (setImmediate) so RequestContext.toAsync() has already -// registered the uWS abort handler and doRenderStream is parked on the -// pending pull() promise (onResolveStream/onRejectStream wired up). -// 2. Bump the sink's highWaterMark so write() buffers instead of draining +// 1. Bump the sink's highWaterMark so write() buffers instead of draining // straight to res.write() — this keeps isHttpWriteCalled() false so // end() takes the tryEnd() path. -// 3. Write a chunk large enough that tryEnd() hits socket backpressure on +// 2. Write a chunk large enough that tryEnd() hits socket backpressure on // a non-reading client → pending_flush is created + protected. +// 3. Go async (setImmediate) so RequestContext.toAsync() has registered the +// uWS abort handler and doRenderStream is parked on the pending pull() +// promise (onResolveStream/onRejectStream wired up). // 4. Throw, rejecting the pull() promise → onRejectStream → // handleRejectStream() with pending_flush still set. // +// write() and end() come before the wait. If pull() waited first, the server +// would send the head (chunked), and a later end() would skip the tryEnd() path. +// // Without the fix, protected Promise count grows by ~1 per iteration. import { heapStats } from "bun:jsc"; @@ -38,13 +41,13 @@ const server = Bun.serve({ new ReadableStream({ type: "direct", async pull(controller: any) { - await new Promise(r => setImmediate(() => r())); controller.start({ highWaterMark: CHUNK.length + 1 }); controller.write(CHUNK); const p = controller.end(); if (p instanceof Promise && Bun.peek.status(p) === "pending") { flushPending++; } + await new Promise(r => setImmediate(() => r())); // Tear down the client after handleRejectStream has run. The throw // below rejects pull()'s promise; onRejectStream → handleRejectStream // fires on the microtask queue, then this setImmediate fires on the diff --git a/test/js/bun/http/serve-syscall-fault.test.ts b/test/js/bun/http/serve-syscall-fault.test.ts index fb8a596803fe..42553e09f5fa 100644 --- a/test/js/bun/http/serve-syscall-fault.test.ts +++ b/test/js/bun/http/serve-syscall-fault.test.ts @@ -190,6 +190,51 @@ describe.skipIf(skip)("Bun.serve under injected syscall faults", () => { expect(proc.signalCode).toBeNull(); expect(proc.exitCode).toBe(0); }); + + test("poll_start → Bun.serve throws the injected errno", async () => { + using dir = tempDir("serve-listen-fault", {}); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + /* js */ ` + const { socketFaultInjection: fault } = require("bun:internal-for-testing"); + const { errno } = require("os").constants; + const { getSystemErrorName } = require("util"); + const out = {}; + for (const [label, name, address] of [ + ["tcp EACCES", "EACCES", { hostname: "127.0.0.1", port: 0 }], + ["tcp ENOBUFS", "ENOBUFS", { hostname: "127.0.0.1", port: 0 }], + ["unix ENOBUFS", "ENOBUFS", { unix: process.env.SOCKET_PATH }], + ]) { + fault.set({ syscall: "poll_start", action: "errno", errno: errno[name], repeat: 1 }); + try { + Bun.serve({ ...address, fetch: () => new Response() }).stop(true); + out[label] = "listening"; + } catch (e) { + out[label] = [e.code, e.syscall, e.errno < 0 ? getSystemErrorName(e.errno) : e.errno]; + } finally { + fault.clear(); + } + } + console.log(JSON.stringify(out)); + `, + ], + env: { ...bunEnv, SOCKET_PATH: join(String(dir), "fault.sock") }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ out: JSON.parse(stdout || "null"), stderr }).toEqual({ + out: { + "tcp EACCES": ["EACCES", "listen", "EACCES"], + "tcp ENOBUFS": ["ENOBUFS", "listen", "ENOBUFS"], + "unix ENOBUFS": ["ENOBUFS", "listen", "ENOBUFS"], + }, + stderr: "", + }); + expect(exitCode).toBe(0); + }); }); // The dispatcher removes a paused socket from the epoll set when its peer hangs up, so resuming it diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index 5c0da1622716..bddb0ee3d2dd 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -20,6 +20,7 @@ import { libcPathForDlopen, MAX_PATH_BYTES, runFixtureMaxRSS, + sweepSocketTimeouts, tempDir, tls, tmpdirSync, @@ -28,6 +29,7 @@ import { connect } from "net"; import { join, resolve } from "path"; // import { renderToReadableStream } from "react-dom/server"; // import app_jsx from "./app.jsx"; +import { setSocketOptions } from "bun:internal-for-testing"; import { heapStats } from "bun:jsc"; import { spawn } from "child_process"; import { on, once } from "node:events"; @@ -190,12 +192,12 @@ it("should call cancel() on ReadableStream when the Request is aborted", async ( async server => { const controller = new AbortController(); const signal = controller.signal; - const request = fetch(server.url, { signal }); + // The head arrives before the body has a chunk, so fetch() resolves and the abort fails the body read. + const response = await fetch(server.url, { signal }); await onIncomingRequest.promise; controller.abort(); - expect(async () => await request).toThrow(); - // Delay for one run of the event loop. - await Bun.sleep(1); + await expect(response.text()).rejects.toMatchObject({ name: "AbortError" }); + await waitForCancel.promise; expect(abortedFn).toHaveBeenCalled(); expect(cancelledFn).toHaveBeenCalled(); @@ -266,6 +268,39 @@ describe("HEAD request with a ReadableStream body", () => { }); } }); + +describe("HEAD request answered with a fetch() Response whose body was never read", () => { + it.each([ + ["fetch()", (url: URL) => fetch(url)], + ["HTMLRewriter.transform(await fetch())", async (url: URL) => new HTMLRewriter().transform(await fetch(url))], + ])("%s aborts the upstream request", async (_name, proxied) => { + const aborted = Promise.withResolvers(); + await using upstream = Bun.serve({ + port: 0, + idleTimeout: 0, + fetch(req) { + req.signal.addEventListener("abort", () => aborted.resolve()); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("never ends")); + }, + }), + ); + }, + }); + await using proxy = Bun.serve({ + port: 0, + fetch: () => proxied(upstream.url), + }); + + const res = await fetch(proxy.url, { method: "HEAD" }); + expect(res.status).toBe(200); + expect(await res.text()).toBe(""); + await aborted.promise; + }); +}); + for (let withDelay of [true, false]) { for (let connectionHeader of ["keepalive", "not keepalive"] as const) { it(`should NOT call cancel() on ReadableStream that finished normally for ${connectionHeader} request and ${withDelay ? "with" : "without"} delay`, async () => { @@ -353,6 +388,9 @@ describe.todoIf(isBroken && isIntelMacOS)( }, async server => { const count = 1000; + const batchSize = 64; + let completed = 0; + const wrongDigests: string[] = []; async function callback() { const response = await fetch(server.url, { body: blob, @@ -366,23 +404,20 @@ describe.todoIf(isBroken && isIntelMacOS)( } const digest = Buffer.from(Bun.concatArrayBuffers(chunks)).toString(); + if (digest !== expected) wrongDigests.push(digest); - expect(digest).toBe(expected); - Bun.gc(false); + // Collect while the rest of the batch is in flight. CI sets BUN_GARBAGE_COLLECTOR_LEVEL, + // which makes every expect() collect too, so the digests are checked once at the end. + if (++completed % 4 === 0) Bun.gc(false); } - { - let remaining = count; - - const batchSize = 64; - while (remaining > 0) { - const promises = new Array(count); - for (let i = 0; i < batchSize && remaining > 0; i++) { - promises[i] = callback(); - } - await Promise.all(promises); - remaining -= batchSize; + for (let remaining = count; remaining > 0; remaining -= batchSize) { + const batch: Promise[] = []; + for (let i = 0; i < Math.min(batchSize, remaining); i++) { + batch.push(callback()); } + await Promise.all(batch); } + expect({ completed, wrongDigests }).toEqual({ completed: count, wrongDigests: [] }); Bun.gc(true); dumpStats(); @@ -619,8 +654,8 @@ it("should work for a file", async () => { ); }); -it("keeps serving from a Worker after a request for a file that doesn't exist", async () => { - using dir = tempDir("serve-worker-missing-file", { +it("keeps serving from a Worker after an error that no error() handles", async () => { + using dir = tempDir("serve-worker-unhandled-error", { "a.txt": "hello", "server.mjs": ` import { join } from "node:path"; @@ -628,7 +663,11 @@ it("keeps serving from a Worker after a request for a file that doesn't exist", const server = Bun.serve({ port: 0, hostname: "127.0.0.1", - fetch: req => new Response(Bun.file(join(import.meta.dirname, new URL(req.url).pathname))), + fetch(req) { + const { pathname } = new URL(req.url); + if (pathname === "/throws") throw new Error("thrown by the handler"); + return new Response(Bun.file(join(import.meta.dirname, pathname))); + }, }); parentPort.postMessage(server.port); `, @@ -639,8 +678,12 @@ it("keeps serving from a Worker after a request for a file that doesn't exist", try { const [port] = await once(worker, "message"); const get = (path: string) => fetch(`http://127.0.0.1:${port}${path}`).then(res => res.status); - const statuses = [await get("/missing.txt"), await get("/a.txt").catch(error => error.code)]; - expect({ statuses, errors }).toEqual({ statuses: [500, 200], errors: [] }); + const statuses = [ + await get("/missing.txt"), + await get("/throws").catch(error => error.code), + await get("/a.txt").catch(error => error.code), + ]; + expect({ statuses, errors }).toEqual({ statuses: [404, 500, 200], errors: [] }); } finally { await worker.terminate(); } @@ -1701,29 +1744,31 @@ it("reload() cannot turn a Bun.serve server into a node:http server", async () = // per-socket block; a reload that smuggles in the node:http handler used by // node's http.Server wrapper must be ignored, not flip the context into // node-compat mode under connections allocated with the smaller block. - const first: Handler = () => new Response("first"); - const second: Handler = () => new Response("second"); - await runTest( - { - fetch: first, - }, - async server => { - expect(await (await fetch(server.url.origin)).text()).toBe("first"); - server.reload({ - fetch: second, - // @ts-expect-error internal option used by node:http's Server - onNodeHTTPRequest: () => { - throw new Error("must never be routed"); - }, - }); - // Fresh connections after the reload (fetch pools per-origin, so mix in - // explicit no-keepalive requests to force new native sockets). - for (let i = 0; i < 8; i++) { - const res = await fetch(server.url.origin, { headers: { connection: "close" } }); - expect(await res.text()).toBe("second"); - } + const clientPorts: number[] = []; + const respond = (body: string) => (req: Request, server: Server) => { + clientPorts.push(server.requestIP(req)!.port); + return new Response(body); + }; + // Uses its own server, so fetch's only pooled connection to it predates the reload. + using server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: respond("first") }); + expect(await (await fetch(server.url)).text()).toBe("first"); + server.reload({ + fetch: respond("second"), + // @ts-expect-error internal option used by node:http's Server + onNodeHTTPRequest: () => { + throw new Error("must never be routed"); }, - ); + }); + expect(await (await fetch(server.url)).text()).toBe("second"); + for (let i = 0; i < 8; i++) { + const res = await fetch(server.url, { keepalive: false }); + expect(await res.text()).toBe("second"); + } + // The client port identifies the connection. + const [beforeReload, reused, ...fresh] = clientPorts; + expect(reused).toBe(beforeReload); + expect(fresh).toHaveLength(8); + expect(fresh).not.toContain(beforeReload); }); it("reload() that drops the node:http handler keeps the server's node:http stop() semantics", async () => { @@ -2324,6 +2369,36 @@ describe("response framing", () => { }); }); + // node:http and undici reject a response with two Content-Length headers. + it("sends one Content-Length header on the dev missing-response page", async () => { + using server = Bun.serve({ port: 0, hostname: "127.0.0.1", development: true, fetch: () => undefined as any }); + const contentLengthLines = (extraHeaders: string) => + new Promise((resolve, reject) => { + let raw = ""; + const s = net.connect(server.port, "127.0.0.1", () => { + s.write(`GET / HTTP/1.1\r\nHost: x\r\n${extraHeaders}Connection: close\r\n\r\n`); + }); + s.on("data", d => (raw += d.toString("latin1"))); + s.on("error", reject); + s.on("close", () => { + const separator = raw.indexOf("\r\n\r\n"); + if (separator < 0) { + reject(new Error("server closed before sending a complete HTTP header block")); + return; + } + const head = raw.slice(0, separator); + resolve(head.split("\r\n").filter(l => /^content-length:/i.test(l))); + }); + }); + expect({ + text: await contentLengthLines(""), + browser: await contentLengthLines("Sec-Fetch-Dest: document\r\n"), + }).toEqual({ + text: ["Content-Length: 57"], + browser: [expect.stringMatching(/^Content-Length: \d+$/)], + }); + }); + // The default-500 and dev-error-page paths report the thrown error via // on_unhandled_rejection, which would fail an in-process test, so run // them (and the keep-alive desync witness) in a subprocess. @@ -2483,6 +2558,42 @@ it.concurrent("dev error page embeds the thrown error, its stack, and build/reso expect(exitCode).toBe(0); }); +it.concurrent("dev error page caps each source line at 1024 bytes", async () => { + // The 3-byte "€" starts at byte 1023, so the cap lands inside it. + const head = `var keep=["${Buffer.alloc(1012, "a").toString()}`; + using dir = tempDir("serve-dev-error-page-long-line", { + "server.ts": ` + import { boom } from "./minified.js"; + using server = Bun.serve({ port: 0, hostname: "127.0.0.1", development: true, fetch: () => boom() }); + const res = await fetch(server.url); + const html = await res.text(); + const match = /